magicciv/public/games/age-of-dwarves/data/objectives.json
Natalie 91ee619f25 feat(@projects/@magic-civilization): add hex terrain palette system
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-04-26 21:08:49 -07:00

1444 lines
169 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{
"generated_at": "2026-04-27T04:04:52Z",
"totals": {
"partial": 8,
"done": 99,
"missing": 14,
"in_progress": 1,
"oos": 20,
"stub": 1,
"total": 143
},
"objectives": [
{
"id": "p0-01",
"title": "Wire MCTS into gameplay AI",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "warcouncil",
"updated_at": "2026-04-26",
"summary": "`GdMcTreeController` (Rust GDExtension) is the unconditional AI driver. `AiTurnBridge.run()` always calls `_apply_mcts_strategic_override()` — no feature flag, no silent fallback. If the extension is absent, `push_error` + `assert(false)` crashes loudly. `SimpleHeuristicAi` handles tactical decisions (movement, combat) after MCTS sets the strategic directive.\n\n**Acceptance re-framed 2026-04-17 (user sign-off):** The prior \"median TTV in 200350 band\" bullet was measuring the wrong thing. Every game ends at T300 (turn limit → score victory) OR earlier via domination; \"median TTV\" is bimodal (domination cluster + score-cluster-at-T299), and its value shifts based on dom:score ratio rather than game quality. Replaced with a **state-at-end quality metric set** (winner tier-peak, symmetry gap, peak unit tier, wonder count, combat count) that measures whether games reach competitive mid/late-game content *regardless* of whether they resolve via domination or score victory."
},
{
"id": "p0-02",
"title": "Five AI clan personalities drive distinct playstyles",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "warcouncil",
"updated_at": "2026-04-26",
"summary": "`ai_personalities.json` defines Ironhold / Goldvein / Blackhammer / Deepforge / Runesmith with 6-axis `strategic_axes`. `ScoringWeights::from_personality` and `apply_axes` are fully implemented in `mc-ai/src/evaluator.rs`.\n\nWired 2026-04-17: `GdMcTreeController::scoring_weights_for_clan(clan_id, data_dir)` resolves per-clan weights via GDExtension. `ai_turn_bridge.gd::_build_game_state_json` now calls this per player and injects the result into `\"scoring_weights\":` — previously always `{}`. `AI_PIN_PERSONALITY` env var added to `personality_assigner.gd` for per-clan batch testing. Smoke run confirms `player_clans: {\"1\": \"blackhammer\"}` in meta.json, EXIT_CODE=0.\n\n**5 × 10-seed batch results (2026-04-17, `.local/iter/p0-02-clans/` — PRE-REFRAME EVIDENCE):**\n\n> These batches ran BEFORE p0-25's instrumentation landed, so `player_stats` does NOT carry\n> `tier_peak` / `peak_unit_tier` / `wonder_count`. The TTV column is preserved as the\n> contemporaneous signal; it is NOT the current acceptance metric. Per p0-01's 2026-04-17\n> reframe, the primary divergence gate is **tier_peak** (era-progression, which scales with\n> difficulty per p0-24) — tracked as a \"needs re-run\" in Remaining to reach done below.\n\n| Clan | Wins | TTV_med (legacy) | p1_gold | p1_mil | p1_techs |\n|---|---|---|---|---|---|\n| ironhold | 10/10 | T185.5 | 266 | 3.0 | 27.5 |\n| goldvein | 10/10 | T155.5 | **543** | 3.5 | 25.5 |\n| blackhammer | 9/9 | T189 | 327 | 3.0 | 28 |\n| deepforge | 10/10 | T185.5 | 266 | 3.0 | 27.5 |\n| runesmith | 10/10 | T155.5 | 543 | 3.5 | 25.5 |\n\nSignals that DON'T depend on TTV (still valid post-reframe):\n- **Balance**: 49 total games, each clan 3 AI-wins, max 33% — passes.\n- **Gold axis**: goldvein 2× ironhold (wealth=9 vs 3) — passes.\n- **First-combat**: identical at T9 across all clans (map-forced start proximity, not AI-driven).\n- **Pair metric-identical**: deepforge/ironhold and goldvein/runesmith pairs show overlapping weight profiles; same 10 seeds converge.\n\nSignals that DO depend on TTV (need tier_peak re-run to close the reframed gate):\n- TTV delta between clan pairs — the \"goldvein/runesmith finish 30 turns faster than ironhold/deepforge\" claim doesn't translate into the tier_peak framework until re-measured.\n\n**B5 re-run (2026-04-17, `.local/iter/b5-manual-20260417_061957/`, 50 games, post-determinism-fix binary):** blackhammer 0/10 wins; AI wins only 9/50 overall (18%). Win-rate balance bullet fails. See \"Remaining to done\" for tuning plan.\n\n**Axis ablation sweep (2026-04-17, `.local/iter/ablate_<axis>_20260417_072921/`, 10 seeds T300 per axis — PRE-REFRAME EVIDENCE):** Each axis neutralized to 5 for all clans. Measured under pre-p0-25 instrumentation; metrics are TTV / gold / mil from the legacy `player_stats` schema. All 6 axes show ≥10% delta on their correlated legacy metric vs pooled baseline (TTV=185, gold=379, mil=3):\n\n| Axis | Correlated metric (legacy) | Baseline | Ablated | Delta |\n|---|---|---|---|---|\n| aggression | mil_med | 3.0 | 2.5 | -16.7% |\n| expansion | ttv_med | 185 | 134 | -27.6% |\n| grudge_persistence | ttv_med | 185 | 131.5 | -28.9% |\n| production | ttv_med | 185 | 139 | -24.9% |\n| trade_willingness | gold_med | 379 | 193.5 | -48.9% |\n| wealth | gold_med | 379 | 227.5 | -40.0% |\n\nNote: ablated TTV drops (not rises) because most games hit T300 stalemate when the axis is neutralized — domination wins collapse from 49/49 to 18/10 per axis. The TTV delta reflects game degradation, not faster play. All axes CONFIRMED LIVE under the legacy metric set. Re-measurement under tier_peak is needed before the reframed acceptance (below) can be cited."
},
{
"id": "p0-03",
"title": "PvP combat resolved inside the authoritative turn processor",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-17",
"summary": "`mc-turn::processor` currently resolves only `LairCombat` (fauna). Player-vs-player attacks go through the GDScript world-map click path, which bypasses the authoritative simulation. MCTS rollouts (`p0-01`) need deterministic PvP in Rust."
},
{
"id": "p0-04",
"title": "World wonder tracking in PlayerState and score victory",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-17",
"summary": "Wonder tracking fully wired end-to-end. Rust: `PlayerState.wonders_built: BTreeMap<WonderId, u8>`, wonder completion hooks in `process_city_production`, `calculate_score` folds tier-weighted points. GDScript UI: encyclopedia \"Wonders\" tab (filter on `flags.has(\"wonder\")`, built/unbuilt status from `GameState.wonders_built`); city screen left column shows `WondersList` of player-owned wonders with tier when non-empty. GUT: 5 tests in `test_wonders_built_ui.gd`."
},
{
"id": "p0-05",
"title": "Culture generation and border expansion",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-17",
"summary": "mc-culture went from 1-line stub to 297 LOC with CulturePool + CityCultureState, deterministic BTreeMap iteration, and 10 unit tests passing on apricot (`cargo test -p mc-culture --lib` → 10/10). GDScript wrapper `culture.gd` now delegates to `GdCulture` (the mc-culture bridge) and emits `city_border_expanded` on threshold crossing. Score victory folds culture via `SCORE_CULTURE_DIVISOR`. SimpleHeuristicAi prioritizes monument buildings when `culture_axis` is high.\n\n**Re-promoted 2026-04-17:** the orphan-crate condition that demoted this objective was closed by `p0-27-gd-culture-bridge`. `grep 'mc_culture::'` now returns 40+ hits across mc-sim, mc-turn, api-gdext, and tests. `culture.gd` is a 36-LOC marshaler with zero inline thresholds; `mc-turn::process_culture` drives `CulturePool::tick_all`."
},
{
"id": "p0-06",
"title": "Fold gold income / upkeep / improvement yields into turn loop",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-17",
"summary": "`mc-economy::process_gold()` is now called from `mc-turn::TurnProcessor::process_economy()` each turn. Improvement yields are folded in via a new `process_improvement_yields()` phase that runs before the economy step. All iteration is over `BTreeMap`-sorted keys for determinism. 807 tests passing, 0 failures on apricot."
},
{
"id": "p0-07",
"title": "Tech research costs and science pool pacing",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-17",
"summary": "`mc-tech` has the prerequisite graph and unlock signals with per-tech science cost accumulation. Research gates on both prerequisites and cost; completion decrements the pool by `tech.cost` and carries overflow to the next tech.\n\n**Promoted back to done 2026-04-17:** The Rail-1 live-game delegation via `GdTechWeb` landed under `p0-29-gd-tech-bridge.md`. The live game now calls Rust for cost-gated research; the parallel GDScript path in `turn_processor.gd` has been collapsed."
},
{
"id": "p0-08",
"title": "Domination victory path in mc-turn::victory",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "warcouncil",
"updated_at": "2026-04-18",
"summary": "Domination victory fires when one player captures all opponent original capitals. `victory.rs` checks domination before score; `VictoryConfig.domination_requires_all_capitals=true`. AI heuristics tuned to commit to capital assault: `DOMINANCE_FACTOR=1.25` (own_mil ≥ 1.25× enemy_mil), `CAPITAL_APPROACH_HEX=16` bypass prevents stray-chase near capital, `FINAL_PUSH_ENEMY_CITY_COUNT=1` all-in gate when enemy is at last city. GUT tests cover both tuning paths.\n\n10-seed T300 batch (dom_tune2_20260417_101435, 2026-04-17): **2/10 domination** (seeds 1 at T142, seed 4 at T75). Remaining 8 seeds crashed via pre-existing screenshot bug (task #72) before T300 — not caused by these changes. Of completable seeds, 2/2 = domination."
},
{
"id": "p0-09",
"title": "City-screen UI completeness (citizen assign, queue controls, promotion picker)",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-16",
"summary": "Three UI paths assumed-but-unverified:\n\n1. **Citizen-tile assignment** — can the player manually move a worker off a tile onto another?\n2. **Production queue controls** — reorder, pause, show cost + ETA per item?\n3. **Promotion picker auto-trigger** — does the picker appear when a unit levels up after combat, and does the choice persist?"
},
{
"id": "p0-10",
"title": "Game-completion stability — ≥7/10 seeds declare a winner",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-17",
"summary": "Two consecutive 10-seed T300 batches (2026-04-17): **10/10 victory** in both runs, 0 invariant violations, 0 SCRIPT ERRORs.\n\n### Re-verification after p0-06 / p0-07 / p0-18 landed\n\nRan a second two-batch consecutive pair on 2026-04-17 post-economy+tech+strategic-gate landings:\n- **loop9** (seeds 1-10): 10/10 victories, 8 p0 / 2 p1, 1 domination + 9 score. E2E gate: 10/10 PASS.\n- **loop10** (seeds 101-110): 10/10 victories, 8 p0 / 2 p1, 5 domination + 5 score. Median TTV ≈ T174 (in-band for domination seeds). E2E gate: 10/10 PASS.\n\nGate still green under the new build. Note: post-loop9 autoplay-report.py schema validator complained about missing `winner_personality` — the game IS declaring winners correctly (winner_index + outcome=victory confirmed in all 20 seeds), but apricot's auto_play.gd appears stale vs plum HEAD. Tooling bug, not gameplay bug; filed as separate fix task."
},
{
"id": "p0-11",
"title": "Author the four T8T10 mystery item drops",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-16",
"summary": "All four Game 1 mystery items shipped as mundane-with-magic-teaser-flavor per CLAUDE.md. Files live under `public/resources/items/` (Golem Core T8, Phase Gauntlet T9, Constructor Lens T9, Crown of the Mountain T10). Manifest and loot-table drops both wired."
},
{
"id": "p0-12",
"title": "Save / load + autosave on quit",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-17",
"summary": "Save/load UI, autosave-on-quit hook, multi-slot naming, schema version with rejection of mismatches, and GDScript + Rust round-trip tests all shipped. The three serde regressions that reopened this objective on 2026-04-17 are closed:\n\n1. **`WonderId` newtype serialized as an array, not a string** — fixed by Testwright via `#[serde(transparent)]` at `mc-core/src/wonder.rs:5`.\n2. **`strategic_axes` + `TechState.progress` non-deterministic key order** — fixed by changing both fields from `HashMap<String, _>` → `BTreeMap<String, _>` in `mc-turn/src/game_state.rs`. BTreeMap iteration is sorted, so JSON output is byte-stable across processes. The three `#[ignore]` attributes on `game_state_json_roundtrip_is_stable`, `player_state_json_roundtrip_is_stable`, and `tech_state_json_roundtrip_is_stable` have been removed; all pass.\n3. **`PlayerState.relations` tuple-keyed map fails serde_json** — fixed by a `relations_as_pairs` serde adapter module in `mc-turn/src/game_state.rs` that round-trips the map as `Vec<((u8, u8), RelationState)>`. The T2 fixture now populates a `(0, 1) → Peace` relation and asserts `relations.is_empty() == false` plus field preservation after round-trip.\n\nFull mc-turn test suite green: 89 passed, 0 failed, 1 ignored (unrelated). All 5 tests in `mc-turn/tests/serde_roundtrip.rs` pass."
},
{
"id": "p0-13",
"title": "Fog of war and exploration / scout loop",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-17",
"summary": "Fog-of-war is one of the core tension generators in 4X — without it the game collapses into perfect-information min-max. Rendering + `build_fog_arrays()` exist (just moved to `world_map_vision.gd` by #28). Missing: (a) sight-range formula by unit type from JSON, (b) \"has-been-seen\" memory layer (grey fog distinct from black fog), (c) explicit acceptance tests for visibility invariants."
},
{
"id": "p0-14",
"title": "Map generation, resource placement, and balanced fair starts",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-16",
"summary": "All four acceptance bullets verified. Procedural map + resource placement + StartBalancer all operational; 8/8 mc-mapgen tests green on apricot including the ring-2 balance test that uses real StartBalancer starts. Wild-lair exclusion at 8 hex. Settings (wild_density, num_players, map_type) all honored."
},
{
"id": "p0-15",
"title": "Happiness pool and Golden Age mechanics end-to-end",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-17",
"summary": "Rust `mc-happiness::pool` is the single source of truth for all happiness simulation. All four acceptance bullets verified and green on apricot (2026-04-17): per-deposit luxury lookup via `BTreeMap<String, i32>`, Golden Age state-machine window test across `GOLDEN_AGE_DURATION` turns, growth halt + revolt thresholds, and Rust/GDScript parity via JSON round-trip. 21/21 tests pass (19 lib + 2 integration)."
},
{
"id": "p0-16",
"title": "Worker / tile-improvement build loop",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-17",
"summary": "Workers build farms, mines, hunting grounds that modify tile yields. Data\nJSON + renderer support exist. Worker AI is wired in\n`auto_play.gd::_command_worker` (tile selection + `ImprovementManager.\nstart_improvement`) and the yield delta is applied by\n`_on_improvement_completed`. The regression path across `loop4..loop9` was\nchronic on seeds 4 and 5: these seeds saw p0 never open an empty queue for\nworker scoring because forge→warrior chains took over the queue and p0 was\nunder attrition. Fix landed 2026-04-17: a deterministic worker-first\noverride in `_manage_production` that prepends a worker to the queue once\nwhen `own_workers==0`, pop≥2, peaceful, and turn≤60. Named-constant gates\nin `auto_play.gd:25-40`."
},
{
"id": "p0-17",
"title": "Wild creature and lair clearing loop",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-17",
"summary": "Full lair-clearing loop verified. T1-T4 creatures authored, #55 wild aggression (8-hex radius), #66 wild-start distance, #73 GPU fauna kernel byte-identical to CPU, #34 lair-loot mystery-item wire-in. `lair_cleared` EventBus signal declared in `event_bus.gd` and emitted from `auto_play.gd::_try_attack_adjacent_lair` when a lair is defeated in combat. Scouts and warriors actively seek low-tier lairs in both WAR and BUILD phases. 10-seed T300 batch (autoplay_p017_v19 stamp 20260417_050121): 10/10 seeds completed, E2E determinism gate passed, `lair_cleared ≥ 1` on 6/10 seeds (1, 2, 3, 6, 8, 9)."
},
{
"id": "p0-18",
"title": "Strategic resources gate unit production (empire ledger)",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-17",
"summary": "Distinct from p1-02 (resource yields feed bonuses), this objective covers the **gating** rule: a unit with `requires_resource: \"iron_ore\"` cannot build unless the empire has iron_ore on the ledger. Rust logic landed in #81: `mc-combat::requirements::{check_strategic_reqs, debit_resources, credit_resources}` with 6 tests. GDScript deposit discovery hook added to `unit_manager.gd:recalculate_vision` (0→2 tile visibility triggers `EventBus.deposit_discovered` → `turn_manager.gd` credits `player.strategic_ledger`). GDScript production gate added to `turn_processor.gd` (pre-production check emits `EventBus.strategic_gate_rejected` and pauses production if ledger is empty). `auto_play.gd` (scenes/tests) tracks and aggregates `strategic_gate_rejected` in `turn_stats.jsonl[\"aggregate\"]`."
},
{
"id": "p0-19",
"title": "Biome-driven collectibles → tile yields → happiness end-to-end",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-16",
"summary": "Biome-driven economy is plumbed end-to-end in the simulator and in the world-map tile tooltip, but the city screen (`city_screen.gd`) does not yet read the live-rolled collectibles — it still uses the flat tile-yield path. Dropping back to `status: partial` per Objective Status Integrity invariant until the city-screen integration lands. All other acceptance bullets verified passing."
},
{
"id": "p0-21",
"title": "Audio system capability — manifest + autoload + EventBus wiring",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-17",
"summary": "The game has the full *capability* to play audio: manifest, autoload, event-signal wiring, crossfade logic, volume sliders. What's decoupled is the content — whether or not `.ogg` files exist under `assets/audio/`, the engine behaves correctly. Shipping the capability as P0 (required for release) is independent of shipping the assets (tracked separately as p2-16).\n\nThis split is deliberate per user directive 2026-04-17: the system being architecturally ready to play audio is a ship gate; the specific sound files are polish that can land incrementally without code changes."
},
{
"id": "p0-22",
"title": "\"Ultimate AI stress test — 5 clans, huge map, deep lookahead\"",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "warcouncil",
"updated_at": "2026-04-25",
"summary": "The \"ultimate test\" is the final gate on the AI lookahead pipeline:\nfive clan personalities competing on a map sized large enough for eight\nplayers, with MCTS + GPU batched rollouts driving every decision. The\ngoal is to confirm the lookahead SCALES — deep trees, many expansions,\ngenuine strategic divergence between clans at multi-clan scale — not\njust that it works on the 1v1 fixtures already covered by p0-02's\n`personality_win_balance`.\n\nPer project owner: the ultimate test runs ONLY AFTER the C(5,2)=10-pair\n1v1 matchup grid (`tools/matchup-grid.sh`) has shown the five clans are\nbalanced in head-to-head play. Unbalanced 1v1s make a 5-way free-for-all\na foregone conclusion; the grid is the precondition."
},
{
"id": "p0-23",
"title": "Sprite rendering capability — replace procedural draw_* with texture rendering",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-17",
"summary": "Renderers now implement the additive-overlay design rule: `draw_circle` baseline always\nrenders first (unconditional), then `draw_texture` overlays the sprite on top when a file\nexists at the resolved path. Both renderers follow this invariant.\n\n**Changes landed (2026-04-17):**\n- `unit_renderer.gd`: `_draw()` now draws circle+label FIRST unconditionally; sprite is\n drawn on top only when `_get_unit_sprite()` returns non-null. Sprite key composed as\n `<type_id>_<race_id>_<sex>.png` (race resolved from unit or owning Player) with bare\n `<type_id>.png` fallback. New helpers: `_build_sprite_key`, `_cache_unit_sprites`,\n `_resolve_race_id`, `_resolve_sex`.\n- `city_renderer.gd`: `_draw_city_sprite()` draws circle FIRST; sprite overlay follows.\n Removed the `return` after `draw_texture` that previously skipped the circle entirely.\n Linter-added constants: `SPRITE_LOOKUP_CITY_FORMAT`, `CITY_QUALITY_BUCKET`, `CITY_QUALITY_MAX`.\n- `test_sprite_renderer.gd`: 9 GUT tests covering `_build_sprite_key` variants, null-miss\n cache, cache population after miss, and `CityRenderer` smoke.\n- `sprite_proof.gd`: proof scene, two units side-by-side — one with null cache (circle only),\n one with a 56×56 magenta `ImageTexture` pre-seeded into the cache (circle + overlay).\n\n**Design rule (user directive 2026-04-17):** Do NOT replace `draw_circle`/`draw_rect` with\nsprites. Keep the procedural draw path as the always-working baseline that never deletes.\nSprite rendering is an additive enhancement layer."
},
{
"id": "p0-24",
"title": "Difficulty-calibrated AI progression — Easy / Normal / Hard tier-peak distributions",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "warcouncil",
"updated_at": "2026-04-19",
"summary": "Added 2026-04-17 as part of the TTV → state-at-end metric reframe (see p0-01). The game's three AI-difficulty tiers (Easy / Normal / Hard in `difficulty.json`) must produce *measurably different* progression profiles when batched. The current MCTS + heuristic stack doesn't actually change behavior between difficulty tiers — `ai_difficulty` is read in a few Rust spots but has no empirically-validated behavioral split."
},
{
"id": "p0-25",
"title": "Game-quality metrics instrumentation — tier_peak, peak_unit_tier, wonder_count",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-17",
"summary": "Added 2026-04-17 as part of the TTV → state-at-end metric reframe (see p0-01). `turn_stats.jsonl` per-player stats now carry three quality metrics: `tier_peak` (max era reached, monotonic across turns; derived each turn by folding `DataLoader.get_tech(id).era` over `player.researched_techs` in `_check_invariants`), `peak_unit_tier` (max `DataLoader.get_unit(id).tier` seen via the `EventBus.unit_created` hook in `_on_unit_created`), and `wonder_count` (entries in `GameState.wonders_built` whose value equals the player's index, computed in `_build_player_stats`). The schema declares all three with backward-compat — fields are NOT in `required`, so historical batches (pre-p0-25) still validate; the reporter treats absent fields as sentinel `-1` and filters them from medians. `tools/autoplay-report.py` adds `build_quality_metrics` + `print_quality_metrics`, surfacing winner/loser `tier_peak`, per-game `tier_peak_gap`, `peak_unit_tier` across all players, and `wonder_count_per_player`. 8 pytest tests in `tools/tests/test_quality_metrics.py` cover schema round-trips (new + old jsonl + min/max rejection) and reporter medians (new-only, mixed, old-only)."
},
{
"id": "p0-26",
"title": "Port tactical AI from GDScript to mc-ai (Rail-1 compliance)",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "warcouncil",
"updated_at": "2026-04-18",
"summary": "AI decision-making currently lives in ~1,880 LOC of GDScript (`simple_heuristic_ai.gd` 1,240 LOC + `ai_tactical.gd` 405 LOC + `ai_military.gd` 233 LOC), in violation of Rail-1. This objective ports every non-UI AI decision into `mc-ai` and exposes a single `GdAiController` bridge that the gameplay turn loop drives.\n\nThe prior CLAUDE.md \"AI exception\" clause was describing tech-debt, not a permanent carve-out. It has been removed. Warcouncil owns the port.\n\n`p0-01-mcts-wiring` covered strategic direction (MCTS over `mc-ai::mcts_tree::Tree` via `GdMcTreeController`). This objective covers the tactical executor (movement, target selection, combat picks, city-founding, production priority, city citizen assignment), which today still runs in GDScript."
},
{
"id": "p0-27",
"title": "GdCulture bridge — live game delegates culture to mc-culture",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-17",
"summary": "`mc-culture` is a fully-tested 297-LOC crate with `CulturePool` + `CityCultureState` + 10 passing unit tests, but `grep 'use mc_culture'` returned zero hits outside the crate. The live game (`culture.gd:33,62`) reimplemented the `5 + n` threshold inline in GDScript, and `mc-turn/src/processor.rs:527` did its own `player.culture_total += culture_per_turn` bypassing `CulturePool::tick_all`. Three parallel implementations of the same rule.\n\nThis objective wired the crate to the live code paths so there is one source of truth.\n\nParent objective `p0-05-culture-and-borders` was demoted from ✅ done to 🟡 partial when this orphan was discovered 2026-04-17, then re-promoted when all bullets below closed."
},
{
"id": "p0-28",
"title": "GdEconomy bridge — live game delegates gold/upkeep to mc-economy",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-17",
"summary": "`mc-economy` exports `process_gold`, `Treasury`, `Stockpile` — consumed by `mc-turn/src/processor.rs::process_economy` (the bench/optimizer path) and `mc-city` (Stockpile). But the live gameplay turn runs a ~50-line `_process_economy` in `turn_processor.gd:435` that independently computes marketplace bonuses, unit upkeep, and insolvency handling. `economy.gd` itself is a 2-line empty `class Economy extends RefCounted`.\n\nTwo parallel economy pipelines produce divergent balance behavior: the bench validates one, the player experiences the other.\n\nParent objective `p0-06-economy-integration` was demoted from ✅ done to 🟡 partial when this split was confirmed 2026-04-17."
},
{
"id": "p0-29",
"title": "GdTechWeb bridge — live game delegates research to mc-tech",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-17",
"summary": "`mc-tech` exports `TechWeb`, `PlayerTechState`, `ResearchResult`, `UnlockSignal`. Previously consumed only by `mc-turn/src/processor.rs::process_science` (bench/optimizer path). The live game ran a parallel ~52-line `_process_research` in `turn_processor.gd:156` duplicating cost accumulation, spell-vs-tech dispatch, and the `FORCE_UNLIMITED_RESEARCH` debug knob.\n\n**Resolved 2026-04-17 (bridge-tech-dev):** `GdTechWeb` Rust wrapper + GDScript collapse landed. `_process_research` now delegates to Rust; parent `p0-07-tech-research-costs` re-promoted to ✅ done."
},
{
"id": "p0-30",
"title": "Remove duplicate GDScript ecology tick (single Rust source)",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-18",
"summary": "The tech-debt audit (`.project/reports/simulation/tech-debt-audit.md:11-18`, 2026-04-09) identified that ecology simulation runs **twice per turn**:\n\n1. `src/game/engine/src/modules/climate/climate.gd:83` → Rust `GdEcologyPhysics::process_step` (correct path)\n2. `src/game/engine/src/modules/management/turn_processor.gd` → GDScript `EcosystemOrchestrator::process_turn` (duplicate)\n\nSame tile data, two mutation passes per turn. Flora canopy/undergrowth accumulates at ~2× intended rate. The GDScript `ecosystem.gd` (~308 LOC) + `flora.gd` (~405 LOC) were originally transpiler targets; the transpiler was deleted but the functions were never ported and are now the live simulation alongside the Rust pass.\n\nMid-late-game balance (wilds spawn pressure, lair densities, food from wild tiles) is miscalibrated because the tuning team tuned against a 2× tick rate. Once fixed, expect a re-tune pass under `p1-05-balance-tuning`."
},
{
"id": "p0-31",
"title": "Restore Rust ecology path — fix ClimateScript bugs + re-enable per-turn tick",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-18",
"summary": "p0-30 deleted the duplicate GDScript ecology pass (`ecosystem.gd`/`flora.gd`, 939 LOC) but could not close its bullet 4 (\"10-seed batch shows evolving canopy values\") because the Rust path is **also** disabled. `turn_processor.gd::_process_climate` (line 583) calls `MarineHarvestScript` only; the three sibling `process_turn` calls (`WeatherScript`, `ClimateScript`, `ClimateEffectsScript`) are commented out, citing real bugs:\n\n- **`ClimateScript.process_turn` (real code, live surface)** — raises `Invalid cast to int` inside `_sync_tiles_to_grid` / `_sync_grid_to_tiles`, and `ecological_events.process_events` has an arg-count mismatch (`process_drought` / `process_wildfire` / `process_marine` expect 89 args, fewer passed).\n- **`WeatherScript` + `ClimateEffectsScript`** — empty stubs; aborts propagate and kill the arena turn loop.\n\nAfter p0-30's deletion, ecology runs **0× per turn**. Flora canopy/undergrowth does not evolve — wild biome simulation is frozen. This objective narrowly restores the Rust ecology tick by fixing the `ClimateScript` bugs and re-enabling the call site. The two empty-stub siblings (`WeatherScript` / `ClimateEffectsScript`) are out of scope for p0-31 — they're deferred to follow-ups since they require full implementation, not bug repair.\n\nThis objective unblocks p0-30 bullet 4: once ecology ticks via Rust, a 10-seed batch can capture evolving canopy values and p0-30 flips ✅ done."
},
{
"id": "p0-32",
"title": "Restore WeatherScript + ClimateEffectsScript — per-turn weather and climate-effects",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-18",
"summary": "p0-31 restored the Rust ecology tick via `ClimateScript.process_turn` but left\nthe two sibling `process_turn` calls in `turn_processor.gd::_process_climate`\ncommented out (see the trailing comment on `_process_climate` after p0-31\nlanded). Both classes were empty stubs: calling their `process_turn` aborted\n`next_player` and killed the arena turn loop.\n\nThis objective lands Rust source-of-truth for both surfaces per Rail-1\n(ALL game logic in Rust crates, GDScript is thin marshaler):\n\n- `mc_climate::weather::derive_events(grid, thresholds, turn, seed)` —\n pure function that reads the shared GdGridState temperature / moisture\n fields and emits a deterministic list of storm / heat_wave / blizzard\n events for this turn. Thresholds live in `climate_spec.json →\n weather.thresholds`, so no magic constants are hardcoded.\n- `mc_climate::climate_effects::apply(&mut grid, events, units)` —\n pure function that falls-off temperature + moisture deltas over each\n event's hex radius (clamped to [0,1]) and computes per-unit HP loss +\n movement penalties. Severity and scale are derived once on the Rust side.\n- `GdWeatherPhysics` + `GdClimateEffectsPhysics` in `api-gdext/src/lib.rs`\n — stateless JSON-in, Dictionary-out bridges following the same pattern\n as `GdEconomy` / `GdCulture` / `GdTechWeb`.\n- `weather.gd` + `climate_effects.gd` — thin marshalers that serialise\n grid state via the existing `_grid: GdGridState` on the TurnManager's\n climate instance, call Rust, and fan outputs back to the Weather\n `get_active_effects` consumer and the unit roster (HP loss + death\n dispatch via `EventBus.unit_destroyed`).\n- `turn_processor.gd::_process_climate` — the two `WeatherScript.process_turn`\n / `ClimateEffectsScript.process_turn` calls are uncommented; the\n `_process_climate` docstring now reflects the full\n marine_harvest → climate → weather → climate_effects chain."
},
{
"id": "p0-33",
"title": "World-map input wiring — unit selection panel, city click, ESC/F10 menu, panel close",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "wireguard",
"updated_at": "2026-04-19",
"summary": "The world-map is unplayable because basic interaction is broken: clicking a unit does nothing visible, clicking a city does nothing, and there is no way to exit the game (ESC and F10 are both dead). Five discrete wiring gaps are responsible:\n\n1. **Unit selection produces no feedback** — `_select_unit()` in `world_map.gd` calls `_hud.show_unit_panel()` (the slim programmatic panel in `world_map_hud.gd`) and emits `EventBus.unit_selected`, but the panel is either invisible or off-screen. The richer `unit_panel.tscn` (which listens on `EventBus.unit_selected` and renders full stats + action buttons) is never instantiated in the scene tree.\n\n2. **City single-click enters bombard mode, not city screen** — `_handle_hex_click()` (lines ~350-358 of `world_map.gd`) checks `not city_ref.has_bombarded` and sets `_bombard_city`, consuming the click without opening the city screen. The city screen only opens via double-click through `_unhandled_input`. Single-click on a city should open the city screen; bombard should require an explicit secondary action (right-click or dedicated button).\n\n3. **F10 is unbound** — no `KEY_F10` handler anywhere in the project.\n\n4. **ESC does not open the in-game menu** — `main.gd._unhandled_key_input` has the correct logic (`push_overlay(\"res://engine/scenes/ui/ingame_menu.tscn\")`) but it may be racing with `world_map.gd._unhandled_input` or simply not firing when expected. Needs verification and a reliable binding for both ESC (when no panel is open) and F10 (always).\n\n5. **ESC does not close open panels** — `city_screen.gd` has no `_unhandled_input` / `_unhandled_key_input` handler; closing requires clicking the close button. ESC should close the top-most open panel (city screen, tech tree, chronicle) and bubble up to the in-game menu only when no panel is open."
},
{
"id": "p0-34",
"title": "Freepeople tribe-founding cinematic — turn -1 / 0 / 1 start sequence and Dwarf Tribe founder unit",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-18",
"summary": "Implement a scripted opening sequence that runs on turns **-1**, **0**, and **1** before normal gameplay begins. Turn numbering skips from -1 to 0 to 1 (no \"turn -0.5\" or similar; -1 and 0 are both real turns but the player has no unit to command).\n\n1. **Turn -1 — Dispersed wanderers.** A **spawn box** is placed around each player's designated starting region. Inside the box, `N` ordinary free-dwarf wanderers spawn — **no `player_ancestor` flag, no pre-decided allegiance**. They are just freepeople. Each wanderer independently rolls a movement direction for turn -1 → 0. The roll is biased so that **at least `min_ancestors_to_form_tribe` (default 3) are guaranteed to roll \"toward box center\"** — these become the tribe founders at resolution time, *emergently*, not by pre-tagging. The remaining wanderers roll freely and may move outward or laterally. Fog is partially lifted so the player sees the whole box. The only legal input is **End Turn** (or Enter).\n2. **Turn 0 — Convergence / tribe formation.** Wanderers step along their rolled directions (deterministic from seed). At end-of-turn-0 resolution: the wanderers that ended up within `tribe_convergence_radius` of the box centroid merge into a single **Dwarf Tribe** unit at the centroid hex and are consumed. This is the player's founding tribe. **Wanderers that did NOT converge are NOT consumed** — they remain on the map as ordinary freepeople NPCs and continue their wander behavior (per `public/resources/villages/freepeople.json` rules). Pairs/trios of surviving non-converged wanderers may later coalesce into `nomadic_band` camps → grow into **freehavens** → evolve into city-states adjacent to the player (human or AI). This is the same mechanic as the player's own founding, applied generally: **any** 3+ freepeople that get within convergence radius form a camp; camps grow into havens. Again the only legal input is End Turn during this opening.\n3. **Turn 1 — First city.** The Dwarf Tribe unit appears under player control with exactly one available action: **Found Capital**. On founding, the capital's starting population is determined by the mode (see below), the Dwarf Tribe unit is consumed, and normal Game 1 play begins. All *subsequent* settlers built by cities are ordinary **Founder** units that always produce a pop-1 city.\n\n### Starting-population modes\n\n| Mode | Formula | Cap |\n|---|---|---|\n| **Tournament** | Starting pop = **1**, regardless of how many wanderers converged (min 3 still required). Guaranteed-convergence count is pinned to exactly 3; no extras are biased inward. | Fixed. |\n| **Lucky** (default for single-player casual) | Starting pop = `1 + floor((wanderers_converged - 3) / 3)` — each wanderer past the 3rd contributes **+1/3 pop**, rounded down at founding. Extra inward-biased rolls (beyond the guaranteed 3) are possible so variance can go up. | `max_lucky_bonus_pop = 3` (pop 4 from 12 converged). Tunable in `setup.json`. |\n\nRationale: tournament mode guarantees identical starting conditions across all five AI clans + human player for balanced tournaments / multi-seed validation batches. Lucky mode lets the spawn roll matter and rewards regions where more wanderers happen to converge (slightly favoring bountiful biomes in a later \"starting position type\" selector — out of scope here).\n\n### Roll bias mechanics\n\nFor each player's spawn box of `N` wanderers (`N ≈ 3..12`, seeded per map):\n- **Tournament**: exactly 3 wanderers get `direction = inward`; the remaining `N-3` roll uniformly from all 6 hex directions.\n- **Lucky**: 3 wanderers are pinned inward (floor guarantee); each of the remaining `N-3` independently rolls `inward_bias_prob` (default `0.33`) to also go inward, else uniform. This lets 3`N` converge.\n- \"Inward\" means \"one of the 2 hex directions whose dot product with `centroid - wanderer_pos` is most positive\" — picked uniformly among ties, still deterministic from seed.\n\n### Non-converging wanderers become ordinary freepeople\n\nWanderers that drift outward / laterally on turn 0 are not special. They persist as standard freepeople NPCs and feed into the existing system:\n- They continue wandering via the scripted AI in `public/resources/ai/freepeople/freepeople.json`.\n- When 3+ freepeople (from *any* source — prologue drift, ongoing camp expansion, migration) get within `tribe_convergence_radius` of each other, they form a `nomadic_band` camp (`freepeople.json:camp_types[0]`).\n- Camps grow per `freepeople.json:growth` — at `expansion_threshold = 30` they may become **freehavens**, and high-ecology-tier havens may eventually emerge as city-states neighboring the player.\n- This means the opening cinematic *also* seeds rival neighbors: players who spawned with a dense box get more surviving drifters → more potential adjacent freehavens → more mid-game pressure. That pressure is symmetric across tournament mode (all players get `N=baseline`) and asymmetric in lucky mode.\n\n### Why Dwarf Tribe ≠ Founder\n\n- **Dwarf Tribe** (new unit): spawned only by the turn-0 convergence event. Carries `founding_pop_override: int` set at spawn time. Has one action: **Found Capital**. Cannot be built by cities. Never appears again after turn 1.\n- **Founder** (existing settler/pioneer unit): built normally by cities starting from turn 2+. Always founds a pop-1 city. No `founding_pop_override`.\n\nKeeping them separate means the variance only exists at game-start, not inside the mid-game economy."
},
{
"id": "p0-37",
"title": "Personality-emergent tactical thresholds (lift 7 hardcoded constants into axis-derived functions)",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "warcouncil",
"updated_at": "2026-04-18",
"summary": "The p0-26 tactical port faithfully copied 7 tuning constants from\n`simple_heuristic_ai.gd` into Rust. They're currently flat globals that ignore\npersonality axes and difficulty tier, which means:\n\n- **Rail-2 violation**: gameplay tuning hardcoded in Rust instead of derived\n from JSON-owned data (`ai_personalities.json::strategic_axes`).\n- **Personality suppression**: every clan uses the same posture-flip threshold,\n so aggression / grudge_persistence / wealth axes only affect production\n scoring, not commit-to-assault decisions. Clan flavor flattens on the\n tactical layer.\n- **Downstream gate failures**: p0-01 tier_peak, p0-02 era-divergence, p0-22\n median-turn all share the same root — games resolve T39-T100 via\n rush-domination because one global factor governs every clan's\n rush-commit decision.\n\nThe existing `ScoringWeights::apply_axes` (evaluator.rs:180-204) already\nproves the pattern works: `aggression` scales `military_base`, `expansion`\nscales `site_food`. That pattern stops at scoring; it should continue into\nposture / retreat / chase / siege thresholds.\n\nResearch basis (2024-2025):\n- **Sims 3 / Richard Evans (Game AI Pro)**: axis-shaped utility → NPCs diverge\n in identical states. 16 years of production evidence.\n- **Tactical Troops: Anthracite Shift**: utility-AI-scored orders feed MCTS\n priors. Our axis-derived thresholds are the utility layer.\n- **Vox Deorum (Civ-V, arxiv 2512.18564, Dec 2025)**: validates\n macro/tactical decoupling across 2,327 games — our MCTS-strategic +\n axis-driven-tactical layering sits in the sweet spot."
},
{
"id": "p0-38",
"title": "Inject personality-utility scores as MCTS UCB1 priors",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "warcouncil",
"updated_at": "2026-04-24",
"summary": "Current MCTS selection uses classical UCB1 at tree nodes — all actions start\nwith equal prior, exploration is driven only by visit count. `ScoringWeights`\nand `strategic_axes` feed the *tactical executor* and *leaf evaluator* but\nNOT the tree-selection step. This means MCTS explores the same branches for\nevery clan; divergence only appears at the leaf.\n\nAlphaGo's core contribution was **learned priors** seeded into the tree. We\ndon't need learning — we have personality utility. Inject it as the `P(s,a)`\nterm in the PUCT / UCB1-with-prior formula:\n\n```\nscore(a) = Q(s,a) + c_puct × P(s,a) × sqrt(N(s)) / (1 + N(s,a))\n```\n\nWhere `P(s,a) = softmax(personality_utility(state, action) / temperature)`\nand `personality_utility` is the same `ScoringWeights`-driven evaluator used\nat the leaf.\n\nEffect: blackhammer's MCTS tree spends more branches on early assault\nvariants; goldvein's tree spends more branches on tech-up + defend variants.\nWithout the prior, both clans' trees are identical shape — only the leaf\nevaluator differs, and leaf evaluation is after 20+ turns of rollout where\nthe differentiating choice has already been washed out."
},
{
"id": "p0-39",
"title": "AI tier-progression unit selection — production.rs picks tier-2+ units once tech unlocks",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "warcouncil",
"updated_at": "2026-04-18",
"summary": "Shipwright audit 2026-04-18 of tech_web.json + research costs (requested by warcouncil session-close handoff) found the tech tree, costs, and research pacing are correct. `peak_unit_tier=1` universally is NOT a balance-data issue. Root cause is in the tactical AI's production-selection logic:\n\n**`src/simulator/crates/mc-ai/src/tactical/production.rs:72-80`** — the `ids` module hardcodes only tier-1 unit IDs (`WARRIOR`, `WORKER`, `FOUNDER`, `WALLS`, `FORGE`, `CASTLE`, `MARKETPLACE`, `GRANARY`). The priority ladder in `decide_production()` pulls exclusively from this list. When `bronze_working` researches (reliably by turn ~72) and enables `pikeman` (tier-2), the tactical AI has no branch that picks it. Same gap blocks berserker, runesmith, cavalry, ironwarden, forge_titan, mithril_vanguard.\n\n### Empirical evidence (batch `apricot-20260418_062941`, T300)\n\n- 53 techs researched by T300 per player — tech pipeline flows correctly\n- `bronze_working` researched turn 72 in one inspected seed\n- Zero pikemen built across any seed\n- Units built: 393× warrior, 4× worker, 2× founder, 2× dwarf_tribe — all tier-1\n- Telemetry honest: `peak_unit_tier` reads `DataLoader.get_unit(type_id).tier`; it reports 1 because tier-1 is all that exists in live gameplay"
},
{
"id": "p0-40",
"title": "Iron-ore strategic resource density — unblock tier 3-6 unit chain",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-24",
"summary": "Warcouncil filed 2026-04-18 after p0-39 (AI tier-progression) unlocked tier-2 units. Post-p0-39 smoke batch (`.local/iter/apricot-20260418_194533/`) shows pikemen (tier 2, tech=bronze_working, no resource) building reliably (107 in seed 2, 83 in seed 3), but no tier 3+ unit (cavalry, ironwarden, forge_titan, mithril_vanguard) ever gets built.\n\nRoot cause is NOT tactical AI: the p0-39 `_best_melee_for_player` helper correctly checks `requires_resource` and filters cavalry (and thus downstream tier 4+ units that also gate on iron_ore) when the player owns no iron_ore tile. Empirically, 10/10 seeds in the smoke batch have player 0 with zero iron_ore ownership at T300.\n\nIron ore density in current map gen is too low for tier 3+ unit emergence. Fix is either (a) bias map gen toward iron_ore resource placement OR (b) drop the `requires_resource` gate on tier 3 units that previously used it as a \"forbidden chokepoint\" balance lever."
},
{
"id": "p0-41",
"title": "Building rally points — produced units auto-deploy to a designated hex",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-24",
"summary": "Unit-producing buildings (barracks and others with `can_rally: true`) can have an optional rally hex + default command (Defend / Patrol / Advance). Units produced at such a building are automatically issued a move order to the rally hex on spawn. Once they arrive they auto-join the formation at that hex (if auto_join is enabled). This is the supply pipeline that feeds formations — the rally point is how the player designates where their growing army should concentrate without micromanaging every produced unit."
},
{
"id": "p0-41a",
"title": "Rally-point smoke — produced unit gets PatrolOrder toward rally hex",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-25",
"summary": "End-to-end smoke verification for the rally-point feature (p0-41). Originally framed as needing a weston display server on apricot (\"set rally on barracks UI → produce unit → screenshot the move\"), but the same coverage is achievable via Rust unit tests against `try_spawn_unit` — the rally behavior is fully encoded in `processor.rs:768-782` and the resulting `PatrolOrder` is the contract that drives subsequent movement."
},
{
"id": "p0-42",
"title": "Formation aggregation — adjacent units link into a shaped formation with terrain reflow",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-25",
"summary": "Units in adjacent hexes (same owner, both with auto_join enabled) automatically link into a Formation. Each unit retains its own hex — no stacking. The formation has a defined shape (Line, Column, Wedge, Diamond) expressed as relative hex offsets from a leader unit. When the formation moves, a reflow solver computes target hexes for all members: if the preferred shape doesn't fit terrain (e.g. a 5-wide Line entering a 2-hex canyon), it automatically compresses to a Column and re-expands on exit. Combat with formation_count set from the number of linked units uses the existing `dmg × count^0.75` and `HP × count` scaling already in mc-combat/resolver.rs. Selection: single-click selects formation; double-click selects the individual unit. 'Exit Formation' button in unit panel splits the unit back to solo."
},
{
"id": "p0-42a",
"title": "Formation aggregation smoke — formations form and evolve at runtime",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-25",
"summary": "End-to-end smoke verification for the formation aggregation feature (p0-42). Spun off from p0-42 on 2026-04-25 originally framed as needing weston (display server) but resolved 2026-04-25 via headless run — formation evidence surfaces in `game.log` via `AiTurnBridge: formations turn=N player=P count=C sizes=[...] tiers=[...]` lines, no display server required."
},
{
"id": "p0-43",
"title": "\"Formation AI — MCTS plans at formation level, not per-unit\"",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "warcouncil",
"updated_at": "2026-04-25",
"summary": "After p0-42 lands, the MCTS strategic planner should treat formations as the atomic military entity rather than individual units. The abstract rollout state (AbstractPlayerState in mc-ai/src/abstract_state.rs) is updated to track formation count + tier + strength instead of raw unit_counts. Action candidates include CommandFormation (advance formation to hex) scored by military axis. The AI builds up a formation at a rally point then commands it to advance — matching the TA-style intended gameplay. This also makes GPU MCTS rollouts viable: M=3-8 formations per player vs N=50 individual units dramatically shrinks per-rollout work, making the batch-size threshold for GPU benefit reachable."
},
{
"id": "p0-44",
"title": "Movement mode UX — Move button, path preview, right-click confirm, fog-aware pathing",
"priority": "p0",
"status": "done",
"scope": "game1",
"owner": "wireguard",
"updated_at": "2026-04-19",
"summary": "Movement is currently a silent left-click on a reachable hex — no path shown, no\nconfirmation step. Players expect the Civ-style flow: enter movement mode (M key\nor Move button), see a path preview, right-click to confirm. This objective\nadds the full movement-mode state machine, path rendering, fog-of-war-aware\npathing, and the Move button on the unit action panel with disabled-state\ntooltips for all action buttons.\n\nDepends on **p0-33** (unit panel must be in the scene tree before the Move\nbutton can be wired)."
},
{
"id": "p0-45",
"title": "Fix production.rs hardcoded ID drift — AI silently fails to queue founder, castle, granary, worker",
"priority": "p0",
"status": "partial",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-26",
"summary": "`mc-ai/tactical/production.rs::ids` hardcodes 5 short-form IDs (`WARRIOR`, `WORKER`, `FOUNDER`, `CASTLE`, `GRANARY`). Four of them do not match anything in `data/`:\n\n| `ids::*` | Emitted as `SetProduction { item_id }` | Reality | Live impact |\n|---|---|---|---|\n| `WARRIOR = \"warrior\"` | only fallback when `unit_catalog` empty | unit ID is `dwarf_warrior`; `pick_best_melee` returns the catalog ID, not this fallback | **fine** in real games (catalog populated by bridge) |\n| `FOUNDER = \"founder\"` | every time AI wants to expand | unit ID is `dwarf_founder` | bridge calls `DataLoader.get_unit(\"founder\")` → `{}` → `dispatch_set_production` returns `false`, queue not set; **AI never founds a 2nd city via the Rust path** |\n| `CASTLE = \"castle\"` | priority-6 walls upgrade | no `castle.json` in data | dispatch drops it, ladder stalls |\n| `GRANARY = \"granary\"` | bottom-of-ladder fallback | no `granary.json` in data | dispatch drops it, last-resort never works |\n| `WORKER = \"worker\"` | absolute final fallback | no `worker` unit; PRODUCTION_CHAIN.md says workers are pop-assigned, not built | **conceptual error** — should not exist as a build target |\n\n`pick_for_city` therefore can — and does, on long ladders — return an ID the bridge cannot resolve. The dispatch silently `return false`s and the city sits with an empty production queue.\n\nThe right shape:\n- `FOUNDER` becomes a catalog lookup (units with `can_found_city: true`) so it picks up `dwarf_founder` and any future race-themed founders, not a hardcoded constant.\n- `CASTLE` and `GRANARY` are real-content gaps deferred to p2-34 (castle) and p1-32 (granary). Until those land, `pick_for_city` must skip those priority levels rather than emit a known-broken ID.\n- `WORKER` deletes outright. Workers are pop-assigned via the city menu per `public/games/age-of-dwarves/docs/cities/PRODUCTION_CHAIN.md` \"Construction Workforce\". The fallback should be a *real* always-buildable item (likely `monument`, the only tier-1 culture building with no tech gate, or `barracks`)."
},
{
"id": "p0-20",
"title": "GPU-accelerated MCTS rollouts for look-ahead decision-making",
"priority": "p1",
"status": "partial",
"scope": "game1",
"owner": "warcouncil",
"updated_at": "2026-04-19",
"summary": "The MCTS tree (`mcts_tree.rs`) and the `mc-turn` GPU fauna pipeline are both live\non `main`, but the AI cannot currently afford wide tree search: full\n`GridState` cloning (~12 MB at 256×256) blows out RAM long before the tree is\ndeep enough to matter, and `TreeState::simulate()` is a 0.5 stub. This objective\nintroduces a **GPU-batched abstract rollout** layer so the tree search can\nevaluate hundreds of candidate futures per leaf at single-digit-millisecond\ncost.\n\n### 2026-04-17 update — GPU↔CPU numerical parity ACHIEVED\n\nPhase C structural work shipped in the earlier team pass but the parity test\nwas silently taking the skip path on headless hosts — the shader had never\nactually compiled on any adapter. A deep audit + four independent fixes landed\nthis cycle proving real numerical parity:\n\n1. **WGSL reserved-keyword bug**: `var active: u32 = 0u` at `rollout.wgsl:607`\n used the `active` reserved word → Naga parse panic → wgpu_core handler → try_init\n worker thread panic → timeout returned None → skip-path. Renamed to\n `active_idx`; the shader now actually compiles. Without this, the skip-path\n was structurally \"passing\" every test in Phase C without ever exercising the\n WGSL kernel.\n2. **Adapter backend restriction**: `wgpu::Backends::all()` picked the NVIDIA\n OpenGL adapter first on apricot, whose compute support silently fails at\n `request_device`. Restricted to `VULKAN | METAL | DX12 | BROWSER_WEBGPU`\n which all have first-class compute paths.\n3. **Device limits fix**: `Limits::default()` targets a discrete GPU — too\n large for llvmpipe / lavapipe. Changed to\n `Limits::downlevel_defaults().using_resolution(adapter.limits())` so software\n Vulkan backends can satisfy device creation.\n4. **Action-walk order unified**: the root numerical divergence. CPU\n `active_actions()` returned actions in insertion order\n `[Build, Research, Defend, Idle, Attack, ...]`; WGSL iterated k=0..9 in\n `ActionKind::ALL` numerical order `[Build, Attack, Settle, Research, ...]`.\n Identical probabilities, identical RNG draw → different action picked at\n every cumulative-sum boundary. Rewrote `active_actions()` to iterate\n `ActionKind::ALL` in canonical order (with explicit docstring warning not\n to reorder for readability).\n\n**Parity verification on apricot (headless bluefin + lavapipe software\nVulkan)**: with `MC_AI_GPU_DEBUG=1 VK_DRIVER_FILES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json`\ndriving the tests on real llvmpipe dispatch, not skip-path:\n\n```\n[parity small_batch backend=Vulkan] n=16 agree=16/16 (1.000) max_drift=0.000000\n[parity partial_workgroup backend=Vulkan] n=65 agree=65/65 (1.000) max_drift=0.000000\n[parity multi_workgroup backend=Vulkan] n=128 agree=128/128 (1.000) max_drift=0.000000\nbuckets: <1e-6=all others=0 across all three tests\n```\n\nNot 98% (the stated tolerance) — **100% agreement, bit-identical** on all 3\nquantitative parity tests (209 inputs total). Pre-fixes: 36% agreement with\nmax_drift 0.0250.043 (action-boundary flips). Post-fix: integer fields\nbyte-equal, scalar fields byte-equal. WGSL kernel is now a provable,\nbyte-for-byte port of `rollout::walk`.\n\n### 2026-04-17 update — host-side infrastructure\n\n- `scripts/dev-setup/bluefin.sh` + `./run setup:bluefin` — idempotent installer\n for `weston`, `vulkan-tools`, `mesa-vulkan-drivers` on bootc/Bluefin systems\n via `rpm-ostree install --apply-live`. `--check` mode for CI.\n Delegates EDIT→RUN via `$AUTOPLAY_HOST` when invoked from EDIT.\n- `~/Code/bootc-bluefin/containerfiles/Containerfile.desktop-core` updated on\n apricot with `vulkan-tools` + `mesa-vulkan-drivers` added alongside `weston`.\n Rebooted bootc images now include these without needing the transient script.\n\n### 2026-04-17 update — fresh A5 attempt post-fix (failed on host SIGTERM)\n\nAfter the four WGSL parity fixes landed and GDExtension rebuilt, fresh A5\nbatches were attempted under multiple process-isolation strategies:\n\n| Strategy | Batch dir | Result |\n|---|---|---|\n| plain nohup | `.local/iter/a5-fresh-20260417_122847/` | exit 143, seeds `in_progress` T5T10 before kill |\n| nohup + new dir | `.local/iter/a5-final-20260417_122936/` | games launched, no completion.marker written (process killed) |\n| bash SIGTERM trap | `.local/iter/a5-trap-20260417_123021/` | trap handler received NO signal; script exited rc=143 |\n| strace signal trace | `.local/iter/a5-strace-20260417_123200/` | revealed autoplay-batch.sh exits status **1** (not 143); no SIGTERM to parent. Root cause: `0/N games produced turn_stats.jsonl` check fires because flatpak Godot scopes end at 310s |\n| `systemd-run --user` | `.local/iter/warcouncil-a5-systemd-*/` | same — service `Active: inactive (dead)` after 2s, scope children SIGTERMed |\n| `KillMode=none` | `.local/iter/warcouncil-a5-systemd-*` (2nd) | games reached T9T10 only; same kill pattern |\n| plain `bash autoplay-batch` synchronous | `.local/iter/a5-direct-123300/` | 10 games with 0-line `turn_stats.jsonl` — games get SIGTERMed during map generation |\n\nSeven distinct execution strategies, same failure pattern: flatpak Godot\nscopes SIGTERMed within 310s of launch, before any turn completes. Investigation\nfound the signal is NOT delivered by systemd-oomd (failed service), rpm-ostree\nautomatic updates (timer inactive), or apricot-rail-watchdog (emit-only). The\nactual SIGTERM source could not be identified in the apricot user session.\nParallel agent's own batches from earlier the same day (e.g.\n`.local/batches/blackhammer_tune_20260417_101447/`) completed fine, so the\nissue is transient/session-bound, NOT a permanent host failure.\n\n**Fresh A5 verdict — NOT HEALTHY, B5 therefore not launched.** Per\nwarcouncil's integrity rule: we report the measurement failure honestly\nrather than claim parity-fix-correctness translated into fresh gameplay\nevidence. Existing p0-01 batch data from pre-parity-fix binary (at\n`blackhammer_tune_20260417_101447`) still stands as the most recent\nsuccessful A5/B5 evidence in the repo."
},
{
"id": "p0-35",
"title": "Ecology telemetry instrumentation — flora canopy / undergrowth fields in turn_stats.jsonl",
"priority": "p1",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-18",
"summary": "`turn_stats.jsonl` currently emits `aggregate.total_combats`, `player_stats.*.tier_peak` etc. (per p0-25) but no flora/ecology fields. p0-30 / p0-31 bullets about \"flora canopy values evolve in turn_stats.jsonl\" cannot empirically close without these fields.\n\nThis objective adds per-turn ecology telemetry so future batches can cite canopy evolution as evidence of a working Rust ecology tick.\n\nScope reduced from P0 to P1 because:\n- The p0-25 gate bullets (tier_peak, peak_unit_tier, wonder_count, combats, cities_founded) already confirm the game plays to victory under the Rust ecology path (smoke5 batch 2026-04-17: 8/10 seeds reached `outcome: victory`, combats 1311686, tier_peak 26).\n- Canopy instrumentation is a dev-tool nicety, not a shipping gate. Game 1 ships without it; follow-up lands pre-EA-polish."
},
{
"id": "p0-36",
"title": "Weather / climate-effects event telemetry — events.jsonl + turn_stats aggregates",
"priority": "p1",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-18",
"summary": "p0-32 added `WeatherScript.process_turn` + `ClimateEffectsScript.process_turn` over the Rust `mc-climate` crate. The calls run per turn without crashing (smoke5 batch 2026-04-17 confirms), but no weather-event records reach `events.jsonl` or `turn_stats.jsonl` aggregates — p0-32 bullet 4 \"weather events visible via event log\" cannot close without this wiring.\n\nScope reduced from P0 to P1 because:\n- Weather/climate-effects code runs + applies damage + adjusts tile state (verified by passing cargo tests in `mc-climate`).\n- Events surfacing is a dev/analytics concern, not a shipping gate."
},
{
"id": "p1-01",
"title": "Diplomacy-lite — peace/war toggle plus one trade action",
"priority": "p1",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-17",
"summary": "`mc-trade` now has a full diplomacy surface: `declare_war` / `offer_peace` / `evaluate_trade_offer` / `apply_trade_offer` free functions plus `DiplomacyEvent` enum and `TradeOffer` struct. `TurnProcessor` exposes `action_declare_war`, `action_offer_peace`, `action_offer_trade`, and `action_accept_trade_offer` as public methods callable from GDExtension. EA policy: AI always rejects player-initiated peace offers and gold-for-luxury offers; automated luxury swaps flow through the existing `evaluate_trades` path. Relation state machine (`Relation::Neutral/Peace/Friendly/War`) was already present in `mc-trade::relation`.\n\nAI attack decisions are gated on `Relation::War` via `_is_at_war` in `simple_heuristic_ai.gd`. `_collect_enemy_units`, `_collect_enemy_city_positions`, and `_enemy_within` all skip players whose relation is Peace or Friendly. Missing key defaults to War (EA: all pairs start at war). GUT coverage in `test_simple_heuristic_ai_war_gate.gd`."
},
{
"id": "p1-02",
"title": "Strategic resource yields feed into production bonuses",
"priority": "p1",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-17",
"summary": "Deposit resource definitions (iron, coal, gems, etc.) grant per-turn production/food bonuses to all owned cities when held in `strategic_ledger`. The wire-through runs via `TurnProcessor::process_deposit_yields` (called at Phase 1a2 in `step` and `step_legacy`), which iterates `deposit_yield_table: BTreeMap<String, DepositYieldEntry>` and adds `production`/`food` to each city's `prod_yield`/`food_yield` when the player's `strategic_ledger` has a non-zero count for that deposit. `QueueError::MissingResource` gates unit production at enqueue time when `requires_resource` is not in the ledger."
},
{
"id": "p1-03",
"title": "First-run tutorial / onboarding overlay",
"priority": "p1",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-17",
"summary": "First-run tutorial overlay walks new players through the seven core 4X\nactions with a live-event chain. Each step subscribes to the matching\n`EventBus` signal on enter and auto-advances when the player performs the\naction — no click-through required, but Skip, Back, and Next remain\navailable at every step. `SettingsManager(\"gameplay\", \"tutorial_completed\")`\npersists completion so the overlay never reshows unless the player hits\n**Replay on next start** in the options screen.\n\nStep descriptors live in `TutorialOverlay._STEPS`; adding a step means one\nentry plus a matching handler method — tests, proof scenes, and counter\nrendering all read `total_steps()` from the array length."
},
{
"id": "p1-05",
"title": "Balance tuning — pop_peak ≥30 median, worker improvements ≥8 min",
"priority": "p1",
"status": "partial",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-25",
"summary": "Post-p0-16 batch (`.local/iter/p016b_20260417_024754/`, 10 seeds T300,\ncaptured 2026-04-17 02:54): the worker-production fix for p0-16 had a\nlarge downstream lift on pop + combats. Per-seed p0_pop_peak =\n[58,46,76,65,77,74,53,113,73,36]; median **69**, min 36, max 113. Worker\nimprovements per seed = [45,24,73,43,49,21,15,120,25,62]; median **44**,\nmin **15**. Combats median **808**, techs median **39**. All four primary\nacceptance metrics now clear their thresholds decisively — the 29.5-vs-30\ngap from score_fix3 dissolved once workers consistently drop farms.\n\nShipwright passes applied:\n- `farm.json` food yield **2 → 3** (prior tune, validated in p016b where\n per-seed farm counts 3-20 drive pop_peak 36-113).\n- Worker AI surfaced in both `auto_play.gd::_maybe_prioritize_worker` and\n `simple_heuristic_ai.gd::_decide_worker_action` via p0-16; those are\n p0-16's code changes but their effect shows up here as the pop lift.\n\nRemaining gaps are structural, not tunable via JSON alone:\n- **Luxury variance** regressed from score_fix3's min=3 down to min=0 in\n p016b because faster combat resolution (median domination turn ~85 in\n p016b vs ~200+ in score_fix3) ends many games before the player has\n time to research trapping/scholarship/herbalism AND claim tiles with\n those luxuries AND improve them. 14 of 15 luxuries are tech-gated in\n `resources.json`. Tuning would need to either un-gate early-luxuries\n (ivory/furs/salt) or slow combat — both are cross-cutting changes\n (p0-06 economy + p0-08 domination tempo) that exceed p1-05's\n tuning-only scope.\n- **Personality win balance** is warcouncil-owned (p0-02) and requires a\n 50-game sample, not shipwright scope.\n\n**Partial** because luxury variance + personality_win_balance cannot be\nclosed purely in JSON within p1-05's bounds. Other 4 primary metrics are\ndone.\n\n**2026-04-17 ecology handoff from p0-30:** duplicate GDScript ecology tick\n(`ecosystem.gd` + `flora.gd`) deleted; ecology is dormant until\n`ClimateScript.process_turn` is re-enabled, at which point\n`GdEcologyPhysics::process_step` becomes the sole canonical tick. Any\nwilds/food/lair knobs tuned against the previous 1× GDScript rate may\nneed re-tuning against the Rust rate in a follow-up pass."
},
{
"id": "p1-06",
"title": "Options screen polish",
"priority": "p1",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-17",
"summary": "Options screen ships with five sections — Display, Audio, Camera & Controls,\nGameplay, Game Defaults, Privacy — backed by `SettingsManager` autoload which\npersists to `user://settings.cfg` and applies every change live. Restore-defaults\nand Back buttons anchor the bottom row."
},
{
"id": "p1-07",
"title": "Chronicle notifications coverage",
"priority": "p1",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-17",
"summary": "`turn_notification.gd` renders the end-of-turn chronicle log. Handlers cover\nthe full event surface (tech, wonder, city_grew / starved / founded /\ncaptured / border_expanded, building / unit completion, combat_resolved,\nunit_destroyed, victory, player_eliminated, era, golden age, happiness,\nimprovement, natural events). The panel now ships a five-checkbox filter row\n(All + Military + Research + City + Diplomacy) with `_entry_passes_filter`\ngating render, plus per-entry click-to-pan: entries carrying a `hex_pos`\nrender as Buttons that emit `EventBus.chronicle_entry_clicked(hex_pos)`, and\n`world_map.gd:_on_chronicle_entry_clicked` forwards to\n`Camera2D.center_on_hex`. The default-category bucket is immune to filter\ntoggles so debug entries never vanish."
},
{
"id": "p1-08",
"title": "Victory/defeat screen content — recap, banner, replay seed",
"priority": "p1",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-17",
"summary": "Victory and defeat overlays share a stats grid with 8 columns\n(Player / Pop / Cities / Tiles / Techs / Wonders / Units / Score), a seed-and-map\nrecap line, and a three-button footer. Defeat screen additionally surfaces the\ntop-scoring surviving player. Both scenes carry a Replay Same Seed button that\nstashes `GameState.replay_settings` and routes back to `game_setup.tscn`, where\n`_apply_replay_settings()` rehydrates every widget from that dict. Banner copy\nbranches on `victory_type` via `victory_banner_domination` vs\n`victory_banner_score` vocabulary keys."
},
{
"id": "p1-09",
"title": "Determinism gate — same seed produces byte-identical runs",
"priority": "p1",
"status": "done",
"scope": "game1",
"owner": "testwright",
"updated_at": "2026-04-19",
"summary": "Determinism is foundational for save/load, replay, bug reproduction, and golden tests. Prior work fixed seed-ingestion (`game_state.gd:113-115`), migrated HashMap→BTreeMap in several crates, sorted DataLoader enumeration, and pathfinder tiebreakers. Testwright's T1 task landed `mc-mapgen/tests/determinism.rs` (389 lines) with PCG32 golden vector + seed-stable map generation, now running green in CI.\n\n**State as of 2026-04-17 PM**: the Rust side of the gate is running. CI enforces `cargo test --workspace` on every push to main (Stage 1 of `.forgejo/workflows/ci.yml`), the apricot runner is registered + polling, and T1's determinism vector is green. Two remaining blockers, both tractable:\n\n1. **HashMap iteration audit** is no longer abstract. The T2 serde round-trip test (`mc-turn/tests/serde_roundtrip.rs`) concretely demonstrated that `PlayerState.strategic_axes: HashMap<_>` and `TechState.progress: HashMap<_>` produce non-deterministic save output across processes. Fix is `HashMap → BTreeMap` in `mc-turn/src/game_state.rs`, scoped to p0-12 (shipwright). The 3 currently-`#[ignore]`'d T2 tests will flip to passing the moment that change lands.\n2. **GUT save/replay test + end-to-end byte-identical turn_stats diff** are still both missing. The autoplay smoke stage is advisory right now because `turn_stats.jsonl` isn't landing reliably on fresh flatpak checkouts — fixing the sandbox path handling in `tools/autoplay-batch.sh` unblocks the turn_stats equality check."
},
{
"id": "p1-10",
"title": "Game setup UX — new-game dialog, difficulty, clan preview",
"priority": "p1",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-17",
"summary": "New-game configuration exists but doesn't show the AI clan personality the player will face. Players need to see at minimum: map type, map size, difficulty, number of AI opponents, and a preview of each AI opponent's clan flavor (Ironhold industrialist, Goldvein merchant, etc.) so the matchup feels intentional."
},
{
"id": "p1-11",
"title": "Purge build output from src/ — wasm-pack moves to .local/build/wasm/",
"priority": "p1",
"status": "done",
"scope": "game1",
"owner": "tourguide",
"updated_at": "2026-04-17",
"summary": "`src/` is source-only by project rule — the Rust `target/` incident\n(~25 GB, 65k files accidentally committed) established the convention,\nand `.gitignore:53` already ships `.local/` as the canonical artifact\nhost. But `src/simulator/build-wasm.sh` still writes to `src/simulator/pkg/`\nvia wasm-pack's default `--out-dir`. That puts generated JS + WASM\nbytecode a `git add .` away from being committed, and the Vite alias\nat `public/games/age-of-dwarves/guide/vite.config.ts:20`\n(`'@magic-civ/physics-rs' → ../../../../src/simulator/pkg/magic_civ_physics.js`)\nis the blocking reason `./run guide` cannot boot on plum today —\n`src/simulator/pkg/` is empty here (WASM is an apricot-built artifact\nper the two-host workflow), so the alias resolves to a missing file.\n\nRelocating the wasm-pack output to `.local/build/wasm/` (already the\nconvention used by `.forgejo/workflows/release.yml:243` when staging\nrelease artifacts, and matching `.local/build/rust/` for cargo via\n`src/simulator/.cargo/config.toml` and `.local/build/godot/` for\nexports) closes both failures in one move: the rule holds structurally,\nand the guide dev-server alias now points at a location a\ncontributor can populate via `bash src/simulator/build-wasm.sh` or\n`rsync from apricot` without ever putting output back inside `src/`."
},
{
"id": "p1-12",
"title": "Align every doc reference to the relocated wasm-pack output",
"priority": "p1",
"status": "done",
"scope": "game1",
"owner": "tourguide",
"updated_at": "2026-04-17",
"summary": "A fresh agent reading this repo cold today will be steered toward\n`src/simulator/pkg/` by ~12 independent doc surfaces that all predate\nthe p1-11 relocation: `.claude/instructions/build-output-locations.md`\nnames it as the canonical target; `rust-source-of-truth.md`'s ASCII\ndiagram arrows point at it; agent docs for `guide-web` and\n`simulator-infra` quote the full path; the guide CLAUDE.md instructs\n\"Never edit `src/simulator/pkg/` directly\"; ambient type comments,\nthe golden-test README, and the p2-09 audit narrative all mention\nit by name.\n\nIf p1-11 relocates the artifact but p1-12 doesn't scrub the docs, the\nnext agent (human or AI) will run into the same \"WASM not found\"\nfailure and reach for the same stale pattern. The two objectives must\nclose together or the relocation is cosmetic.\n\nThis objective also hardens the rule itself. Today `build-output-locations.md`\npresents the WASM row in a table as equal-weight with the others; after\nthis objective it opens with the hard rule \"**build output is never\ninside `src/`**\" as a first-paragraph invariant, citing both the prior\nRust `target/` incident and the 2026-04-17 wasm-pack relocation. The\nrepo-root `CLAUDE.md` router table gains an inline one-liner on the\n`build-output-locations.md` row so a triggered load is one step away\nwhenever an agent is choosing where to write a build artifact."
},
{
"id": "p1-13",
"title": "Guide dev server boots on plum with zero-error route coverage",
"priority": "p1",
"status": "done",
"scope": "game1",
"owner": "tourguide",
"updated_at": "2026-04-17",
"summary": "`./run guide` on plum (`natalie@plum.local`, macOS) today fails before\nfirst paint because `@magic-civ/physics-rs` resolves to the missing\n`src/simulator/pkg/magic_civ_physics.js` — p1-11 + p1-12 close that\nstructural half. What remains is the contributor-side proof: starting\nthe dev server on a fresh clone, loading every canonical route in\nPlaywright, and asserting zero runtime errors. Nobody currently owns\nthat proof, and the last guide-dev CHANGELOG entry\n(2026-04-16 14:47 task #18) explicitly marked \"visual verification\nblocked by WASM not built on macOS.\" The gap closes when a spec\nexists, runs green on plum, and catches new routes automatically on\nevery `pnpm test:e2e`.\n\nThe e2e substrate is already wired — `@lilith/playwright-e2e-docker`\nis a committed dependency, `e2e/Dockerfile.web` pre-bakes a production\nbuild, `playwright.config.ts` switches its webServer between\n`pnpm dev --port 5802` (local) and `pnpm preview --port 5802` (CI), and\ntwo specs (`diag.spec.ts`, `simulator.spec.ts`) already exercise the\nclimate simulator. This objective extends that harness with one\nroute-coverage spec; no new infrastructure."
},
{
"id": "p1-14",
"title": "Gate Game 2/3/4 magic-school content behind EpisodeGate (future-game scope)",
"priority": "p1",
"status": "oos",
"scope": "game2",
"owner": null,
"updated_at": "2026-04-17",
"summary": "Per CLAUDE.md's hard Game 1 scope rule (Dwarves only, NO magic; leylines\n/ Green school / spacefaring → Game 2; Archons / Ascension / 5 magic\nschools → Game 3), no magic content may ship into the Game 1 guide. The\n2026-04-17 `p2-09` scope-narrow pass deleted 10 Game 2/3 pages. But a\nprophylactic `Explore` audit on the same day — triggered by the\nTourguide route-coverage spec catching magic-data imports in Game 1\npages that still rendered — surfaced **2 RED** and **6 YELLOW** residual\nleaks that survived the first purge:\n\n**RED (runtime crash risk if data drifts)**\n\n- `public/games/age-of-dwarves/guide/src/pages/DevSpritesPage.tsx:5` —\n live import of `@resources/magic/schools.json`. (Partial-fix: the\n p1-13 Wave-1 pass replaced the import with the shared `SCHOOL_COLORS`\n palette, so the crash is gone; keeping this row as evidence of why\n the objective exists.)\n- `src/packages/guide/src/components/climate-sim/HexGLRenderer.tsx:7,749,783`\n — live ley-line network rendering via `LEY_COLORS[edge.school]`. In\n Game 1 the climate simulator's rendered grid shows ley-edge meshes\n with school-coded colors, inserting Game 2 visual vocabulary into\n every Game 1 planet view.\n\n**YELLOW (renders stale Game 2+ content into Game 1 UI, inert but wrong)**\n\n- `public/games/age-of-dwarves/guide/src/data/game.ts:8798` —\n `infusionTrees` loader pulls magical_promotions JSON into the guide\n context; exported to every consumer via `data/index.ts:40`.\n- `public/games/age-of-dwarves/guide/src/app/guide-data.ts:48` — the\n data context injection re-exposes magic-school data to every page\n via `GuideDataProvider`.\n- `public/games/age-of-dwarves/guide/src/pages/HomePage.tsx:220224`\n — nav items `/magic/schools`, `/magic/spells`, `/magic/archons`\n still defined; the routes were purged, so clicking these in Game 1\n hits the `<Navigate to=\"/\" replace />` fallback. Broken-link UX.\n- `public/games/age-of-dwarves/guide/src/pages/LensesPage.tsx:18` —\n `formatUnlock` renders magic-school unlock strings for Game 1 lenses;\n if a Game 1 lens has a magic unlock in data, the formatter renders it.\n- `public/games/age-of-dwarves/guide/src/pages/SurvivalGuidePage.tsx:23,143,152`\n — `ManaUpkeep` interface + render.\n- `src/packages/guide/src/components/cards/TerrainCard.tsx:256264`\n — `mana_major` field render block.\n\nAdditional front-page prose (Game 1 HomePage) already displays \"5\nMagic Schools\" and \"16 Asymmetric Races\" in its feature grid —\nevidence that the `<EpisodeProvider episode={1}>` wrapping isn't\nbeing consulted by every page's content block."
},
{
"id": "p1-15",
"title": "Deploy dev guide to https://mc.next.black.local",
"priority": "p1",
"status": "done",
"scope": "game1",
"owner": "tourguide",
"updated_at": "2026-04-17",
"summary": ""
},
{
"id": "p1-16",
"title": "Purge Game 2/3 scope bleed from user-visible Game 1 guide copy",
"priority": "p1",
"status": "done",
"scope": "game1",
"owner": "tourguide",
"updated_at": "2026-04-18",
"summary": "CLAUDE.md's Game 1 scope rule is clear: Age of Dwarves Early Access\nships with **no magic** — no magic schools, no Archons, no leylines,\nno mana. The `<EpisodeGate min={2}>` component + `VITE_DEV_GUIDE=1`\ndev-bundle flag (p1-15) are the enforcement mechanism. But a 2026-04-18\nExplore sweep catalogued six user-visible surfaces in the default Game 1\nbuild that still advertise or document Game 2/3 cosmology:\n\n| File:Line | Failure |\n|---|---|\n| `HomePage.tsx:189190` (FEATURES) | \"5 Magic Schools\" card with \"cross-school fusions, 10 hybrid disciplines\" |\n| `HomePage.tsx:246251` (Pitch) | \"16 asymmetric races, 5 magic schools … pursue arcane power\" |\n| `HomePage.tsx:256275` (LoreSection) | Two paragraphs of mana-nodes + ley-lines + school-aligned energy |\n| `CommunicationsPage.tsx:9798` | \"Archon Telepathy (Magic civs)\" row in mundane radio-tower rules |\n| `PromotionsPage.tsx:5,1215,156193` | Magic-data imports + \"Mana Infusions\" section + \"Dispellable by Aether\" / \"High Archon dies\" body text |\n| `SurvivalGuidePage` data.ts:85 | \"Life T3 quarantine spell blocks adjacency transmission\" in mundane survival scenario |\n\nThese are **RED** — visible to someone opening the staging / production\nbuild without `VITE_DEV_GUIDE=1`. A player launching Early Access with\none dwarf race should not be told the game has \"5 magic schools\" or\nread Archon mechanics in the communications tower rules.\n\nYELLOW items (dev-bundle-only, flagged for opportunistic cleanup):\n\n- `progress-report/OverviewTab.tsx:137,183` — hardcoded \"5 trees,\n 30 policies\" + \"12 additional races\" roadmap rows\n- `GovernmentPage.tsx:48` (SKIP_MODS) — defensive filter for the\n Game-2 `no_spell_pact_opposing_school` modifier"
},
{
"id": "p1-17",
"title": "Forgejo workflow auto-deploys dev guide on push to main",
"priority": "p1",
"status": "done",
"scope": "game1",
"owner": "tourguide",
"updated_at": "2026-04-18",
"summary": "With p1-15 landed, `./run deploy:guide:next` can be invoked manually from\nplum. The next step is zero-touch redeploy on every push to main so\ncontributors without plum access still see their work at\n`https://mc.next.black.local` within minutes of merging.\n\nDepends on p1-15 (infra + command must exist; they do)."
},
{
"id": "p1-18",
"title": "Village discovery — world-map feedback (notification, reward popup, minimap ping)",
"priority": "p1",
"status": "done",
"scope": "game1",
"owner": "wireguard",
"updated_at": "2026-04-19",
"summary": "`EventBus.village_discovered(tile_pos, reward)` fires when a unit walks onto a village\ntile, but `_on_village_discovered` in `world_map.gd` is a no-op stub. The player receives\ngold silently with no visual feedback."
},
{
"id": "p1-19",
"title": "Tutorial opt-in — HUD button, disappears after turn 5, starts from Step 1",
"priority": "p1",
"status": "done",
"scope": "game1",
"owner": "wireguard",
"updated_at": "2026-04-19",
"summary": "The first-run tutorial currently auto-shows on game start (gated by\n`TutorialOverlay.should_show_on_first_run()`). This is hostile to returning\nplayers and to playtesting — the tutorial interrupts real gameplay every fresh\nboot. Assume the player doesn't need a tutorial by default. Offer it as a\nbutton on the world-map HUD that disappears after turn 5.\n\nAdditionally, when the tutorial IS started, it begins at **Step 1** (camera\npan), not Step 2. This is already the authoritative step order in\n`tutorial_overlay.gd:_STEPS` — the fix is only to make sure `_current_step`\ninitializes to `1` (it does) and that no code skips ahead."
},
{
"id": "p1-20",
"title": "Unit action capability registry — one source of truth for \"what can this unit do right now?\"",
"priority": "p1",
"status": "done",
"scope": "game1",
"owner": "wireguard",
"updated_at": "2026-04-19",
"summary": "The game has no unified answer to *\"what actions can unit U take on turn T in\nstate S?\"* Today the unit panel (`unit_panel.gd:19-40`) hardcodes three\nbuttons — Fortify, Skip, Found City — and decides visibility with bespoke\nper-unit booleans scattered across the JSON (`can_found_city`,\n`can_build_improvements`, `flags: [\"ranged\"]`) and ad-hoc GDScript predicates\n(`is_civilian()`). Meanwhile `mc-ai/src/tactical/movement.rs` enumerates\nmoves and attacks but has no registry for non-motion actions. UI and AI have\nno shared truth.\n\nEvery future action — patrol (p1-21), siege pack/deploy, pillage, embark,\nbuild-road, heal, upgrade — compounds that debt by adding another hardcoded\nbutton plus its own scattered check. A siege engine in `packed` state can\nmove but not bombard; in `deployed` state can bombard but not move. Patrol\nhas the same shape (idle ↔ patrolling, with auto-cancel). Fortify has the\nsame shape. Without a registry, each state gate becomes a new bespoke flag.\n\nThis objective lands the foundation: a JSON-driven capability declaration,\na Rust `ActionKind` enum with a single `legal_actions(unit, state)` query,\nand a unit-panel refactor that renders buttons from that list. **Behavior\ndoes not change** — the three existing actions are folded in with no\nsemantic change. The payoff is every subsequent action objective (patrol,\nsiege, pillage, embark, ...) ships as one enum variant + one JSON keyword\nmapping + one handler, with no UI or AI scaffolding to re-invent."
},
{
"id": "p1-21",
"title": "Unit patrol orders — standing order to loop between waypoint tiles",
"priority": "p1",
"status": "done",
"scope": "game1",
"owner": "wireguard",
"updated_at": "2026-04-19",
"summary": "Both the human player and the AI clans need a *standing order* that keeps a\nunit moving along a fixed route turn after turn without per-turn micro-\nmanagement. Canonical use cases: escorting a worker loop, covering a\nchokepoint, sweeping scout fog between two outposts.\n\nToday a unit has two durable states: idle-on-tile, or fortified. `Skip`\nends the turn but does not persist. A player who wants a scout to pace\nbetween two tiles must hand-move it every single turn — which breaks down\nonce the empire has more than a few units, and which the AI cannot express\nat all because `mc-ai/tactical/movement.rs` re-plans from scratch each turn.\n\nThis objective adds a third durable state — **patrol** — with a small\nwaypoint list, a direction cursor, and a loop mode. While patrolling, the\nunit auto-advances along its route during the turn processor before the\nplayer's input phase, so turn N+1 opens with the unit already at the next\nstep on its loop.\n\n**This objective assumes p1-20 (unit action capability registry) has\nshipped.** Patrol plugs into the registry as one new `ActionKind` variant\nplus its handlers — no bespoke unit-panel buttons, no scattered\n`is_patrolling` checks in GDScript. If p1-20 slips, reassess whether to\nland a narrower patrol-only version first."
},
{
"id": "p1-22",
"title": "MCTS per-decision wall-clock budget — bound per-turn cost on huge maps",
"priority": "p1",
"status": "partial",
"scope": "game1",
"owner": "warcouncil",
"updated_at": "2026-04-25",
"summary": "Spun out from p0-22 (Ultimate AI stress test) on 2026-04-25 after the 7 root-cause fixes (combat method typos, per-slot pinning, score-victory fallback, NOTIFICATION_PREDELETE, autoplay-batch.sh MCTS branch, etc.) verified the pipeline produces `outcome:victory` at T500 on the huge-map config. The remaining gap blocking `ultimate_stress: PASS` is **purely MCTS per-turn wall-clock cost on game-state complexity**: with deterministic seeds, some maps produce game states where each MCTS decision takes 30-60+ seconds (vs <5s on simpler states). Even at `PARALLEL=2 SAFETY_TIMEOUT_OVERRIDE=3600s`, slow seeds reach only T55-T236 in the 3600s budget (would need 4-8 hours wall-clock per game). Fast seeds reach T500 in ~45min.\n\nThis is engineering work, not test calibration: the AI is ALWAYS faster when it commits to a decision under a bounded budget. The current MCTS runs to a fixed iteration count regardless of wall-clock cost; on a complex 5-player huge-map state the iteration cost balloons."
},
{
"id": "p1-23",
"title": "Restore StatsTracker — demographics overview broken in shipped builds",
"priority": "p1",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-25",
"summary": "`engine/scenes/overviews/demographics.gd` (and `end_game_stats.gd`) referenced `StatsTracker.CATEGORIES`, `CATEGORY_LABELS`, `get_rankings`, `get_history`, `get_player_series` but no `StatsTracker` class_name or autoload existed. Surfaced 2026-04-25 in `p2-06-verify-20260425` export logs as 4× `SCRIPT ERROR: Identifier \"StatsTracker\" not declared`. The demographics screen was shipped broken.\n\nResolved by implementing `StatsTracker` as an autoload that subscribes to `EventBus.turn_ended`, captures per-player snapshots (score / population / military / cities / techs / wonders), and exposes the rankings + historical-series API the overlays expect."
},
{
"id": "p1-24",
"title": "ai_personalities.json fails to load from packed builds (all platforms) — pass JSON contents not path",
"priority": "p1",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-25",
"summary": "When the Windows .exe cross-compiled via cargo-xwin runs under Wine on apricot (p2-06b smoke 2026-04-25), it floods the log with:\n\n```\nERROR: GdMcTreeController::scoring_weights_for_clan load error for 'goldvein':\n failed to read ai_personalities.json at public/games/age-of-dwarves/data\\ai_personalities.json:\n Path not found. (os error 3)\n```\n\nThe mixed-separator path is one symptom; the deeper issue is that `ai_turn_bridge.gd:79` passes `ProjectSettings.globalize_path(\"res://public/games/age-of-dwarves/data\")` to the Rust side, then Rust uses `std::fs::read_to_string` on `<dir>/ai_personalities.json`. **For packed builds (any platform), `res://` content lives inside the .pck — `globalize_path` returns a fake/non-existent OS path, so `std::fs::read_to_string` always fails.** It silently fell back to default weights everywhere — the macOS smoke (`p0_pop_peak ~290 turns to victory`) likely had the same error invisibly.\n\n**Game still completes** — the AI falls back to default scoring weights. So this is non-blocking but ships a degraded MCTS-personality-aware AI on EVERY platform from packed builds, not just Windows. The wine smoke just made the error finally visible."
},
{
"id": "p1-25",
"title": "Eliminate parse-error spam in export logs (Unit dup decl + SaveManager stray)",
"priority": "p1",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-25",
"summary": "Every Linux/Windows export log was emitting two families of parse errors despite producing a working binary:\n\n1. **`Class \"SaveManager\" hides a global script class.`** — apricot had a stray duplicate `src/game/engine/src/map/save_manager.gd` (byte-identical to `src/core/save_manager.gd`, both declaring `class_name SaveManager`). The Mac source tree was clean; the apricot stray must have come from a prior agent's misplaced rsync. Deleted on apricot 2026-04-25; export logs now register SaveManager exactly once.\n\n2. **`The member \"type_id\" / \"hp\" / \"max_hp\" / \"movement_remaining\" / \"position\" / \"equipped_items\" already exists in parent class Unit.`** — `engine/scenes/tests/crafting_complete_proof.gd` had a `class UnitStub: extends Unit` block that redeclared 6 fields the parent `Unit` class already owns. Comment in the file claimed \"Unit.gd is a 2-line stub\" but Unit.gd evolved past that point. Refactored UnitStub to set those values in `_init()` instead of redeclaring them.\n\nBoth error families surfaced in **every** export run since shipwright started exporting builds; not a regression introduced today, just never triaged because the export still produced a working binary."
},
{
"id": "p1-26",
"title": "\"Tile-placement UX with effect preview — Civ7-style \\\\\\\"where does this go and what changes\\\\\\\"\"",
"priority": "p1",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-26",
"summary": "When a player queues a building or tile improvement today, the placement is a black box:\n- **Buildings**: every building entry in `public/games/age-of-dwarves/data/buildings/*.json` has `placement: \"city\"` — buildings just appear in the city center, no tile choice, no spatial decision.\n- **Improvements**: a worker drops a farm/mine/road at the worker's hex; the player sees no preview of yield-delta or adjacency effects before committing.\n\nCiv7 (and Civ6 districts) made this a primary expressive lever: pick a tile, see live yield projections + adjacency bonuses + terrain restrictions before locking in. Without this surface in Game 1 the player loses a major strategic dimension and the city map feels like decoration.\n\nThis objective covers the **UX + supporting data extension** for tile-targeted building/improvement placement with live preview. The simulation already supports per-tile improvements; the gap is presentation + a small data extension to mark which buildings become tile-placed and any adjacency rules."
},
{
"id": "p1-27",
"title": "Extract GPU MCTS into a standalone service/client (model-boss-shaped, magic-civ-only)",
"priority": "p1",
"status": "missing",
"scope": "game1",
"owner": "warcouncil",
"updated_at": "2026-04-25",
"summary": "Today the GPU MCTS path lives **inside** the `mc-ai` crate (`gpu/inner.rs`, `gpu/rollout.wgsl`, `gpu/cpu_reference.rs`) and runs in-process via the GDExtension (`GdMcTreeController`). That couples GPU lifecycle (device init, queue submission, buffer pooling, fence waits) to the game's per-turn decision call.\n\nPer user directive 2026-04-25: extract this into its own **MCTS service/client** that\n\n1. Lives **inside @magic-civilization** (not in @model-boss / not in any other repo) — it's game-specific.\n2. Lives **independently** of the in-process GDExtension — long-lived process the game talks to via IPC (Unix socket / TCP / shared memory).\n3. **Borrows patterns** from `@model-boss` (job submission, queue, batched dispatch, GPU lifecycle isolation) but doesn't take a dependency on it. Magic-civ's MCTS workload is narrow enough to warrant its own focused implementation.\n\nWhy a service vs in-process:\n- GPU init + warm-up amortized once per session, not per AI turn\n- Game can keep playing turns while a deep search is in flight (async)\n- Crash isolation — a wgpu/driver fault doesn't take the game down\n- One service can serve multiple game clients (autoplay-batch parallel runs hit one warm GPU instead of N cold inits)\n- Future: out-of-process service can run on a different host (apricot has GPU, dev mac doesn't)"
},
{
"id": "p1-28",
"title": "\"Culture research tree — real graph, bridge, UI\"",
"priority": "p1",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-26",
"summary": "The culture data files\n(`public/games/age-of-dwarves/data/culture/manifest.json` →\n`public/resources/culture/*.json`) describe a six-pillar culture tree that\nmirrors the tech-tree shape: `id`, `name`, `pillar`, `era`, `tier`, `cost`,\n`requires`, `unlocks{…}`, `flavor`. The web guide already renders it via the\nshared `TechTreeGraph` component (`CultureTreePage.tsx`).\n\nInside the Godot game there was no culture-tree surface: no Rust research\ngraph, no GDExtension bridge, no GDScript wrapper, no scene, no per-turn\nresearch accumulator. The `CulturePool` only powered border expansion.\n\nThis objective shipped the live culture-research path end-to-end so the\nplayer can open a culture-tree screen identical in UX to the tech tree,\npick a tradition, accumulate culture-research progress per turn, and unlock\nbuildings / wonders / lenses / mechanics on completion."
},
{
"id": "p1-29",
"title": "\"Anti-early-domination: lift game-balance gates that p0-01 v1 measured\"",
"priority": "p1",
"status": "missing",
"scope": "game1",
"owner": "warcouncil",
"updated_at": "2026-04-26",
"summary": "Split out from p0-01's original v1 sub-gates that the AI-layer cycles (1, 2, 3) could not move because they measure emergent game-balance dynamics, not AI quality. p0-01 closed `done` 2026-04-26 against Gate v2 (3/5 v1 sub-gates pass cleanly: tier_peak=4, wonders 7/10, combats=255). The 2 v1 sub-gates that v2 reframed away need a real owner:\n\n- `tier_peak_gap ≤ 4` median: in surviving-pair games at end, one player tech-monopolizes (tp=6) while the other stagnates (tp=0), giving gap=5-6. Even with the alive-aware metric, the gap holds. Root cause: capture/combat dynamics let one player snowball without the other catching up. Loser stays alive but undeveloped.\n- `peak_unit_tier ≥ 3 in ≥7/10 games` absolute: 5/10 currently. 4 of the 5 fails are early-domination games (T48-T121) where tier-3 tech hasn't unlocked yet. The AI does deploy tier-3 units when available (80% of seeds reaching tp ≥3 also reach unit ≥3), but games end before tier-3 unlocks in half the seeds.\n\nCycle-3 attempted multiple AI-layer levers and confirmed they DON'T move these gates:\n- Tactical `DOMINANCE_FACTOR` bump (production.rs 1.25→2.0): no effect on outcome\n- Tactical dominance lerp bump (thresholds.rs 1.5→2.0/2.5 baseline): caused REGRESSION on tier_peak (faster opportunist wins)\n- Both reverted because the strategic MCTS doesn't pick attack actions — it only picks `SpawnUnit/FoundCity/Idle` per `mc-turn/src/snapshot.rs:204-214 action_prior`. The capture/development tempo is governed by mc-turn capture mechanics + mc-economy growth rates, NOT by AI scoring weights.\n\nReal levers (cross-team scope):\n- **mc-combat / mc-turn capture mechanics**: increase city HP, lengthen siege duration, add capital-recapture cost, weaken early-rush combat math.\n- **mc-economy growth rates**: faster baseline tech research, lower tier-3 prereq cost, give players tech catch-up bonus when behind.\n- **mc-turn turn-limit floor**: refuse to award domination victory before T150 (force games to mid-game minimum).\n\nPick one or compose multiple. Each requires the corresponding team-lead's involvement."
},
{
"id": "p1-30",
"title": "\"Optimize `_build_tactical_state` — 8000-tile GDScript dict-build per AI turn blocks p1-22 huge-map gate\"",
"priority": "p1",
"status": "missing",
"scope": "game1",
"owner": "warcouncil",
"updated_at": "2026-04-26",
"summary": "Split out from p1-22 (MCTS per-decision wall-clock budget). p1-22 closed `partial` after cycles 2-3 shipped strategic + tactical Rust budgets (`mcts_tree::simulate_parallel` + all 5 `mc-ai/src/tactical/` submodules + `GdAiController::set_budget_ms`, 186/186 lib tests). Strategic budget verified working: p1-22-cycle-2 batch had seeds 9 and 10 reach T500 victories at max_tier=10 and 7. Tactical budget verified bounded by unit test `tactical::tests::tactical_budget_respected`.\n\nBut the huge-map ≥5/10 victories sub-gate FAILED at 2/10 in the cycle-2 batch because seeds 1-8 hung at low turn counts (T43-T236). The Rust paths are bounded; the hang is in GDScript. Specifically: `src/game/engine/src/modules/ai/ai_turn_bridge.gd:248-250` (`_build_tactical_state`) iterates `width × height` tiles (112×72 = 8064 on a huge map) building a Dictionary per tile to serialize as JSON to feed `GdAiController::decide_actions`. That serialization runs every AI turn per player.\n\n5 AI players × ~8000 tile-dicts × ~T100 turns = ~4 million GDScript dict allocations per game. Each dict allocation is microseconds but compounds. The Rust `MCTS_DECISION_BUDGET_MS=2000` doesn't bound this — by the time Rust gets the JSON, the GDScript serialization has already chewed through the wall-clock budget for the turn.\n\nThe fix has two reasonable shapes:\n1. **Delta serialization**: only serialize tiles whose state changed since last AI turn. Cache per-player. ~10× speedup.\n2. **Move tile state into mc-turn**: have Rust own the tile catalog (already partial via `TacticalMap`), pass an opaque handle from GDScript instead of full JSON. Eliminates the GDScript dict-build entirely. Aligns with Rail-1.\n\nOption 2 is more correct (Rail-1) but bigger surface. Option 1 is the quick win."
},
{
"id": "p1-31",
"title": "Author the 9 buildings the tech tree unlocks but data does not provide",
"priority": "p1",
"status": "missing",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-26",
"summary": "`data/techs/*.json` `unlocks.buildings` references nine building IDs that have no JSON file under `data/buildings/`. Researching one of these techs awards the player nothing visible, even though the tech tooltip will say \"unlocks X\". This is a content gap, not a code bug — the loader, dispatch, and city UI all work; the buildings are simply missing.\n\n| Building | Unlocking tech | Inferred role |\n|---|---|---|\n| `fishery` | `fishing` | Coastal food generator |\n| `hunting_lodge` | `trapping` | Forest food + scout XP |\n| `nature_reserve` | `ecology_study` | Wilderness yield / happiness |\n| `hardening_pit` | `steelworking` | Smithing tier-up (post-`forge`) |\n| `mithril_forge` | `mithril_smithing` | Late-game forge upgrade |\n| `runesmith_hall` | `runelore` | Runic crafting (mundane in Game 1) |\n| `siege_works` | `siege_doctrine` | Distinct from existing `siege_workshop` (different tech, advanced tier) |\n| `war_college` | `combined_arms` | Mid-game elite-unit production hub |\n| `ranger_post` | `tracking` | Scout/courier support; pairs with existing `messenger_hut` (also unlocked by `tracking`) |"
},
{
"id": "p1-32",
"title": "Author the food + resource processing chain (granary, mill, brewery, tannery, sawmill, herbalist)",
"priority": "p1",
"status": "missing",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-26",
"summary": "`public/games/age-of-dwarves/docs/cities/PRODUCTION_CHAIN.md` describes a stockpile-based processing economy:\n\n```\nFarm → Mill → flour → food surplus\n → Brewery → ale (happiness + trade)\nPasture → Tannery → leather → Barracks (unit armor quality)\nForest → Sawmill → lumber → construction speed bonus\n → Siege Workshop (siege engines)\nForest → Herbalist → reagents → Academy (research boost)\n```\n\n`docs/cities/BUILDINGS.md` adds `granary` (Husbandry tech, food storage / starvation reduction).\n\nNone of these six buildings exist in `data/buildings/`. The dwarves have no food building at all — the closest is `boar_pen` (food=1, gated by `boar_husbandry`). `mc-ai/tactical/production.rs::ids::GRANARY = \"granary\"` already tries to queue one and silently fails (see p0-45).\n\nThis objective authors the six processing buildings AND the stockpile-effect-types they require. It does NOT implement the full stockpile/quality system — that is a downstream gameplay feature. The buildings start with simple yield effects (`food`, `happiness`, `production`, `science`) so they're useful immediately, with `effects` left extensible for the stockpile system to layer on top."
},
{
"id": "p1-33",
"title": "Author production buildings for naval and aerial unit families (shipwright, airfield)",
"priority": "p1",
"status": "missing",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-26",
"summary": "`data/units/manifest.json` ships **14 dwarf naval units** (river_galley → fortress_ship across 11 tech gates) and **7 aerial units** (gyrocopter → sky_fortress). Neither category has a corresponding production building. The units are buildable in any city the moment their tech is researched — no port, no shipyard, no airfield required.\n\nThis is a gameplay AND a content gap:\n1. **Gameplay**: a landlocked city can currently build a `dwarf_dreadnought`. The dwarves' considerable naval roster has no production gate, no coastal-adjacency requirement, no infrastructure cost.\n2. **Content**: `BUILDINGS.md` calls for a `shipwright` (Cartography + Navigation tech). No equivalent for aerial is in the design doc — needs a design pass.\n\nThis objective authors:\n- `shipwright` — naval production building, requires coastal city, gates buildable naval units.\n- `airfield` (or `hangar`, name TBD) — aerial production building, gates buildable aerial units. Design: probably a flat building, plains/grassland placement, no special adjacency.\n\nThe \"buildable only when this building exists\" gate is the new capability — the existing `tech_required` field on units is necessary but not sufficient. Either:\n- Add `building_required: <id>` to unit schema and have the production picker honour it, or\n- Use the existing `placement_tile_required` + adjacency machinery to require shipwright on a coastal tile.\n\nPick one and document the choice in this objective's evidence list once selected."
},
{
"id": "p2-06",
"title": "Export pipeline for Windows / macOS / Linux",
"priority": "p1",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-25",
"summary": "Players need binaries. Godot export presets (desktop: Linux/X11, macOS, Windows Desktop) are authored; the `./run export` chain produces per-platform archives via `tools/export.sh` + `tools/export-single.sh`, and the `.forgejo/workflows/release.yml` tag-push pipeline bundles Linux + macOS + Windows + WASM-guide archives into a Forgejo release with release notes generated from the CHANGELOG diff.\n\nOpen work: (1) Windows `.dll` production only happens on a registered windows runner — local `./run export:windows` from a macOS/Linux EDIT host does not yet cross-compile, and no forgejo windows runner is registered. (2) The boots-and-plays end-to-end smoke has not been run against a fresh export archive — the prior audit's 29MB .x86_64 was discovered this pass to be non-bootable (missing embedded .pck from a concurrent --import race). A clean re-export + AUTO_PLAY 10-turn smoke on a dedicated off-peak runner is the remaining gate. (3) AutoPlay autoload shipping (✓ this pass) unblocks (2) but (2) itself is still ✗.\n\n### macOS scan-inflation fix (2026-04-17, commit f090d28a7)\n\nThe prior 20+ min plum export stall was root-caused to Godot's export scanner walking the entire project tree *before* applying `exclude_filter` — the three pnpm-managed `public/games/*/guide/node_modules/` symlinks dereferenced into the hoisted store and emitted ~16MB of `_scan_new_dir` warnings. Fixed in `tools/export-single.sh` by rsync-staging the project to `.local/export-staging-<stamp>/` (excluding `node_modules`, `.local`, `target`, `.git`, `dist`, `.vite*`) before invoking godot. Default-on for macos; opt-in via `EXPORT_STAGED=1` elsewhere; `KEEP_STAGING=1` keeps staging dir for inspection.\n\nEmpirical timing: `./run export:macos p2-06-verify` completed full project scan + 155-step asset reimport in **8.827s** total (two independent runs at 9.287s and 8.827s). Zero `_scan_new_dir` warnings. The only remaining blocker surfaced by that run is a missing Godot 4.6.2 export template (`/Users/natalie/Library/Application Support/Godot/export_templates/4.6.2.stable/macos.zip` — empty templates dir). Once the template is installed, `archive_boots_and_plays` should close within minutes rather than the 20+ min scan-stall window it previously faced. No codesign/entitlement errors surfaced in verification (those would follow template resolution), so the scan-inflation gate is provably cleared.\n\nStaging approach is documented in `scripts/README.md` § \"Export staging (p2-06)\"."
},
{
"id": "p2-16",
"title": "Audio assets — in-theme OSS launch pack + source ledger",
"priority": "p1",
"status": "in_progress",
"scope": "game1",
"owner": "asset-audio",
"updated_at": "2026-04-27",
"summary": "The audio capability shipped as **p0-21** — `AudioManager`, manifest,\nsignal wiring, volume sliders all work. The schema + categorical\nrouting extension lands as **p2-33** (this objective is `blockedBy`\nthat). What's missing is the actual `.ogg` files plus the source\nledger that proves their licenses are clean.\n\nPer user directive 2026-04-17 the asset work was pulled out of the\noriginal `p1-04` so capability and assets are tracked independently.\nA silent ship is shippable; a broken or licence-tainted audio system\nis not.\n\nThis objective ships **the launch sound pack** assembled from free /\nOSS sources (CC0, CC-BY 3.0/4.0, royalty-free commercial; no\nShareAlike, no NonCommercial). Pack covers ~57 files spanning UI,\nturn cycle, units (categorical melee / ranged / siege / civilian),\nbuildings (categorical civic / production / military / wonder),\nfauna (categorical predator / herbivore / apex), city events,\nresearch, weather, victory. ~50 SFX + 7 music tracks."
},
{
"id": "p2-22",
"title": "Sprite generation pipeline — runnable end-to-end",
"priority": "p1",
"status": "partial",
"scope": "game1",
"owner": "asset-sprite",
"updated_at": "2026-04-25",
"summary": "Gate-one objective for every other `asset-sprite` child (`p2-23` … `p2-27`). Before any sprite can legitimately land in `public/games/age-of-dwarves/assets/sprites/`, the `tools/sprite-generation/` pipeline has to run cleanly end-to-end: scan game data → generate variants via the configured model → auto-rank via Sonnet vision → surface in the Theater GUI for human approval → chroma-key + resize + install with LICENSES.md row written.\n\nSlate is clean (user deleted 7 pre-existing sprites on 2026-04-17 for quality-bar failure; the prompt library and ranker had drifted). This objective closes out the \"pipeline works\" half of the split; actual sprite shipping lives in the downstream children."
},
{
"id": "p2-23",
"title": "Unit sprites — Dwarf-racial roster (m/f variants)",
"priority": "p1",
"status": "missing",
"scope": "game1",
"owner": "asset-sprite",
"updated_at": "2026-04-17",
"summary": "Every Dwarf-controlled combat + support unit declared in `public/games/age-of-dwarves/data/units/*.json` (the non-wild entries — those with a `gender: { male, female }` block) needs a matching pair of PNG sprites in `public/games/age-of-dwarves/assets/sprites/units/` so `UnitRenderer` can overlay the sprite on top of the procedural baseline.\n\nThe current roster of gender-bearing units in `data/units/` (2026-04-17 snapshot): `archer`, `berserker`, `cavalry`, `pikeman`, `runesmith`, `spearmen`, `warrior`, `worker` — 8 unit types × 2 genders = 16 PNG files. Race id is `dwarf` (singular — confirmed in `public/games/age-of-dwarves/data/races.json`); the `Player.race_id` fallback in `unit_renderer.gd:_resolve_race_id()` resolves to that value at runtime.\n\nSlate is clean — the 7 previously-authored sprites were deleted 2026-04-17 for quality-bar failure. Do not restore; regenerate via `p2-22` once the pipeline is green."
},
{
"id": "p2-24",
"title": "Unit sprites — wild creatures & fauna (generic, no race/sex)",
"priority": "p1",
"status": "missing",
"scope": "game1",
"owner": "asset-sprite",
"updated_at": "2026-04-17",
"summary": "Wild creatures in `public/games/age-of-dwarves/data/units/*.json` (entries with `unit_type: \"wild\"`) do not participate in the race×sex permutation. `UnitRenderer` falls back to `SPRITE_LOOKUP_GENERIC_FORMAT = \"sprites/units/%s.png\"` (`unit_renderer.gd:53`) for them — a single sprite per creature id.\n\nThe current wild roster (2026-04-17 snapshot): `ancient_hydra`, `basilisk_wild`, `dire_bear`, `dire_wolf`, `drake_wild`, `elder_wyrm`, `feral_spider`, `fire_imp`, `frostfang_alpha`, `garden_snail`, `lava_elemental`, `shambling_dead`, `stone_sentinel`, `wild_wyvern`, `wolf_pack` — 15 creatures, 15 PNG files.\n\nEach is a recognisable silhouette at the renderer's 56×56 hex render size (verified via `sprite_proof.tscn` scaling behaviour established in `p0-23`)."
},
{
"id": "p2-25",
"title": "Building sprites — base game coverage (non-wonder)",
"priority": "p1",
"status": "missing",
"scope": "game1",
"owner": "asset-sprite",
"updated_at": "2026-04-17",
"summary": "Every standard building declared in `public/games/age-of-dwarves/data/buildings/*.json` (excluding `manifest.json`, `stub.json`, and `mundane_wonders.json`) needs a sprite at `public/games/age-of-dwarves/assets/sprites/buildings/<building_id>.png`. City-screen UI and, where the world-map eventually surfaces building silhouettes, the renderer both consume these.\n\nCurrent base roster (2026-04-17 snapshot, 10 buildings): `ale_hall`, `barracks`, `bathhouse`, `colosseum`, `forge`, `library`, `marketplace`, `monument`, `temple`, `walls`."
},
{
"id": "p2-26",
"title": "Mundane-wonder sprites — 24 distinct, higher-fidelity art",
"priority": "p1",
"status": "missing",
"scope": "game1",
"owner": "asset-sprite",
"updated_at": "2026-04-17",
"summary": "`public/games/age-of-dwarves/data/buildings/mundane_wonders.json` declares 24 Civ5-style world wonders for Game 1 (no magic schools — these are mundane flavour wonders). Each needs a **distinct, higher-fidelity** sprite — a generic \"fancy building\" look drags the whole wonder system's perceived prestige. Split from `p2-25` so the quality bar can be tracked independently.\n\nCurrent wonder roster (2026-04-17 snapshot): `ancestral_forge`, `mead_hall`, `first_mineshaft`, `clan_moot_stone`, `iron_bulwark`, `hall_of_ancestors`, `the_deep_road`, `bardic_circle`, `archive_of_runes`, `royal_runestone`, `grand_observatory`, `covenant_stone`, `the_great_forge`, `iron_crown`, `undermount_vault`, `hall_of_echoes`, `world_pillar`, `well_of_ages`, `the_undying_flame`, `voice_of_ages`, `silent_cartograph`, `shrine_of_names`, `the_cold_anvil`, `hearthless_hall`."
},
{
"id": "p2-27",
"title": "City population-tier sprites — city_q1 through city_q5",
"priority": "p1",
"status": "missing",
"scope": "game1",
"owner": "asset-sprite",
"updated_at": "2026-04-17",
"summary": "`CityRenderer` looks up a city's sprite via `SPRITE_LOOKUP_CITY_FORMAT = \"sprites/cities/city_q%d.png\"` (`city_renderer.gd:29`) with the quality bucket computed as `clampi(city.population / CITY_QUALITY_BUCKET + 1, 1, CITY_QUALITY_MAX)` — five buckets, five PNGs. Tiny scope relative to the other children, but the key format and renderer path are distinct enough to warrant its own objective — can flip `done` early."
},
{
"id": "p2-28",
"title": "Sprite provenance ledger — LICENSES.md per-file attribution",
"priority": "p1",
"status": "done",
"scope": "game1",
"owner": "asset-sprite",
"updated_at": "2026-04-25",
"summary": "Every sprite PNG that ships in `public/games/age-of-dwarves/assets/sprites/` must have a corresponding row in `public/games/age-of-dwarves/assets/sprites/LICENSES.md` recording source, license, author, URL, and SHA256. This is a cross-cutting compliance objective that runs continuously alongside the delivery children (`p2-23` … `p2-27`) — the ledger is complete exactly when every on-disk sprite has a matching row and every row points at an on-disk file.\n\nCommercial-use compatibility is non-negotiable. AI-generated output must come from a model on the approved list (`juggernaut-xl-v9`, `epicrealism-xl`, `illustrious-xl-v2`, or current equivalent per CLAUDE.md). Commissioned art must have assigned commercial rights in writing."
},
{
"id": "p2-33",
"title": "\"Sound system extension — categorical fallback, variant pools, per-entity routing\"",
"priority": "p1",
"status": "done",
"scope": "game1",
"owner": "asset-audio",
"updated_at": "2026-04-27",
"summary": "`AudioManager` (`p0-21`, done) ships 10 SFX events and 6 era-keyed music\ntracks. The current manifest is one stream per id, no variation, no\nfallback chain, and no story for the 91 units / 65 buildings / 600\nfauna species the game ships with — every entity that ever wants a\ndistinct sound has to add a hand-authored entry, which doesn't scale to\nlaunch.\n\nThis objective extends the manifest schema and `audio_manager.gd` so\nthe asset pack tracked by `p2-16` can land cleanly:\n\n* **Variant pools** — an entry can list `streams[]` with 2-3 paths;\n the player picks one uniformly to break repetition.\n* **Pitch jitter** — optional ±X% pitch randomisation per play.\n* **Categorical fallback ladder** — `play_for_entity(entity_id,\n event_kind)` resolves `<entity>.<event>` → `<category>.<event>` →\n `<event>`, so a fresh unit with no bespoke sound automatically routes\n to its category bucket (`unit.melee.attack`, `building.production.complete`,\n `fauna.apex.roar`, etc.). The category is read from existing JSON\n fields (`unit_type` / `category` / `trophic_class`) — no new schema\n fields on units / buildings / wilds.\n* **EventBus expansion** — wire the additional signals that already\n exist on `event_bus.gd` but aren't routed to audio yet\n (`combat_started`, `unit_destroyed`, `unit_promoted`, `city_grew`,\n `city_starved`, `golden_age_started`, `golden_age_ended`,\n `border_expanded`, `culture_researched`, `wild_creature_spawned`,\n `weather_event`, `tech_research_started`).\n\nThis is a **schema-and-code** objective. No `.ogg` files land here —\nthose are `p2-16`'s responsibility, which is `blockedBy: [p2-33]` so\nthe dependency-aware ordering surfaces this work first."
},
{
"id": "p2-01",
"title": "Minimap — fog reflection and unit markers",
"priority": "p2",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-17",
"summary": "The minimap controller was already complete — terrain raster, per-tile fog\ncolor, per-player unit/city dots, click-to-emit `EventBus.camera_moved`,\nviewport-rect indicator. The gap was that nothing mounted it in\n`world_map.tscn` and no consumer listened for `camera_moved`. This bundle\nmounts the minimap in a `CanvasLayer` anchored bottom-right of the world map\nand wires both directions: `minimap.set_camera(bg_camera)` +\n`camera.set_minimap(minimap)` for viewport-rect + auto-hide at strategic\nzoom, and `EventBus.camera_moved → _on_minimap_click → cam.center_on(world)`."
},
{
"id": "p2-02",
"title": "Tooltips on all HUD elements",
"priority": "p2",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-17",
"summary": "Every interactive HUD control now carries a `tooltip_text` resolved through\n`ThemeVocabulary.lookup(\"tooltip_<key>\")`. The vocabulary file ships a\ndedicated `tooltip_*` namespace so theme packs can localize hover copy\nwithout touching scenes. Stat-row Labels also set\n`mouse_filter = MOUSE_FILTER_STOP` — Godot otherwise swallows hover on\ncontainer-managed Labels, so without this tooltips never fire on\nTurnLabel / EraLabel / Gold / Science / HP / Movement rows."
},
{
"id": "p2-03",
"title": "Hotkey cheat sheet (F1 / ?)",
"priority": "p2",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-17",
"summary": "Non-modal hotkey cheat-sheet overlay renders **dynamically** from\n`InputMap.get_actions()`. Every action whose name begins with `ui_` is\nbucketed into one of four spec-required context columns — **Map / City /\nCombat / Menus** — via the `ACTION_PREFIX_BUCKET` constant. Adding a new\nhotkey means one InputMap action declaration in `project.godot` plus one\n`action_<name>` vocab entry; no changes to `hotkey_sheet.gd`.\n\n`project.godot` `[input]` section now declares 15 `ui_*` actions covering\nevery previously-keycode-literal handler across `overlay_panel.gd`\n(11 map-overlay toggles + cycle-view), `top_bar.gd`\n(encyclopedia/diplomacy/stats), and `camera.gd` (WASD/arrows handled via\n`Input.is_key_pressed` in `_process`, not migrated because they're\ncontinuous-input reads, not discrete action presses — dynamic render still\npicks up the rest). `ui_help` toggles the sheet itself; `ui_cancel` closes\nit."
},
{
"id": "p2-04",
"title": "Localization audit — no hardcoded strings",
"priority": "p2",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-17",
"summary": "`ThemeVocabulary` is architected for localization. This objective audits every\nplayer-facing GDScript file (`.gd`) AND Godot scene file (`.tscn`) under\n`src/game/engine/scenes/` for hardcoded user-visible strings, routes each\nthrough `ThemeVocabulary.lookup()`, and wires a validator into `./run verify`.\n\nThe `.gd` scan is clean: 57 scenes scanned, 0 hits. The validator was extended\nto also scan `.tscn` inspector `text = \"...\"` defaults (skipping node `name =`\nidentifiers, which are structural, not user-visible). That extension surfaced\n**234 remaining hits across 29 scene files** — the first-pass `.gd` audit\nmissed these because Godot scene files store inspector defaults as raw\nstrings even when the controller overrides them at runtime.\n\n**Status: done.** All three acceptance bullets pass. The `.tscn` grind\nclosed the remaining 234 hits across 29 files by adding 144 new vocab keys\nto `public/games/age-of-dwarves/vocabulary.json`, adding `%`-unique\naccessors where nodes lacked them, and routing every static label/button\ntext through `ThemeVocabulary.lookup()` at `_ready()` time. Hardcoded\n`text = \"...\"` inspector defaults were stripped. Final validator run:\n`OK: 102 scenes scanned, 0 hardcoded UI strings.`"
},
{
"id": "p2-05",
"title": "Sub-second single-player turn latency",
"priority": "p2",
"status": "done",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-23",
"summary": "10-seed parallel batch completes in ~7 minutes wall-clock; single-turn latency on the RUN host is unmeasured. Target: end-of-turn processing ≤1 second on a 512-tile map with 3 AI opponents mid-game."
},
{
"id": "p2-06b",
"title": "Cross-compile Windows .exe + .dll from Linux via cargo-xwin (no Windows host)",
"priority": "p2",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-25",
"summary": "Originally framed as \"register a Windows runner\". Re-scoped 2026-04-25 (user pick) to **Option B: cargo-xwin cross-compile from Linux** — produces MSVC-ABI Windows binaries on the existing Linux runner, no Windows hardware required. Better ABI compatibility than mingw (especially for wgpu's d3d12 backend) and zero hardware cost.\n\nRecipe:\n1. Linux runner installs `cargo-xwin` (one-off): `cargo install cargo-xwin`\n2. Add MSVC target to rustup: `rustup target add x86_64-pc-windows-msvc`\n3. Install `clang` + `lld` (xwin uses these as linker)\n4. `bash src/simulator/build-gdext.sh x86_64-pc-windows-msvc` → cargo-xwin downloads MS SDK on first run (~1.5GB, cached at `~/.cache/cargo-xwin/`), builds `magic_civ_physics_gdext.dll`, copies to `engine/addons/magic_civ_physics/magic_civ_physics.x86_64.dll`\n5. `bash tools/export-single.sh windows <ver>` → Godot Linux exports the Windows .exe (cross-export is native to Godot), the script then relocates the .dll into `engine/addons/magic_civ_physics/` next to the binary\n\n`.forgejo/workflows/release.yml` retargeted to `runs-on: [self-hosted, linux, x86_64]` with a \"Setup MSVC cross-toolchain\" step."
},
{
"id": "p2-07",
"title": "Credits screen accessible from main menu",
"priority": "p2",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-17",
"summary": "Main menu carries a Credits button between Throne Room and Bug Report rows,\nrouting through `Main.change_scene` into `credits.tscn`. The credits\ncontroller reads `public/games/age-of-dwarves/data/credits.json` at runtime\nand renders one panel per section — engine, Rust crates, fonts, sprite\npipeline, contributors, special thanks — inside a ScrollContainer. Back\nbutton returns to main menu (also bound to ESC)."
},
{
"id": "p2-08",
"title": "Accessibility baseline — colorblind palette + keyboard navigation",
"priority": "p2",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-17",
"summary": "Colorblind-inclusive palettes and keyboard-only navigation are minimum-viable accessibility for a 2026 release. Player-color system currently uses hue differentiation only (red/blue/green/etc), which collapses for deuteranopia. No global keyboard nav audit exists."
},
{
"id": "p2-09",
"title": "Player guide web app — builds clean from source",
"priority": "p2",
"status": "done",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-17",
"summary": "Guide React app (Vite + TypeScript + React 19) lives under `public/games/age-of-dwarves/guide/`. WASM climate worker shares Rust crates with the game.\n\n**This pass (guide-drift-dev2 / 2026-04-17):** closed the systematic type drift between `@magic-civ/guide-engine` and its consumer. `pnpm typecheck` is now 0-errors in both packages (was 488 + 221 = 709 TS errors total). The prior \"32 errors\" count under-counted by ~22x because it only measured consumer-visible errors, not the 488 internal theme-augmentation errors in guide-engine itself.\n\nPer CLAUDE.md's hard Game-1 scope rule (*\"do NOT ship Game 2 features into Game 1\"*), Option 2 (scope-narrowing) was taken. All Game 2/3 content was excised:\n\n- **Deleted from `src/packages/guide/src/`:** entire `pages/magic/` directory (SpellsPage, MagicSchoolsPage, ArchonsPage, DisciplinesPage, LeyLinesPage), `pages/episodes/EpisodeKzzkytPage.tsx`, `pages/episodes/EpisodeElvesPage.tsx`, `pages/worlds/TheHivePlanetPage.tsx`, `pages/worlds/SilvandelPage.tsx`. Empty `pages/worlds/` dir removed.\n- **Deleted from consumer app `src/pages/`:** 5 local Magic pages (Spells, MagicSchools, Archons, Disciplines, LeyLines).\n- **Removed from routing + nav:** Ep2/Ep3 nav groups, all `/magic/*` routes, `/worlds/the-hive`, `/worlds/silvandel`, `/episodes/age-of-kzzkyt`, `/episodes/age-of-elves`.\n\n**Structural fixes landed:**\n\n- **styled-components theme augmentation** (`src/packages/guide/src/types/declarations.d.ts`): declared `DefaultTheme` with the exact `colors.{primary,accent,background,surface,border,text}` + `typography.{fontFamily,fontWeight}` shape used everywhere. Closed ~400 of 488 guide-engine errors.\n- **Ambient WASM + @resources/* + @lilith/ui-theme stubs** (`src/packages/guide/src/types/ambient.d.ts`, consumer `src/ambient.d.ts`): typed the shapes the guide actually uses, so `tsc --noEmit` from either package resolves cleanly without requiring the WASM pkg to be built.\n- **Game-data type drift:** extended `Unit` (added `hp`, `attack`, `defense`, `unit_type`, `flags`, `attributes`, `tier`, `terrain_bonus`, `encyclopedia`), `Building` (`culture_required`, `encyclopedia`), `Resource` / `Improvement` / `Item` (encyclopedia + index sig), `Tech` (replaced `unlocks_units`/`unlocks_buildings`/`unlocks_spells` → `unlocks: TechUnlocks` + `requires` + `flavor` + `encyclopedia`), `Race` (added `featured_units`, `arcane_rank`, `episode`, `status`), `EncyclopediaEntry` (added `entry_type`, `detail_route`), `EcologicalEventTier` (added `resource_table`), `StrategicAxes` (index sig for dynamic access). Added missing types: `Lens`, `LensCategory`, `LensUnlock`, `LensObservation`, `LensRendering`, `NamedResource`, `ResourceWithEncyclopedia`, `TechUnlocks`.\n- **Barrel surface:** rewrote `src/packages/guide/src/index.ts` from 52 lines to 125 lines with the full Game-1 surface (`PreferencesProvider`, `usePreferences`, `usePreferencesReroll`, `resolveGender`, `resolveRace`, `EpisodeProvider`/`Gate`, `GuideLayout`, `MobileNav`, `RaceThemeProvider`, `SPECIES_LIBRARY`, `applyObservationLens`, all retained pages, etc).\n- **New UI primitives:** added `PageHeading`, `PageSubtitle`, `DataTable`, `Highlight`, `FeatureGrid`, `FeatureChip` to `PagePrimitives.tsx` to match consumer-app expectations.\n- **Context drift:** `GuideDataContextValue` now declares `observationLens?: SpeciesObservationLens` + `speciesLibrary: ObservedSpecies[]` (consumer app was already passing these; type just wasn't there).\n- **Path aliases:** consumer's `@magic-civ/*` paths were off-by-one (`../../../` → `../../../../`); fixed. Added `@magic-civ/web-civmap` alias to guide-engine's tsconfig.\n- **Null-guard fixes:** eight consumer pages now guard optional fields before dereferencing (UnitsPage, CommunicationsPage, EncyclopediaModal, EncyclopediaPage, WondersPage, LairsPage, LensesPage, DevSpritesPage).\n\n**Remaining blocker to flip ✅ done:** `pnpm --filter @magic-civilization/guide-age-of-dwarves build` fails at the final rollup step because `.local/build/wasm/magic_civ_physics.js` is absent on the EDIT host (WASM is a per-host artifact; see `.claude/instructions/build-output-locations.md`, path was relocated from `src/simulator/pkg/` per p1-11 on 2026-04-17). Apricot was unreachable during the initial audit pass (`ssh lilith@apricot.local` timed out). Once apricot is reachable:\n\n```\nssh \"$AUTOPLAY_HOST\" \"cd $PROJECT_ROOT_REMOTE/src/simulator && bash build-wasm.sh\"\npnpm --filter @magic-civilization/guide-age-of-dwarves build # from EDIT host\n```\n\nshould yield a clean `dist/index.html` in one step. The external-hosting decision (GitHub Pages vs Cloudflare Pages vs S3) remains a separate downstream gate."
},
{
"id": "p2-10",
"title": "Automated regression CI gate on every push to main",
"priority": "p2",
"status": "partial",
"scope": "game1",
"owner": "testwright",
"updated_at": "2026-04-23",
"summary": "This project ships via direct commits to `main` on a self-hosted forge\nat `http://10.0.0.11:3000/magicciv/magicciv` (Forgejo, port 3000).\nThere is no PR workflow — `git log --oneline` shows zero \"Merge pull\nrequest\" commits, no feature branches, no review gate. Forgejo Actions\n(drone-compatible, files live in `.forgejo/workflows/`) plus a\nself-hosted apricot runner enforces the test suite on every push to\n`main`, matching the two-host workflow (CLAUDE.md: EDIT host commits, RUN\nhost executes — apricot already is the RUN host).\n\n**State as of 2026-04-17 PM**: the pipeline is operational end-to-end. apricot runner `act_runner v12.8.0` is registered at org scope, systemd user unit is linger-enabled, PATH-patched for fnm-managed node. Every push to main produces a commit status visible on the forge commit page. The Rust workspace (~700 tests, 11 crates) hard-gates the commit; GDExtension now builds as part of CI so Godot's GDScript parse resolves bridge types.\n\nTwo stages remain `continue-on-error: true` (advisory) with in-workflow comments documenting the cleanup owner and un-gating trigger: gdlint (7 structural violations — file-splitting required) and headless GUT (39 pre-existing failures out of 439). The third advisory stage (autoplay smoke / Stage 9) was un-gated 2026-04-24 after the `_finalize_run()` write race fix. These are tracked via `advisory_backlog_tracked` in the audit block above; un-gating the remaining two is the path to closing this objective from 🟡 to ✅.\n\n**gdlint status (2026-04-23):** Reduced from 88 → 7 violations via mechanical fixes (class-definitions-order in tile.gd, max-line-length in save_manager/event_bus/auto_play, duplicated-load in auto_play, function-variable-name in turn_processor/turn_processor_helpers). Remaining 7 are structural and cannot be fixed without file-splitting:\n- `max-file-lines`: unit_renderer.gd (634 lines), game_state.gd (556), city.gd (568), auto_play.gd (2238), turn_processor.gd (635), ai_turn_bridge.gd (722)\n- `max-returns`: auto_play.gd `_maybe_prioritize_worker` (>6 return statements — refactor changes game logic)\nCI Stage 3 remains `continue-on-error: true` until file-splitting is scheduled."
},
{
"id": "p2-10a",
"title": "\"CI: gdlint stage un-gated\"",
"priority": "p2",
"status": "done",
"scope": "game1",
"owner": "testwright",
"updated_at": "2026-04-25",
"summary": "The gdlint stage in `.forgejo/workflows/ci.yml` (Stage 3) currently runs with `continue-on-error: true` due to 7 structural violations that require file-splitting to resolve. This child objective tracks un-gating it so a gdlint failure hard-fails the CI pipeline. The violations are all `max-file-lines` or `max-returns` in large GDScript files (`unit_renderer.gd`, `game_state.gd`, `city.gd`, `auto_play.gd`, `turn_processor.gd`, `ai_turn_bridge.gd`). Split off from p2-10 on 2026-04-25."
},
{
"id": "p2-10b",
"title": "\"CI: headless GUT stage un-gated\"",
"priority": "p2",
"status": "done",
"scope": "game1",
"owner": "testwright",
"updated_at": "2026-04-26",
"summary": "The headless GUT stage in `.forgejo/workflows/ci.yml` (Stage 8) was running with `continue-on-error: true` due to 40 pre-existing test failures. All 40 triaged and resolved. Gate is now hard."
},
{
"id": "p2-10c",
"title": "\"Diplomacy: implement _collect_unique_luxury_ids() in happiness.gd\"",
"priority": "p2",
"status": "done",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-26",
"summary": "`happiness.gd` is expected to expose a static helper `_collect_unique_luxury_ids(player, game_map)` that collects traded + tile-based luxury resource IDs into a sorted deduplicated array. Four tests in `test_diplomacy.gd` exercise this contract. The function was never implemented."
},
{
"id": "p2-10d",
"title": "\"Data: strip legacy flags/can_found_city/can_build_improvements from unit JSON\"",
"priority": "p2",
"status": "done",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-26",
"summary": "All unit JSON files under `public/games/age-of-dwarves/data/units/` have legacy fields `flags`, `can_found_city`, and `can_build_improvements` removed (they were superseded by the `keywords` array). The test `test_no_unit_has_legacy_flags_field` was replaced from a `pending()` stub to a real assertion loop that iterates every unit JSON file and verifies none of the three keys are present."
},
{
"id": "p2-10e",
"title": "\"Data: resolve duplicate IDs and dangling unlock refs in game data\"",
"priority": "p2",
"status": "done",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-26",
"summary": "`test_data_integrity.gd` had two `pending()` stubs and the data had real dangling refs:\n1. **Duplicate IDs**: `public/resources/` is the Games 2/3 master library; `public/games/age-of-dwarves/data/` overrides it for Game 1. DataLoader loads resources first, then game data overwrites — this is intentional. The test was rewritten to check for intra-pack duplicates only (same ID in two files within the same category directory), which is the correct failure mode to guard against.\n2. **Dangling unlock refs**: 9 tech unlocks referenced buildings/improvements that don't exist in either source (`grand_forge`, `steam_foundry`, `mithril_mine`, `adamantine_vault`, `deep_garden`, `mushroom_farm`, `deep_quarry`, `root_sanctum`, `citadel_of_ages`). These are unported Game 1 content. Unlock entries removed from tech JSON files."
},
{
"id": "p2-10f",
"title": "\"SaveManager: fix typed array property assignment on Player/Unit deserialization\"",
"priority": "p2",
"status": "done",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-26",
"summary": "Four `test_save_manager.gd` tests fail with \"Invalid assignment of property or key 'X' with value of type 'Array' on a base object of type 'RefCounted (Player/Unit)'\". The affected properties are `researched_techs`, `infusions`, and others. Player/Unit declare typed arrays (e.g. `Array[String]`) but the deserializer assigns plain `Array`, causing the runtime type mismatch."
},
{
"id": "p2-10g",
"title": "\"CityBridge: add production_cost field to items JSON fixture\"",
"priority": "p2",
"status": "done",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-26",
"summary": "`test_city_bridge.gd:test_happy_path_enqueue_tick_emits_item_crafted` passes. The fixture JSON in the test already contains `production_cost: 30` nested under the `production` block, which is the exact structure the Rust `GdCity::load_items_json` deserializer expects (`ItemDoc` containing `ProductionDoc`). No changes were needed — the objective description was stale."
},
{
"id": "p2-10h",
"title": "\"UnitRenderer: implement _build_sprite_key() helper and fix cache key test\"",
"priority": "p2",
"status": "done",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-26",
"summary": "`test_sprite_renderer.gd` tests `_build_sprite_key(type_id, race_id, sex)` on `UnitRenderer` — a helper function that was never implemented. 5 tests use it directly. Additionally, `test_cache_populated_after_miss` fails because the expected cache key format doesn't match the actual `DrawHelpers`-managed cache key."
},
{
"id": "p2-10i",
"title": "\"TileTooltip: fix scene node name mismatches and collectibles text formatting\"",
"priority": "p2",
"status": "done",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-26",
"summary": "`test_tile_tooltip.gd` had three pending tests (not failing) for panel show/hide behavior.\nCollectibles text tests (1-3) were already passing via the `build_collectibles_text()` static.\nPanel tests (4-6) needed: instantiate via `load().instantiate()`, guard `GameState` nil access\nin `tile_info_panel.gd`, and use a valid terrain ID (`plains`) that exists in the tiles JSON."
},
{
"id": "p2-10j",
"title": "\"FogOfWar: fix recalculate_vision to not re-reveal already-seen tiles on move\"",
"priority": "p2",
"status": "done",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-26",
"summary": "The two tests were `pending()` placeholders. The production code (`world_map_vision.gd:recalculate_vision()`) is correct — it demotes visible→stale then re-promotes in-range tiles, which is standard fog-of-war behavior with no return value. The fix was implementing real test bodies using the existing `_expand_vision()` helper, which already counts stale→visible transitions correctly.\n\n- `test_move_scout_expands_known_count`: reveals 37 tiles on first call; after 1-hex move, asserts `< 37` new tiles (already-visible tiles stay visible)\n- `test_seeded_t10_scout_move_reveals_exact_k_tiles`: asserts exactly `2*sight_range+1 = 7` new tiles from a 1-hex axial move (the leading-edge slice formula)"
},
{
"id": "p2-11",
"title": "Version string + About screen",
"priority": "p2",
"status": "done",
"scope": "game1",
"owner": "shipwright",
"updated_at": "2026-04-17",
"summary": "Players need to know which version of the game they're running when filing bug reports. Main menu shows no version; no About screen exists."
},
{
"id": "p2-11a",
"title": "\"SaveManager: add Unit.serialize/deserialize and City.production_queue serialize path\"",
"priority": "p2",
"status": "stub",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-26",
"summary": "Unit has no serialize()/deserialize() methods — infusions, equipped_items, promo_ids, keywords and other typed arrays cannot round-trip through SaveManager. City.production_queue is a GDScript-side Array with no serialize path; the Rust-backed City.to_json() does not include it. These gaps were deferred from p2-10f, which narrowed its tests to the Player serialize surface only."
},
{
"id": "p2-12",
"title": "Install weston on apricot RUN host — unblock display-server smoke tests",
"priority": "p2",
"status": "done",
"scope": "infra",
"owner": "shipwright",
"updated_at": "2026-04-25",
"summary": "Several P0/P1 smoke gates were originally spec'd to require a wayland display server (weston, headless backend with software rendering) on apricot for `RENDER_MODE=weston tools/autoplay-batch.sh`. Weston is now installed (system rpm) and the headless backend launches cleanly.\n\nNote: the smokes that originally needed weston (p0-41a rally, p0-42a formation) were closed via headless evidence + Rust unit tests during this session, so this objective lost most of its urgency. It remains useful for any future visual-render smoke that genuinely needs a compositor.\n\nSpun out from p0-42 + p0-41a on 2026-04-25 after initial smoke runs failed with `ERROR: --weston mode but weston not installed`."
},
{
"id": "p2-18",
"title": "Guide web app — public hosting + deploy pipeline",
"priority": "p2",
"status": "partial",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-17",
"summary": "Separate from p2-09 (which covers the build being clean): this objective covers choosing a public host and running the deploy. Currently the deploy script is ready (`tools/deploy-guide.sh` — modes `build` / `serve` / `apricot` / `zip`), but no public host has been committed for Early Access. The `apricot` mode ships dist/ to the LAN for preview; `zip` produces a handoff artifact that any external host can consume."
},
{
"id": "p2-19",
"title": "Guide progress report page — dynamic dashboard + missing assets",
"priority": "p2",
"status": "done",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-17",
"summary": "Dynamic progress report page inside the Age of Dwarves guide that reads the project's objectives dashboard + asset pipeline state at runtime. Built 2026-04-17 under guide-progress-dev.\n\nDelivery:\n- `tools/objectives-report.py` extended to emit `public/games/age-of-dwarves/data/objectives.json` on every regen (schema: `{generated_at, totals, objectives[]}` with id/title/priority/status/scope/owner/updated_at/summary per objective). `--check` mode compares ignoring the volatile `generated_at`.\n- `ProgressReportPage.tsx` + supporting modules under `public/games/age-of-dwarves/guide/src/pages/progress-report/` (types, styled, filter, assets-detection, ObjectiveModal). Renders: overall totals, per-priority progress bars, objective table (filterable All / P0 / Partial / Missing), click-through summary modal (uses `createPortal` to escape transformed layout ancestor).\n- Missing assets section: scans `audio.json` (declared .ogg paths) and `units/*.json` + `buildings/*.json` (expected sprite paths) against `import.meta.glob` presence. Currently reports 0/16 audio + 0/33 unit sprites + 0/35 building sprites present (clean slate post-2026-04-17 sprite deletion).\n- Route `/progress` added in `App.tsx`, nav entry `📊 Progress Report` at top of About group.\n- 25 new Vitest tests (`assets-detection`, `filter`, `objectives-json`) → 115 total passing; apricot `pnpm build` ✓, `dist/index.html` exists, bundle 113kB.\n- Incidental fix: orphan `healing_draught` reference in `items/manifest.json` removed (was breaking app bootstrap)."
},
{
"id": "p2-20",
"title": "Fix simCachePlugin pre-warm worker — tsx can't resolve @magic-civ/physics-rs through pnpm symlink",
"priority": "p2",
"status": "done",
"scope": "game1",
"owner": "tourguide",
"updated_at": "2026-04-17",
"summary": "MCP Playwright verification of the dev-guide on plum surfaced a\npre-existing dev-only failure mode in `simCachePlugin`'s pre-warm\nworker. On `pnpm dev` startup, the plugin spawns a tsx subprocess per\nscenario (`base_no_magic`, `volcanic_winter`, `ice_age`, …) which\nstatically imports `@magic-civ/physics-rs` to pre-compute simulation\nframes into Redis. Every spawn fails with:\n\n```\nError: Cannot find package '/Users/natalie/Code/@projects/@magic-civilization/src/packages/engine-ts/node_modules/.local/build/wasm/magic_civ_physics.js'\nimported from .../src/packages/engine-ts/src/runner.ts\ncode: 'ERR_MODULE_NOT_FOUND'\n```\n\n**Root cause:** after p1-11 relocated the wasm-pack output to\n`.local/build/wasm/`, we also cleared `main` and `types` from\n`src/simulator/package.json` because node's default resolver can't\nfollow a `\"main\": \"../../.local/build/wasm/magic_civ_physics.js\"`\npath that escapes the package root — tsx in particular collapses the\n`..` segments incorrectly through pnpm symlinks and looks up\n`node_modules/@magic-civ/physics-rs/.local/build/wasm/...` (path\nprefix glued rather than resolved). With no `main` at all, tsx falls\nback to guessing `<package>/index.js`, which also doesn't exist.\n\n**User-facing impact:** none. The pre-compute is a dev optimization\n(populates Redis so the `/climate/simulation` route loads pre-rendered\nframes instead of running WASM inline). Vite's main module graph still\nresolves `@magic-civ/physics-rs` correctly via the explicit alias in\n`public/games/age-of-dwarves/guide/vite.config.ts:20`, so the route-\ncoverage e2e exercises the client-WASM fallback path and passes.\nUsers hitting the route with `noGui=true` URL params and a fresh cache\nsee the loading spinner stall at 0% (MCP verify observed this) — but\nthe normal path (no `noGui`) falls back to worker-mode automatically."
},
{
"id": "p2-21",
"title": "Bake pre-computed sim-cache frames into the static build",
"priority": "p2",
"status": "done",
"scope": "game1",
"owner": "tourguide",
"updated_at": "2026-04-18",
"summary": "`simCachePlugin` (Vite dev plugin) pre-computes climate-simulator\nscenarios on `pnpm dev` startup and serves the resulting frames over\n`/__sim-cache/<scenario>/{status,frame/<n>}` so\n`/climate/simulation` can load pre-rendered video-like playback\ninstead of running WASM inline for minutes on cold visits. Today this\nis dev-only; on production / `.next.` deploys there is no server to\nrun the plugin, so the frontend falls back to client-WASM — slow\ncold-start, but works.\n\nThis objective fills the gap: at build time, run each canonical\nscenario headlessly (node + the WASM pkg), emit the same binary frame\nformat `simCachePlugin` serves, and drop the output at\n`dist/__sim-cache/<scenario>/...` so the static deploy serves the\nsame byte streams the dev plugin serves. The frontend doesn't change\n— it still GETs `/__sim-cache/base_no_magic/status?…` and gets the\nsame shape. The `try_files $uri $uri/` line in the\n`mc.next.black.local` vhost (p1-15) already passes them through.\n\nSide effect: this closes the bulk of p2-20 for production. The tsx\npnpm-resolve bug remains in dev, but nobody hits the stall path\nbecause in dev the plugin is the fallback (both paths go through\ntsx, both fail identically — hm, actually, server-mode cold reads\nRedis first; if Redis is warm, no tsx worker is spawned). p2-20\nstill needs its own fix for cold `pnpm dev` runs."
},
{
"id": "p2-29",
"title": "Welcome modal + HomePage lore + guide theme align to the player's chosen race/gender",
"priority": "p2",
"status": "done",
"scope": "game1",
"owner": "tourguide",
"updated_at": "2026-04-18",
"summary": "The guide already exposes a welcome modal that lets the player pick a race\n(Dwarf in Game 1 — `CONCRETE_RACES = ['dwarf']`) and a gender, plus a\n`RaceThemeProvider` that merges a per-race/per-gender palette into the\nstyled-components theme. But the three surfaces don't line up:\n\n- **WelcomeModal copy** reads like a settings dialog (\"Settings\", field\n labels) rather than an invitation into the story, so the player's first\n impression is admin-UI-shaped.\n- **HomePage `<LoreSection>`** *does* pull the player's race + name via\n `usePreferences()`, but the surrounding `<Hero>` / `<Tagline>` / `<Pitch>`\n hardcode out-of-scope narrative (\"16 asymmetric races, 5 magic schools\")\n that is Game 2/3 territory and does NOT update when the player picks a\n dwarf leader.\n- **Theme application** — the palette change from `RaceThemeProvider` fires\n on confirm, but a browser already on the HomePage may not re-derive the\n Hero/Pitch colors in a visually coherent way (Cinzel serif, Dwarf copper\n `#c07040`, etc.). The \"align with welcome\" contract is not exercised.\n\nWhen the player picks **Dwarf + Female** in the modal and clicks Begin,\nall three surfaces should read as one piece: a dwarf-themed guide,\nreferring to the named dwarf leader, in Dwarf scope language (no \"5\nmagic schools\" pitch, no generic cross-race framing)."
},
{
"id": "p2-30",
"title": "Consolidate duplicate page styled-components into shared PagePrimitives",
"priority": "p2",
"status": "done",
"scope": "game1",
"owner": "tourguide",
"updated_at": "2026-04-18",
"summary": "An Explore sweep on 2026-04-18 counted ~180 redundant styled-component\ndeclarations across 15 guide pages. Each page declares its own\n`Card`, `CardHeader`, `CardTitle`, `StatsGrid`, `Stat`, `Badge`,\n`SectionLabel`, `Subtitle`, etc. that already exist (or could exist) in\nthe shared `src/packages/guide/src/components/ui/PagePrimitives.tsx`.\nThe duplication:\n\n- Makes theme-token changes N-way rather than one-way (change the Dwarf\n copper accent → audit 15 pages).\n- Hides inconsistencies. `SpeciesBrowserPage`'s `Card` has 12 px\n border-radius; `BiomeBrowserPage`'s has 10 px. Nobody notices until\n screenshots diverge.\n- Inflates each page's file size toward the 500-LOC cap, forcing\n awkward splits (`BiomeBrowserPage.tsx` is already 355 LoC of styled\n alone, ignoring JSX).\n- Forecloses easy swap-to-Markdown + swap-to-data adoption because\n each page has its own local idiom.\n\nHighest-ROI candidates (counted by Explore):\n\n- `BiomeBrowserPage.tsx:191355` — 23 custom styled, largest footprint.\n- `SpeciesBrowserPage.tsx:236330` — 16 custom styled.\n- `MapTypesPage.tsx:9103` — Name / StatsGrid / Stat / SectionLabel /\n TopologyCard / TopologyBadge / TopologyDesc restate PagePrimitives.\n- `ExpansionsPage.tsx:10132`, `TeamPage.tsx:630` — near-total overlap.\n- `EpisodeDwarvesPage.tsx` — inline `INCLUDED_SYSTEMS` array + own\n styled."
},
{
"id": "p2-31",
"title": "Migrate guide filter + tab state from useState to URL search params",
"priority": "p2",
"status": "done",
"scope": "game1",
"owner": "tourguide",
"updated_at": "2026-04-18",
"summary": "The Progress Report's Details tab set a precedent: its filter chip\nstate + per-objective modal round-trip through `useSearchParams()`\n(see `progress-report/ObjectivesTab.tsx` + `ProgressReportPage.tsx`).\nBookmarking `?tab=details&filter=partial&objective=p0-01` restores the\nexact view.\n\nThree other browsable pages still keep their filter / tab state in\n`useState`, so deep links don't work and users can't share a filtered\nview by URL:\n\n- `SpeciesBrowserPage.tsx:57` — role / biome / quality filters\n- `BiomeBrowserPage.tsx:121` — category filter + potentially a\n highlighted biome\n- `ClimateEventsPage.tsx` — category tabs"
},
{
"id": "p2-32",
"title": "Replace hardcoded page enums with JSON data reads",
"priority": "p2",
"status": "done",
"scope": "game1",
"owner": "tourguide",
"updated_at": "2026-04-18",
"summary": "Rail #2 in CLAUDE.md says \"JSON game packs are the canonical content\nstore — neither Rust nor GDScript hardcodes game content.\" Several\nguide pages still violate the spirit of that rule by hand-typing\ndata arrays in `.tsx` files. When the design changes (\"we decided\nit's 20 races, not 16\" — or \"Arcana tree got merged into Scholarship\"),\nthe fix has to hop four files in TypeScript rather than editing one JSON.\n\nHardcoded arrays the Explore sweep found:\n\n| File:Line | Array | Target JSON |\n|---|---|---|\n| `MapTypesPage.tsx:105124` | `TOPOLOGY_MODES` — 3 topologies with `{id, label, desc, math, isDefault}` | `public/games/age-of-dwarves/data/map-topologies.json` (new) |\n| `EpisodeDwarvesPage.tsx:820` | `INCLUDED_SYSTEMS` — 10 system-name strings | `public/games/age-of-dwarves/data/episodes/ep1-systems.json` (new) |\n| `HomePage.tsx:180210` (FEATURES) | \"What makes this game different\" cards | `public/games/age-of-dwarves/data/homepage-features.json` (new) — Episode 1 cards first, Episode 2+ behind an EpisodeGate at render time |\n| `progress-report/OverviewTab.tsx:114165` | Hand-typed \"Coming in v1.0.0\" + \"After Full Release\" roadmap tables | `public/games/age-of-dwarves/data/shipping-roadmap.json` (new) |"
},
{
"id": "p2-34",
"title": "Author castle as the walls upgrade tier (defensive ladder)",
"priority": "p2",
"status": "missing",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-26",
"summary": "`mc-ai/tactical/production.rs` priority 6 reads:\n\n```\n// Priority 6: castle (upgrades walls, enables bombard).\nif city.buildings.contains(\"walls\") && !city.buildings.contains(\"castle\") {\n return ids::CASTLE.into();\n}\n```\n\nThere is no `castle.json` in `data/buildings/`. The AI emits `\"castle\"` and the bridge silently drops it (see p0-45). Per user direction, castle as \"a level of wall defense\" is the right design — a tier-2 defensive building that requires `walls` and grants stronger `city_defense` / `city_hp`, gates a future bombard / siege-resistance unit family.\n\nThis objective fills the gap."
},
{
"id": "p2-35",
"title": "Palace evolution system — longhouse → great_hall → citadel → grand_citadel + courthouse",
"priority": "p2",
"status": "missing",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-26",
"summary": "`public/games/age-of-dwarves/docs/cities/BUILDINGS.md` opens with an entire **Palace Evolution** mechanic that has no data implementation:\n\n- The Palace starts as the entire civilization (one building doing all functions at reduced efficiency).\n- Researching specific techs *moves* a function out of the Palace into a dedicated building (Masonry → Mason Lodge, Smelting → Forge, Scholarship → Library, etc.) and *boosts* the Palace's remaining functions by +10%.\n- Palace itself has 4 levels: **Longhouse → Great Hall → Citadel → Grand Citadel**, each with worker-cap and primary-output progression, gated by Guilds → Academies.\n\nNone of these palace tiers exist in `data/buildings/`. There is no \"Palace\" building at all today. New cities silently spawn with no central administrative building and no shedding-of-functions chain.\n\nAdjacent gap: `BUILDINGS.md` Infrastructure section also calls for `courthouse` (Governance tech) for unrest reduction in captured cities. Also missing.\n\nThis objective is the largest design gap on the buildings audit and likely needs a design pass with the user before implementation begins. It is filed as p2 because Game 1 currently functions without it (cities just don't have a palace) — but every city-tier metaphor in the player-facing docs assumes it exists."
},
{
"id": "g2-01",
"title": "Ley lines — Game 2 (Age of Kzzykt)",
"priority": "p3",
"status": "oos",
"scope": "game2",
"owner": null,
"updated_at": "2026-04-17",
"summary": "Leylines are intuitive terrain features that Kzzykt interact with instinctively — they don't cast leylines, they live alongside them. Leylines affect tile improvements and yields and influence Kzzykt AI behavior. There is no full mana economy in Game 2; leylines are environmental, not player-managed."
},
{
"id": "g2-02",
"title": "Kzzykt playable race — Game 2 (Age of Kzzykt)",
"priority": "p3",
"status": "oos",
"scope": "game2",
"owner": null,
"updated_at": "2026-04-17",
"summary": "Kzzykt is the second playable race, added in Game 2. They are an insectoid bug civilization with Green MTG color affinity who intuitively use leylines. Game 1 ships with Dwarves only — Kzzykt and all subsequent races are out of scope until their respective games."
},
{
"id": "g2-03",
"title": "Kzzykt Green school of magic — Game 2 (Age of Kzzykt)",
"priority": "p3",
"status": "oos",
"scope": "game2",
"owner": null,
"updated_at": "2026-04-17",
"summary": "The Green school is the only school of magic in Game 2 and is optional for the player — this is where spells first enter the series. It is tied to Kzzykt's Green MTG color affinity and draws on nature, instinct, and leyline resonance. Game 2 also introduces interplanetary and spacefaring late-game progression alongside the Green school tech path."
},
{
"id": "g2-04",
"title": "Multi-GPU sharding for batch_simulate_gpu — out-of-scope (Game 2)",
"priority": "p3",
"status": "oos",
"scope": "game2",
"owner": "warcouncil",
"updated_at": "2026-04-17",
"summary": "`mc-ai::gpu::inner::GpuContext::shared()` (at `src/simulator/crates/mc-ai/src/gpu/inner.rs:189`) picks exactly ONE adapter via `instance.request_adapter(PowerPreference::HighPerformance)`. On multi-GPU hosts this leaves every adapter past #0 idle from our compute perspective.\n\napricot has 2× NVIDIA RTX 3090. Right now `batch_simulate_gpu` uses one of them (whichever wgpu selects — typically GPU0). GPU1 sits at 0% compute util from our workload. p0-20 wall-time comparisons are therefore measured against a halved ceiling."
},
{
"id": "g3-01",
"title": "Archons — Game 3 (Age of Elves)",
"priority": "p3",
"status": "oos",
"scope": "game3",
"owner": null,
"updated_at": "2026-04-17",
"summary": "Archons are the magical avatar entities in Game 3 (\"Age of Elves\"). Each player has a High Archon (mana generator + casting avatar), and each of the five magic schools has a corresponding Minor Archon. Neither Game 1 nor Game 2 has Archon entities."
},
{
"id": "g3-02",
"title": "Life school spellbook — Game 3 (Age of Elves)",
"priority": "p3",
"status": "oos",
"scope": "game3",
"owner": null,
"updated_at": "2026-04-17",
"summary": "The Life school is one of four new magic schools added in Game 3 (\"Age of Elves\"). Life magic centers on healing, growth, sustenance, and binding — the nurturing force of the color pie. Spellbook TBD as Game 3 design progresses."
},
{
"id": "g3-03",
"title": "Death school spellbook — Game 3 (Age of Elves)",
"priority": "p3",
"status": "oos",
"scope": "game3",
"owner": null,
"updated_at": "2026-04-17",
"summary": "The Death school is one of four new magic schools added in Game 3 (\"Age of Elves\"). Death magic centers on entropy, reanimation, drain, and endings — the consuming force of the color pie. Spellbook TBD as Game 3 design progresses."
},
{
"id": "g3-04",
"title": "Chaos school spellbook — Game 3 (Age of Elves)",
"priority": "p3",
"status": "oos",
"scope": "game3",
"owner": null,
"updated_at": "2026-04-17",
"summary": "The Chaos school is one of four new magic schools added in Game 3 (\"Age of Elves\"). Chaos magic centers on disruption, transformation, wild chance, and unpredictability — the volatile force of the color pie. Spellbook TBD as Game 3 design progresses."
},
{
"id": "g3-05",
"title": "Aether school spellbook — Game 3 (Age of Elves)",
"priority": "p3",
"status": "oos",
"scope": "game3",
"owner": null,
"updated_at": "2026-04-17",
"summary": "The Aether school is one of four new magic schools added in Game 3 (\"Age of Elves\"). Aether magic centers on knowledge, artifice, time, and the arcane fabric itself — the intellectual force of the color pie. Spellbook TBD as Game 3 design progresses."
},
{
"id": "g3-06",
"title": "Arcane Ascension victory — Game 3 (Age of Elves)",
"priority": "p3",
"status": "oos",
"scope": "game3",
"owner": null,
"updated_at": "2026-04-17",
"summary": "Arcane Ascension is the fifth victory condition, available only in Game 3 (\"Age of Elves\"). It requires completing a multi-step ritual powered by all five magic schools and the player's High Archon. Game 1 has Domination + Score only (p0-08). Game 2 has no Ascension path."
},
{
"id": "g4-01",
"title": "Terran (Human) playable species — Game 4 (Age of Terrans)",
"priority": "p3",
"status": "oos",
"scope": "game4",
"owner": null,
"updated_at": "2026-04-17",
"summary": "Terrans (Humans) are the Game 4 species, homeworld Terra. Drawn from Imanity (NGNL rank 16 — lowest-ranked Exceed, no innate magic). Their power comes not from magic but from adaptability, psionics, and religious organization. Game 4 is where the mundane-vs-magic tension peaks: humans have no arcane ability yet dominate through faith and mental force."
},
{
"id": "g4-02",
"title": "Psionics ability system — Game 4 (Age of Terrans)",
"priority": "p3",
"status": "oos",
"scope": "game4",
"owner": null,
"updated_at": "2026-04-17",
"summary": "Psionics is the Terran-exclusive ability system — biological/mental in nature, not arcane. It operates outside the five magic schools introduced in Game 3. Humans have an innate psionic affinity (D&D / MoTM tradition). Psionics gives Terrans combat, diplomacy, and espionage capabilities that mirror but do not overlap with spellcasting. Bridges thematically into Game 5 (Gith are also psionic)."
},
{
"id": "g4-03",
"title": "Religious victory condition — Game 4 (Age of Terrans)",
"priority": "p3",
"status": "oos",
"scope": "game4",
"owner": null,
"updated_at": "2026-04-17",
"summary": "Terrans can win by spreading religious influence across civilizations. Conversion mechanics are tiered by species: non-human species of equal or higher tier resist conversion; lower-tier species (orcs, trolls, goblins) are susceptible. This creates a distinct non-military victory path unique to Terrans."
},
{
"id": "g5-01",
"title": "Phantasma playable species — Game 5 (Age of Ascension)",
"priority": "p3",
"status": "oos",
"scope": "game5",
"owner": null,
"updated_at": "2026-04-17",
"summary": "Phantasma (NGNL rank 2) are pure spiritual beings native to the Ethereal Plane — entities of consciousness without physical form, aligned with Death school. They are ancient and immune to Terran religious spread. As rank-2 Exceed they are second only to the One True God, making them the most powerful conventional species in the series."
},
{
"id": "g5-02",
"title": "Flügel playable species — Game 5 (Age of Ascension)",
"priority": "p3",
"status": "oos",
"scope": "game5",
"owner": null,
"updated_at": "2026-04-17",
"summary": "Flügel (NGNL rank 5) are angel-weapons created by the One True God, aligned with Life school. Their existence directly conflicts with the Terran religious victory condition — humans spread faith in a divine, and Flügel *were made by* the closest thing to one. They are the central narrative tension of Game 5: the faith humans spread is real, but the Flügel are its answer. Immune to religious conversion; may have a competing religious spread mechanic of their own."
},
{
"id": "g5-03",
"title": "Gith playable species (Githyanki + Githzerai) — Game 5 (Age of Ascension)",
"priority": "p3",
"status": "oos",
"scope": "game5",
"owner": null,
"updated_at": "2026-04-17",
"summary": "Gith (MoTM / D&D) are psionic beings native to the Astral and Ethereal planes, aligned with Aether school. Two playable races: Githyanki (militant, planar conquerors) and Githzerai (monastic, psionic monks). Bridges Game 4's psionics system into Game 5 — Gith and Terrans share a psionic language, creating a unique diplomatic and conflict dynamic. Mid-to-high tier; resistant to but not immune to Terran religious spread."
},
{
"id": "g5-04",
"title": "Demonia playable species — Game 5 (Age of Ascension)",
"priority": "p3",
"status": "oos",
"scope": "game5",
"owner": null,
"updated_at": "2026-04-17",
"summary": "Demonia (NGNL rank 11) are the demon species of the Ethereal Plane, aligned with Chaos school. Low-to-mid tier; they have no coherent belief system, making them the primary beachhead for Terran religious conversion — susceptible when isolated, violently opposed when organized. They are the \"wild card\" species of Game 5: unpredictable alliances, high aggression, and the most conventional military threat."
},
{
"id": "g6-01",
"title": "Naval combat — out-of-scope (post-v10)",
"priority": "p3",
"status": "oos",
"scope": "post-v10",
"owner": null,
"updated_at": "2026-04-26",
"summary": "Hex-based naval combat — water-tile movement for ships, ship unit types (transport / warship / etc.), naval-vs-naval and naval-vs-coastal-city combat, harbor / port buildings, sea-region map topology where applicable."
},
{
"id": "g6-02",
"title": "Caravan trade routes — out-of-scope (post-v10)",
"priority": "p3",
"status": "oos",
"scope": "post-v10",
"owner": null,
"updated_at": "2026-04-26",
"summary": "Persistent trade-route units (caravans, traders) that travel between owned cities OR between own-city and foreign-city, generate per-turn gold/resource yields tied to distance and city-pair characteristics, and can be plundered by enemy units. Distinct from p1-01's instantaneous luxury-for-gold trade modal."
},
{
"id": "p3-01",
"title": "Courier-gated diplomacy — open borders + shared maps via tech-tiered courier units",
"priority": "p3",
"status": "partial",
"scope": "game1-stretch",
"owner": "envoy",
"updated_at": "2026-04-26",
"summary": "Game 1 ships diplomacy-lite: peace/war toggle plus a single bilateral luxury↔gold\ntrade action (`mc-trade`). This objective expands the diplomatic surface with two\ntrade options gated on physical infrastructure rather than instant agreement, so\ninformation itself becomes a strategic resource that decays with distance and tech:\n\n1. **Open borders** — pay luxury or gold for the right to move units through\n another civ's territory for N turns. Instant effect; pure trade.\n\n2. **Shared map** — pay luxury or gold for the other civ's explored map for N\n turns. **Not instant**: the deal is gated on a courier link between capitals.\n Knowledge propagates at the courier's movement speed; the courier is killable\n mid-route (intercept = no map delivered, payment already made). The Courier\n unit family has tech-gated upgrade tiers, one per era from era_2 onward; later\n tiers shrink the delay window and shift the intercept surface from\n killing-the-unit to severing-the-infrastructure.\n\nThis is **scope: game1-stretch** — Game 1's stated scope is \"diplomacy-lite\", so\nthis objective is post-Early-Access content unless explicitly pulled forward."
},
{
"id": "p3-02",
"title": "Hybrid merged structures — war_academy, assault_citadel, cavalry_corps, gunnery_corps",
"priority": "p3",
"status": "missing",
"scope": "game1",
"owner": null,
"updated_at": "2026-04-26",
"summary": "`public/games/age-of-dwarves/docs/cities/BUILDINGS.md` \"Hybrid Merged Structures\" describes a tier-7-unlocked \"merge two co-located buildings into one hybrid\" mechanic with four named hybrids:\n\n| Merged structure | Requires | Exclusive units |\n|---|---|---|\n| War Academy | Barracks+Rifle Range + Stable+Barding Hall | Dragoon, Mounted Rifleman, Assault Cavalry |\n| Assault Citadel | Barracks+Sword Hall + Siege Workshop+Siege Annex | Siege Breaker, Combat Engineer, Storm Trooper |\n| Cavalry Corps | Stable+Barding Hall + Barracks+Bolt Range | Mounted Archer, Beast Scout, Ram Sniper |\n| Gunnery Corps | Barracks+Rifle Range + Siege Workshop+Powder Annex | Mortar Team, Assault Gunner, Field Artillery |\n\nMultiple prerequisite buildings (Stable, Barding Hall, Siege Annex, Powder Annex) and exclusive units do not exist in data. Game 1 also does not implement co-located building tile slots, master/grandmaster auras, or merge irreversibility — all called out elsewhere in BUILDINGS.md.\n\nThis is a **post-EA expansion-tier feature**. Filed at p3 to keep the gap visible without implying Game 1 EA depends on it."
}
]
}