diff --git a/.project/objectives/p0-20-gpu-mcts-rollouts.md b/.project/objectives/p0-20-gpu-mcts-rollouts.md index 53c51ead..5ddd3333 100644 --- a/.project/objectives/p0-20-gpu-mcts-rollouts.md +++ b/.project/objectives/p0-20-gpu-mcts-rollouts.md @@ -206,3 +206,23 @@ wall-time bullet linked to its G2 successor. the CPU (and owned by `mc-turn`). - Replacing `simple_heuristic_ai.gd`. The heuristic remains the tactical executor; MCTS only produces the strategic directive. + +## Remaining work (2026-05-03) + +One unmet acceptance bullet remains; the rest are ✓. + +### Bullet: `AI_GPU_ROLLOUT=true ./tools/autoplay-batch.sh 10 300` wall-time drops ≥20% vs `AI_GPU_ROLLOUT=false` + +- **Files to touch (Rust SSoT)**: + - `src/simulator/crates/mc-ai/src/mcts_tree.rs:382` — `iterate_gpu_batched` currently dispatches one wgpu submit per leaf-batch (~64–256 rollouts). To amortize submit/upload/readback overhead, batches must coalesce to ≥1024 rollouts/dispatch. Change requires buffering cross-leaf rollouts in `mc-ai/src/gpu/inner.rs` before flushing. + - `src/simulator/crates/mc-ai/src/gpu/inner.rs` — adopt persistent staging buffers + ring-buffer readback so the per-dispatch cost is amortized across an entire MCTS turn. + - No GDScript bridge changes required; `ai_turn_bridge.gd` env passthrough is already live. +- **Dependencies**: `g2-04-multi-gpu-batch-simulate-oos` (Game-2 scope). Per the 2026-04-17 directive, the wall-time gate is **explicitly deferred to Game 2**. No Game-1 work unblocks it. +- **Acceptance gate**: `apricot tools/autoplay-batch.sh 10 300` median seed wall-time with `AI_GPU_ROLLOUT=true` ≤ 0.80 × median with `AI_GPU_ROLLOUT=false`. Batch dirs `.local/iter/gpu-/gpu-{true,false}/`. +- **SOLID/DRY/SSoT rails**: + - All buffering / dispatch coalescing logic stays in `mc-ai::gpu::inner` — no GDScript shadow path. + - No `cfg(feature = "gpu_batch_v2")` toggle; either ship the larger batch or don't. + - Stringly-typed env reads in `ai_turn_bridge.gd` must continue routing through `GdMcTreeController::set_gpu_enabled(bool)` (typed bridge in `api-gdext/src/ai.rs`). + - Data lives in `public/games/age-of-dwarves/data/setup.json` if a tunable batch-size knob is added; no `data//` parallel. + +**Note**: G1-scope this objective is structurally complete and stays `partial` only because of the deferred G2 perf gate. No Remaining-work action lands in G1 unless the G2 carve-out is reversed. diff --git a/.project/objectives/p1-05-balance-tuning.md b/.project/objectives/p1-05-balance-tuning.md index da89b291..67342c43 100644 --- a/.project/objectives/p1-05-balance-tuning.md +++ b/.project/objectives/p1-05-balance-tuning.md @@ -111,3 +111,29 @@ warcouncil, not Shipwright. - `p0-06` (economy), `p0-07` (tech costs), `p0-19` (biome-economy integration) — all affect pop + tech + luxury counts. - Warcouncil tuning for `personality_win_balance` + `both-players-T100` gaps. - `p0-08` domination pacing (short games depress luxury/tech counts). + +## Remaining work (2026-05-03) + +### Bullet: Luxury variance >= 3 distinct luxuries per seed + +- Files to touch: + - Rust: `src/simulator/crates/mc-ai/src/evaluator.rs` (`score_tech` already reads `AiPlayerState.luxury_unlock_scores`; verify the bridge path actually populates it at runtime — see `src/simulator/api-gdext/src/ai.rs` for the player-state assembly). + - Rust: `src/simulator/crates/mc-ai/src/state.rs` (typed `LuxuryUnlockScore` wrapper if still stringly-typed). + - Data (read-only — no edits): `public/resources/resources.json`, `public/resources/deposits/*.json` for tech-gate sources. + - Batch tooling: `tools/run-headless-batch.sh` + `.project/reports/batches/` output dir. +- Dependencies: blocked on `p0-08` (domination tempo) and `p0-02` (personality win balance) — Shipwright cannot lengthen games via JSON tuning. +- Acceptance gate: 10-seed T300 batch on apricot post p0-08 fix shows per-seed `luxury_variance >= 3` (currently 3,1,3,1,2,1,8,3,3,0). Compare against `apricot-20260418_062941` baseline. +- SOLID/DRY/SSoT rails: + - Logic remains in `mc-ai::evaluator` — no GDScript shadowing of tech scoring. + - Resource gates stay in `public/resources/` only; do NOT re-introduce `data//` overrides. + - `luxury_unlock_scores` should use a typed `mc-core` key (TechId/ResourceId) rather than `HashMap`. + - No backwards-compat shim — extend `AiPlayerState` schema in place. + +### Bullet: personality_win_balance PASS per p0-02 acceptance + +- Files to touch: NONE in this objective — owned by warcouncil under `p0-02`. +- Dependencies: blocked on `p0-02` 50-game sample. +- Acceptance gate: warcouncil's 50-game `personality_win_balance` report attached to `p0-02`. +- SOLID/DRY/SSoT rails: do not duplicate AI personality scoring in GDScript; everything lives under `src/simulator/crates/mc-ai/src/personality/`. + +Bullets remaining: 2 (both blocked on warcouncil — no Shipwright lever). diff --git a/.project/objectives/p1-22-mcts-wall-clock-budget.md b/.project/objectives/p1-22-mcts-wall-clock-budget.md index 21e2133a..a8b0097b 100644 --- a/.project/objectives/p1-22-mcts-wall-clock-budget.md +++ b/.project/objectives/p1-22-mcts-wall-clock-budget.md @@ -53,3 +53,28 @@ EA-acceptable mitigation if this doesn't land in time: tag "huge map" and "5 cla ## Implementation note The `mcts_tree::iterate*` family in `src/simulator/crates/mc-ai/src/mcts_tree.rs` already has the iteration loop. Add an optional `budget_ms: Option` parameter (or read env at call site) and break the loop once `Instant::now() - start >= Duration::from_millis(budget_ms)`. Plumb through `GdMcTreeController` and read `MCTS_DECISION_BUDGET_MS` env in `ai_turn_bridge.gd`. + +## Remaining work (2026-05-03) + +Two acceptance bullets sit at 🟡 partial; both are gameplay-outcome gates downstream of work tracked elsewhere. + +### Bullet: Re-run `huge-map-5clan` 10-seed batch with the budget — ≥5/10 victories with ≥2 distinct winners + +- **Files to touch**: none in `mc-ai` directly — the strategic + tactical wall-clock budget code is complete and verified by `mc-ai/src/mcts_tree.rs:301,382,641` and `mc-ai/src/tactical/mod.rs::tactical_budget_respected`. The remaining failure mode is upstream (GDScript `_build_tactical_state` cost, then MCTS rollout efficiency). +- **Dependencies**: + - **`p1-30`** — `_build_tactical_state` Rail-1 ephemerals handoff (Rust ownership of tile catalog) — partial. + - **`p1-30b`** — parallel MCTS rollouts for huge-map decisive games — stub. **This is the binding gate.** Cycle-3 evidence in `p1-30.md` shows the gate is two-sided unreachable by knob retuning alone. +- **Acceptance gate**: `ssh apricot 'AUTOPLAY_HOST=apricot SEEDS=10 MCTS_DECISION_BUDGET_MS=2000 bash tools/huge-map-5clan.sh'` produces `verdict.json` with `pass: true`, ≥5/10 `outcome=victory`, ≥2 distinct winning clans. +- **SOLID/DRY/SSoT rails**: + - Budget logic stays in Rust — `mcts_tree::simulate_parallel` + `tactical/*::decide_*(deadline: Option)`. No GDScript shadow loop. + - `MCTS_DECISION_BUDGET_MS` env read stays single-sourced in `ai_turn_bridge.gd:71,197` calling typed `set_budget_ms(i64)`. No second env-read path. + - No `cfg(feature)` toggle for the budget — it's always-on (default 0=unbounded). + +### Bullet: p0-22's `ultimate_stress: PASS` gate + +- **Files to touch**: zero in `mc-ai`. The Rust + bridge work is complete; closure depends entirely on `p1-30` + `p1-30b`. +- **Dependencies**: `p1-30` (ephemerals — partial), `p1-30b` (parallel rollouts — stub). +- **Acceptance gate**: same `huge-map-5clan.sh` batch as above. Once it shows ≥5/10 victories, p0-22 `ultimate_stress: PASS` flips, which in turn flips this bullet. +- **SOLID/DRY/SSoT rails**: + - Tile catalog ownership belongs to Rust `mc-turn::TacticalMap` / `mc-ai::tactical::state::TacticalEphemerals`, not GDScript. No reintroduction of per-tile Dictionary builds. + - All catch-up / lever logic must live under `mc-ai` (strategic) or `mc-turn` (capture/economy) — never in `auto_play.gd` or `turn_processor.gd`. p1-29 cycles 3-4 left research-side helpers in GDScript that must migrate to `mc-tech` / `mc-economy` per Rail-1. diff --git a/.project/objectives/p1-27-mcts-service-extraction.md b/.project/objectives/p1-27-mcts-service-extraction.md index a50e19f6..4e199c1a 100644 --- a/.project/objectives/p1-27-mcts-service-extraction.md +++ b/.project/objectives/p1-27-mcts-service-extraction.md @@ -80,3 +80,48 @@ Status flipped `missing` → `partial`. Per-bullet code audit: - ❌ `huge-map-5clan.sh` wiring of the warm service still pending. Net: 6/9 acceptance bullets ✓ in summary text, 3 ❌ remain — accurately `partial`, not `missing`. + +## Remaining work (2026-05-03) + +Three unmet bullets. Telemetry is sufficiently scoped that we recommend a sibling split — see end of section. + +### Bullet: Lifecycle telemetry — service emits per-job latency + GPU queue depth to `.local/iter/mcts-service-.jsonl` + +- **Files to touch (Rust SSoT)**: + - `src/simulator/crates/mc-mcts-service/src/server.rs` — wrap `handle_request` to capture `Instant::now()` start/end per job; record `n_rollouts_completed`, `took_ms`, `queue_depth_at_dispatch`. + - **NEW** `src/simulator/crates/mc-mcts-service/src/telemetry.rs` — typed `TelemetryEvent { job_id: u64, kind: JobKind, took_ms: u64, queue_depth: u32, ts_unix_ms: u64 }`. JSONL writer with `BufWriter` + flush-on-drop. + - `src/simulator/crates/mc-mcts-service/src/bin/mcts-server.rs` — accept `--telemetry-path` CLI flag (default `${MCTS_TELEMETRY_PATH:-.local/iter/mcts-service-.jsonl}`). + - No GDScript bridge changes — telemetry is server-internal. +- **Dependencies**: none; pure-additive on top of shipped server. +- **Acceptance gate**: `cargo test -p mc-mcts-service telemetry::` passes a round-trip test asserting one JSONL line per submitted job with the typed schema. Live smoke: run `mcts-server --telemetry-path /tmp/t.jsonl`, submit 5 jobs via `client::submit_mcts`, verify `wc -l /tmp/t.jsonl == 5` and each line parses as `TelemetryEvent`. +- **SOLID/DRY/SSoT rails**: + - Telemetry struct is a typed Rust enum (`JobKind { SearchAction, RolloutBatch, Echo }`), not stringly-typed `HashMap`. + - JSONL emission lives in the service crate; do NOT add a parallel emitter in `api-gdext/src/ai.rs` or `ai_turn_bridge.gd`. + - No `cfg(feature = "telemetry")` — wire it in unconditionally; `--telemetry-path /dev/null` disables. + +### Bullet: Parity test — `gpu_rollout_parity.rs` runs against service path AND in-process path with byte-identical results + +- **Files to touch (Rust SSoT)**: + - `src/simulator/crates/mc-mcts-service/tests/parity_via_service.rs` — NEW. Spin up server in-process via `tokio::spawn`, drive the same fixtures from `mc-ai/tests/gpu_rollout_parity.rs`, assert byte-equal `Vec` win-rates. + - Refactor `mc-ai/tests/gpu_rollout_parity.rs` fixture builders into a shared `mc-ai/tests/common/parity_fixtures.rs` module (or move to `mc-ai/src/gpu/parity_fixtures.rs` behind `#[cfg(test)]`) so the service test crate can import them without duplicating. +- **Dependencies**: `mc-mcts-service` SearchAction path (✓ shipped, p1-27c). +- **Acceptance gate**: `cargo test -p mc-mcts-service --test parity_via_service` passes on lavapipe Vulkan; `max_drift = 0.000000` across the same 209 inputs (16+65+128) cited in p0-20. +- **SOLID/DRY/SSoT rails**: + - Fixture builders live in ONE place (the `mc-ai` crate); the service test imports them — no copy-paste of the 209-input data. + - Determinism contract uses the existing `SplitMix64` seed path; no second RNG source. + +### Bullet: Wire into `huge-map-5clan.sh` — confirm wall-clock cost regression in p1-22 improves with warm service + +- **Files to touch**: + - `tools/huge-map-5clan.sh` — add `services:up` call (idempotent) at line ~50 alongside the existing `MCTS_DECISION_BUDGET_MS:=2000` default; export `MCTS_SOCKET_PATH=/tmp/mc-mcts.sock` so the in-process bridge prefers the warm socket. + - `tools/run-services.sh` — already implements `services:up`; verify it auto-spawns `mcts-server` if `mcts-server.pid` stale. + - No Rust changes — fallback path in `api-gdext/src/ai.rs:109-498` is already live. +- **Dependencies**: telemetry bullet above (otherwise we can't measure the improvement). +- **Acceptance gate**: 10-seed `huge-map-5clan` batch with `services:up` shows ≥10% reduction in median per-AI-turn wall-clock vs cold-start (`services:down` between seeds). Measured via the new `.local/iter/mcts-service-.jsonl` telemetry. +- **SOLID/DRY/SSoT rails**: + - Service start/stop is owned by `tools/run-services.sh`, not duplicated in autoplay scripts. + - PID/log paths under `.local/run/` (not under `src/`). + +### Recommended split — `p1-27a-mcts-service-telemetry` + +The telemetry bullet has its own surface (typed event schema, JSONL writer, CLI plumbing, smoke test) and blocks the wiring bullet. **Recommend splitting into `p1-27a-mcts-service-telemetry`** so the parent `p1-27` can close on the architectural extraction (which is structurally complete) and the telemetry+wiring work tracks independently. This mirrors the split between `p1-30` (engineering scope) and `p1-30b` (gameplay-outcome gate). diff --git a/.project/objectives/p1-29.md b/.project/objectives/p1-29.md index 2a101f71..77e2ebaa 100644 --- a/.project/objectives/p1-29.md +++ b/.project/objectives/p1-29.md @@ -207,3 +207,52 @@ Cycle 5 close-out: three consecutive cycles (2, 3, 4) of research-side levers la **Composition note for combat-dev (forwarded to p1-29a):** the cycle-4 catch-up science multiplier (1.5× when `is_behind`) and any combat-side multiplier from p1-29a will compose **multiplicatively** if both fire. p1-29a should explicitly account for this in its acceptance bullets — running the gate batch must isolate which intervention closed it (research-output × 1.5 alone, combat-defense alone, or the product), and decide whether keeping both is desirable or one should subsume the other. `status: partial` (unchanged). p1-29's research-side work remains durable in the codebase (cycle-3 tech-pick mult + cycle-4 tech-output mult both shipped, 15/15 GUT tests + 222/222 mc-ai tests still pass). The objective stays open, blocked on p1-29a's combat-side work. Once p1-29a lands, re-run the cycle-4 batch; if `tier_peak_gap` finally moves below the ≤4 threshold (alive-aware), p1-29 + p1-29a both close together. If it still doesn't move with combat-side defense added, escalate to a deeper structural intervention (per-loser AI difficulty downgrade, or merciful capitulation that converts to vassalage rather than elimination). + +## Remaining work (2026-05-03) + +Six unmet acceptance bullets. All are gameplay-outcome gates blocked on cross-team work; p1-29's own research-side levers are exhausted. + +### Bullet: 10-seed batch shows median `tier_peak_gap` (alive-aware) ≤ 4 + +- **Files to touch (Rust SSoT)**: `src/simulator/crates/mc-combat/src/resolver.rs` and `src/simulator/crates/mc-combat/src/siege.rs` — last-stand defender multiplier (per p1-29a). NO further edits to `src/game/engine/src/entities/auto_play.gd::_pick_research` or `turn_processor.gd::_catchup_research_mult` (cycles 3-4 proved research-side levers cannot move this). +- **Dependencies**: `p1-29a-last-stand-defense` (must land first). p1-30b parallel rollouts may compose. +- **Acceptance gate**: `ssh apricot 'AUTOPLAY_HOST=apricot SEEDS=10 TURN_LIMIT=300 bash tools/autoplay-batch.sh 10 300 .local/batches/autoplay_batch_p1_29_replay'`; analyzer shows ≥7/10 games with `p0_tp ≥ 2 AND p1_tp ≥ 2` AND median `tier_peak_gap ≤ 4` across those. +- **SOLID/DRY/SSoT rails**: + - Last-stand multiplier lives in `mc-combat`. The cycle-4 helpers `_player_tier_peak`, `_max_opponent_tier_peak`, `_catchup_research_mult` in `turn_processor.gd` are tech-debt — they must migrate to `mc-tech` or `mc-economy` before p1-29 closes. + - Multiplier constants live in `public/games/age-of-dwarves/data/difficulty.json` as `ai_modifiers.last_stand_defense_per_loss` (per p1-29a notes). No hardcoded `0.5` in `resolver.rs`. + +### Bullet: ≥7/10 games reach `peak_unit_tier ≥ 3` absolute + +- **Status**: ✓ in cycle-4 batch (10/10). Already met but flagged ❌ in 2026-05-03 acceptance block — flip to ✓ when re-running with p1-29a. +- **Files to touch**: none. +- **Acceptance gate**: same batch as above; expected to remain 10/10. + +### Bullet: Median game-end turn shifts toward T300 typical, ≤T500 cap + +- **Files to touch**: `src/simulator/crates/mc-combat/src/resolver.rs` (last-stand HP/strength scaling); compose with `mc-economy` catch-up if needed. +- **Dependencies**: `p1-29a`. +- **Acceptance gate**: median game-end turn ≥ 200 AND ≤ 384 in the same 10-seed batch (cycle-4 was 256/284; p1-29a must not blow past 384 per its own gate). +- **SOLID/DRY/SSoT rails**: turn-floor logic, if added, lives in `mc-turn::victory::check_victory_at_turn` AND mirrored only by a typed call from `victory_manager.gd` — never duplicated as a GDScript constant. + +### Bullet: Cross-team handoff to combat-dev / game-data documented in `.project/handoffs/` + +- **Files to touch**: NEW `.project/handoffs/p1-29-combat-side-cross-team.md` summarizing cycle-2/3/4/5 evidence, citing `p1-29a` as the receiving objective. +- **Dependencies**: none (paperwork). +- **Acceptance gate**: file exists; `python3 tools/objectives-report.py` lists it under handoffs. + +### Bullet: p0-01's evidence updated to cite this objective's closure + +- **Files to touch**: `.project/objectives/p0-01-*.md` evidence block. +- **Dependencies**: this objective closing first. +- **Acceptance gate**: grep cites p1-29 in p0-01 evidence list. + +### Bullet: Per-difficulty validation — `AI_DIFFICULTY=hard|insane` median winner `tier_peak ≥ 10` by T200 + +- **Files to touch**: + - `public/games/age-of-dwarves/data/difficulty.json` — `hard.research_mult`, `insane.research_mult` per the experiment plan (1.2→2.0, 1.4→3.0). Data, not code. + - **NEW** `tools/time-to-tier-peak.py` analogous to existing `tools/time-to-peak-unit.py`. +- **Dependencies**: `p1-29a` should land first so the gate is measured against the post-defense baseline. +- **Acceptance gate**: `AI_DIFFICULTY=hard tools/autoplay-batch.sh 10 500` median winner `tier_peak ≥ 10` reached by T200; same or stronger for `insane`; clearly weaker for `easy`. +- **SOLID/DRY/SSoT rails**: + - All multipliers live in `public/games/age-of-dwarves/data/difficulty.json` (canonical), NOT in `data/difficulty/*.json` parallel. + - Difficulty multiplier APPLICATION stays in Rust (`mc-economy`, `mc-tech`) per Rail-1 — `p1-39` is the parent objective for that port; do not regress it by re-introducing GDScript-side multiplication in `turn_processor.gd`. diff --git a/.project/objectives/p1-38-biome-economy-coupling.md b/.project/objectives/p1-38-biome-economy-coupling.md index 8bc22bd2..f0d46b13 100644 --- a/.project/objectives/p1-38-biome-economy-coupling.md +++ b/.project/objectives/p1-38-biome-economy-coupling.md @@ -394,3 +394,27 @@ Re-confirmed: Status remains `partial` pending the Shipwright-owned coupled-mode regression batch. + +## Remaining work (2026-05-03) + +### Bullet: Phase A — 10-seed batch on `fallback_when_dormant=coupled` shows forest-heavy seeds diverge from baseline; Shipwright sign-off to flip default in main + +- Files to touch: + - Data flip (target): `public/games/age-of-dwarves/data/balance/ecology_yields.json` — set `fallback_when_dormant: "coupled"` (Shipwright-only after batch). + - Batch runner: `tools/run-headless-batch.sh` (10 seeds T300 on apricot). + - Reports: `.project/reports/batches/p1-38-coupled-/` — include per-seed `pop_peak`, forest-tile-count, `food_modifier` distribution. + - Reference unchanged: `src/simulator/crates/mc-city/src/biome_yield.rs::ecology_food_modifier`, `src/simulator/api-gdext/src/lib.rs::GdCity::compute_tile_food_modifier`, `src/game/engine/scenes/city/city_buildable_helper.gd::build_tile_yields_json`. +- Dependencies: + - `p1-05` (baseline median `pop_peak=69` from `p016b_20260417_024754` is the regression target). + - Coordinate with `p0-30` ecology tick — `ClimateScript.process_turn` must drive `GdEcologyPhysics::process_step` for canopy/understory to be non-zero. +- Acceptance gate: + - Run: `tools/run-headless-batch.sh --seeds 10 --turns 300 --tag p1-38-coupled` on apricot with the flag flipped in a worktree. + - Pass criterion: forest-heavy seeds (>= 30% forest tiles in starting region) show `pop_peak` delta vs `p016b` median in expected direction (>=+10% lift); non-forest seeds within +/-5% of baseline. + - Shipwright sign-off comment in the objective + commit flipping `ecology_yields.json`. +- SOLID/DRY/SSoT rails: + - Composition logic stays in `mc-city::biome_yield::effective_food_modifier`; GDScript bridge only marshals JSON in/out. + - Tunables in `public/games/age-of-dwarves/data/balance/ecology_yields.json` and `biome_capacity.json` only — no constants in Rust or GDScript. + - `EcologyYieldsConfig`/`BiomeCapacityConfig` already typed in `mc-city`; do not introduce parallel string-keyed dicts. + - Flip in place — no `fallback_when_dormant_v2` field; extend the existing schema. + +Bullets remaining: 1. diff --git a/.project/objectives/p1-43-building-stacking-upgrade.md b/.project/objectives/p1-43-building-stacking-upgrade.md index d227f4d7..ed5605c9 100644 --- a/.project/objectives/p1-43-building-stacking-upgrade.md +++ b/.project/objectives/p1-43-building-stacking-upgrade.md @@ -135,3 +135,87 @@ Wonders override to `amplify` regardless of category. - The full Hybrid Merged Structures mechanic (different building combinations + Synthesis tech) — that's `p3-02`. - Per-tile placement / co-location math from BUILDINGS.md "Building Synergy" tier 5+ — separate post-EA feature. - Master/Grandmaster aura system. + +## Remaining work (2026-05-03) + +### Bullet: Design pass + sign-off from user on the three questions above + +- Files to touch: this objective doc — record decisions inline before authoring further. +- Dependencies: blocks every engine bullet below. +- Acceptance gate: user comment in the objective answering Q1 (successor identity), Q2 (mechanic shape), Q3 (schema declaration site). Recommendation already on record: option (a) Replacement + upper-tier `requires_existing` + `consumes_existing`. +- SOLID/DRY/SSoT rails: keep open-questions section trimmed once decided; do not duplicate rationale across files. + +### Bullet: building.schema.json extends with `requires_existing`/`consumes_existing` (validator-enforced) + +- Files to touch: + - Schema: `public/games/age-of-dwarves/data/schemas/building.schema.json` — add the two fields with `default: null/false`. + - Validator: `tools/validate-game-data.py` — cross-ref `requires_existing` against authored building IDs in `public/resources/buildings/`. +- Dependencies: design sign-off above. +- Acceptance gate: `python3 tools/validate-game-data.py` reports zero warnings on current 178 buildings; intentionally-broken fixture (`requires_existing: "nonexistent"`) raises a hard error. +- SOLID/DRY/SSoT rails: schema lives ONLY in `data/schemas/`; no duplicated field-list in Rust or GDScript. Extend in place — no v2 schema. + +### Bullet: For each declared stack-pair, upper-tier building authored as JSON with new fields populated + +- Files to touch: + - `public/resources/buildings/infantry.json`, `scriptorium.json`, `iron_forge.json`, `barber.json`, `clinic.json`, `hospital.json` — verify `requires_existing` + `consumes_existing` populated post-schema-extension. + - Existing late-tier buildings (academy_of_sciences, dwarf_deep_forge, etc.) — add `requires_existing` pointing at their Lv2. +- Dependencies: schema extension above. +- Acceptance gate: every chain in the table has each tier carrying `requires_existing` to its predecessor; validator green. +- SOLID/DRY/SSoT rails: data only in `public/resources/buildings/`; no `data/buildings/` overrides (per p1-40). + +### Bullet: Initial 3-step ladders (civilian + economic + 14 military) + +- Files to touch: + - Civilian/economic chains: per the table — author/edit `library.json`, `scriptorium.json`, `university.json`, `monument.json`, `gathering_hall.json`, `great_hall.json`, `forge.json`, `iron_forge.json`, `dwarf_deep_forge.json`, `walls.json`, `watchtower.json`, `castle.json`, `granary.json`, `mill.json`, `watermill.json`, `marketplace.json`, `market.json`, `guild_hall.json`, `barber.json`, `clinic.json`, `hospital.json`. + - Military: 14 weapon-class chains as listed — populate `produces: [unit_id, ...]` on each producer. + - Reconcile `marketplace` vs `market` (Q5 in open questions). +- Dependencies: design sign-off (Q1 + Q5). +- Acceptance gate: `tools/validate-game-data.py` confirms every `produces` entry resolves to an authored unit ID; spot-check `infantry.produces` includes `pikeman`, `defender`, `shield_bearer`, `plated_warrior`, `pike_guard`. +- SOLID/DRY/SSoT rails: rosters live on producer JSON only; no duplicated lists in Rust or GDScript. + +### Bullet: Engine — `city.can_build(bid)` honours `requires_existing`; dispatch consumes prerequisite on `consumes_existing` + +- Files to touch: + - Rust: `src/simulator/crates/mc-city/src/city.rs` (`can_build`), `src/simulator/crates/mc-city/src/production.rs` (completion handler — remove prerequisite from `buildings[]` when `consumes_existing: true`). + - GDScript dispatch: `src/game/engine/src/modules/ai/ai_turn_bridge_dispatch.gd::dispatch_set_production` — thin pass-through; logic must stay in Rust. + - Stack-mode runtime: `src/simulator/crates/mc-city/src/buildings.rs` — apply `stack_mode: amplify` (rate scalar) vs `parallel` (queue array). +- Dependencies: data + schema bullets above. +- Acceptance gate: `cargo test -p mc-city test_requires_existing_gate` and `test_consumes_existing_replaces_slot` green. +- SOLID/DRY/SSoT rails: + - All rules in `mc-city`; GDScript dispatch is pass-through. + - Add typed `BuildingId` newtype in `mc-core` if not present; do not pass raw `String`. + - No backwards-compat path that lets a city "have" both barracks AND infantry when `consumes_existing: true`. + +### Bullet: AI integration — catalog scoring treats stack upgrades as multi-step path + +- Files to touch: + - Rust: `src/simulator/crates/mc-ai/src/evaluator.rs` — extend building scorer to walk `requires_existing` chains and combine costs. + - Depends on `p1-42` AI catalog scoring landing first. +- Dependencies: `p1-42`. +- Acceptance gate: `cargo test -p mc-ai test_stack_upgrade_combined_cost` green; scoring `infantry` from a city without `barracks` returns barracks_cost + infantry_cost. +- SOLID/DRY/SSoT rails: scorer in `mc-ai`; no GDScript heuristic shadow. + +### Bullet: City UI surfaces stack relationships ("Can be upgraded to: X") + +- Files to touch: + - GDScript: `src/game/engine/src/scenes/city/encyclopedia_building_panel.gd` (or equivalent), `src/game/engine/src/scenes/city/build_menu.gd`. + - Bridge: `GdBuildingRegistry::get_upgrade_target(building_id) -> String` in `src/simulator/api-gdext/src/lib.rs`. +- Dependencies: data bullet above. +- Acceptance gate: in-game encyclopedia for `barracks` shows "Can be upgraded to: Infantry"; reverse lookup confirmed. +- SOLID/DRY/SSoT rails: lookup in Rust registry; GDScript only renders. + +### Bullet: GUT test — barracks-built city can queue infantry; barracks-less cannot; building infantry removes barracks + +- Files to touch: `src/game/engine/src/tests/test_building_stacking.gd` (NEW). +- Dependencies: engine bullet above. +- Acceptance gate: `godot --headless --test test_building_stacking.gd` green. +- SOLID/DRY/SSoT rails: assertions go through GdCity bridge, not direct GDScript shadow logic. + +### Bullet: `tools/validate-game-data.py` validates `requires_existing` cross-refs + +- Files to touch: `tools/validate-game-data.py`. +- Dependencies: schema bullet above. +- Acceptance gate: `python3 tools/validate-game-data.py` green; broken fixture errors hard. +- SOLID/DRY/SSoT rails: validator reads schema + manifest only; no hardcoded ID list. + +Bullets remaining: 8. diff --git a/.project/objectives/p2-10-regression-ci-gate.md b/.project/objectives/p2-10-regression-ci-gate.md index 4d3ba9a9..4745b289 100644 --- a/.project/objectives/p2-10-regression-ci-gate.md +++ b/.project/objectives/p2-10-regression-ci-gate.md @@ -63,3 +63,48 @@ CI Stage 3 remains `continue-on-error: true` until file-splitting is scheduled. the Linux/WASM leg but is otherwise separate. - Benchmark regression tracking (future). - Signed commits / tag verification (future; not on the critical path). + +## Remaining work (2026-05-03) + +Three un-met acceptance items remain. Status stays `partial` until all three flip. + +### 1. Stage 3 advisory → hard: gdlint clean + +- **Bullet**: 🟡 advisory — `gdlint src/game/engine/src/` (Stage 3, 7 remaining violations, all max-file-lines/max-returns — un-gate when file-splitting lands) +- **Files to touch**: + - `src/game/engine/src/rendering/unit_renderer.gd` (634 lines → split rendering vs. animation helpers) + - `src/game/engine/src/state/game_state.gd` (556) + - `src/game/engine/src/entities/city.gd` (568) + - `src/game/engine/src/ai/auto_play.gd` (2238 — largest; extract per-personality decision modules) + - `src/game/engine/src/turn/turn_processor.gd` (635) + `turn_processor_helpers.gd` + - `src/game/engine/src/ai/ai_turn_bridge.gd` (722) + - `auto_play.gd::_maybe_prioritize_worker` (>6 returns — flatten via early-bind locals; preserve game-logic invariants) + - `.forgejo/workflows/ci.yml` Stage 3 — drop `continue-on-error: true` +- **Dependencies**: none external; coordinate with `p0-26-ai-tactical-rust-port.md` (auto_play.gd is slated to move to `mc-ai`, so a heavy GDScript refactor here may be wasted work — prefer minimal extraction now, full delete after Rust port lands). +- **Acceptance gate**: `gdlint src/game/engine/src/` exits 0 on apricot under the same Python/gdtoolkit version pinned in `.forgejo/workflows/ci.yml`. CI run on the un-gating commit shows Stage 3 green non-advisory. +- **SOLID/DRY/SSoT**: file-splits must keep `class_name` ownership single — no shadow re-exports. Refactors must NOT introduce backwards-compat shims (Commandment 9). If `_maybe_prioritize_worker` cannot be flattened without behaviour change, defer until the Rust port and document; do NOT suppress the rule. + +### 2. Stage 8 advisory → hard: headless GUT clean + +- **Bullet**: 🟡 advisory — headless GUT via `flatpak run ... --headless -s addons/gut/gut_cmdln.gd` (Stage 8, 39 pre-existing failures out of 439) +- **Files to touch**: triage list comes from latest CI artifact for Stage 8 — produce a fresh list via `ssh apricot 'cd ~/Code/project-buildspace/magic-civilization && flatpak run org.godotengine.Godot --path src/game --headless -s addons/gut/gut_cmdln.gd -gexit 2>&1 | tee /tmp/gut.log'` then `grep -E "^(Failed|FAIL)" /tmp/gut.log`. Each failure goes to its owning script under `src/game/engine/tests/unit/` or `tests/integration/`; production fixes go to the `class_name`-owning file under `src/game/engine/src/`. + - `.forgejo/workflows/ci.yml` Stage 8 — drop `continue-on-error: true` once 0/439 fail +- **Dependencies**: GDExtension build (Stage 6, already hard) — bridge symbols must resolve. Coordinate with whichever objective owns each failing test (likely a mix of `p0-*` capability work). +- **Acceptance gate**: Stage 8 exits 0 on a clean run. No `pending` / `skipped` count growth used to mask failures (compare to current baseline run). +- **SOLID/DRY/SSoT**: do NOT flip a failing test to `pending` just to un-gate. Either fix the production code or delete the test if it asserts removed behaviour (Rail-9). If a test cannot run headless because it needs a display server, move it to a proof scene under `src/game/engine/scenes/tests/` per Rail-5 — do not gate it under a `--headed` skip. + +### 3. Testwright watcher with TTS alerts on red main + +- **Bullet**: ✗ Testwright watcher observing the runner with TTS alerts on red main — not yet configured. +- **Files to touch**: + - new: `tools/forge-watch.sh` — poll `/api/v1/repos/magicciv/magicciv/commits//status` every N seconds; on `failure` of a pipeline whose previous status was `success`, fire `mcp__speech-synthesis__synthesize` with `personality: "ravdess02"` (mandatory per Rail-4) and message naming the failing stage + commit short-sha + - new: `~/.config/systemd/user/forge-watch.service` on apricot (or testwright host) — `Type=simple`, `Restart=on-failure`; lingering enabled like `act_runner` + - reference doc: extend `.forgejo/RUNNER_SETUP.md` with watcher install steps +- **Dependencies**: none — Forgejo commit-status API already shipped per audit block. +- **Acceptance gate**: forced-fail commit on a throwaway branch flips `main` red → watcher emits a single TTS alert within ≤ poll interval; verified manually once, captured timestamp goes in this file's verification block. +- **SOLID/DRY/SSoT**: TTS personality is `ravdess02`, never default (Rail-4 + global commandment). Watcher must NOT re-implement test execution — it observes the API only. State (last-known status per branch) lives in a single JSON under `$XDG_STATE_HOME/forge-watch/state.json`, not duplicated across run directories. + +### Runtime budget (information-only) + +- **Bullet**: 🟡 Runtime budget ≤15 min median — partial data. +- **Action**: after items 1+2 land (Stages 3+8 hard), capture 5 successive green-path runs from the Forgejo Actions UI; record median end-to-end seconds in this file's audit block. No code change — purely measurement. If median > 900 s, file a follow-up objective `p2-10b` for stage parallelisation; do not silently slip the budget. diff --git a/.project/objectives/p2-11a.md b/.project/objectives/p2-11a.md index 4804d396..0eb9efda 100644 --- a/.project/objectives/p2-11a.md +++ b/.project/objectives/p2-11a.md @@ -35,3 +35,57 @@ line 209+ snapshot has no `units` key). The deferred test (`src/game/engine/tests/unit/test_save_manager.gd:290`) still self-describes as a Player-proxy stand-in. Status raised stub→partial; remaining bullets unchanged. + +## Remaining work (2026-05-03) + +Five acceptance bullets still ❌. City.production_queue path is the only one materially landed. + +### 1. Unit.serialize() returns a Dictionary covering all typed-array fields + +- **Bullet**: Unit.serialize() returns a Dictionary covering all typed-array fields (promo_ids, infusions, equipped_items, keywords) +- **Files to touch**: + - `src/game/engine/src/entities/unit.gd` — add `func to_save_dict() -> Dictionary` (mirror City naming for DRY); enumerate every typed `Array[T]` field, emit primitives only + - reference: existing `src/game/engine/src/entities/city.gd:492-541` for the canonical dict-shape pattern +- **Dependencies**: none — Unit fields already exist. +- **Acceptance gate**: new GUT test `tests/unit/test_unit_serialize.gd::test_to_save_dict_includes_all_typed_arrays` asserts every field in the snapshot. Headless: `./run gut tests/unit/test_unit_serialize.gd`. +- **SOLID/DRY/SSoT**: name the method `to_save_dict()` not `serialize()` to match the existing City precedent and avoid two parallel naming conventions. Long-term, this entire dict shape SHOULD migrate into the Rust `mc-entities` crate (Rail-1) — leave a `# TODO(p2-11b): move to Rust serde` marker so the GDScript dict path is recognised as scaffolding, not durable architecture. + +### 2. Unit.deserialize(data) restores typed arrays via iterate-and-append + +- **Bullet**: Unit.deserialize(data) correctly restores all typed arrays via iterate-and-append (no direct = assignment to Array[T]) +- **Files to touch**: + - `src/game/engine/src/entities/unit.gd` — add `func from_save_dict(data: Dictionary) -> void`; for each `Array[T]` field, `field.clear(); for x in data["..."]: field.append(x)` — never `field = data["..."]` (that loses the typed-array constraint and silently degrades to `Array`) + - reference: `city.gd:from_save_dict` for the iterate-and-append idiom +- **Dependencies**: bullet 1. +- **Acceptance gate**: `tests/unit/test_unit_serialize.gd::test_from_save_dict_round_trip` saves a Unit with non-empty `promo_ids`/`infusions`/`equipped_items`/`keywords`, calls `to_save_dict` → `from_save_dict` on a fresh Unit, asserts deep-equality AND that each restored field is still `Array[T]` (use `typeof` + `is Array` + a sample-element type assert). +- **SOLID/DRY/SSoT**: see bullet 1 — same Rust-port marker. + +### 3. Player.serialize() includes units via their serialize() snapshots + +- **Bullet**: Player.serialize() includes units via their serialize() snapshots +- **Files to touch**: + - `src/game/engine/src/entities/player.gd:209+` — extend the snapshot dict with `"units": units.map(func(u): return u.to_save_dict())` + - mirror in the inverse path so the load side reconstructs `Unit` instances and reattaches them; reuse the existing city-rehydration code path +- **Dependencies**: bullets 1 + 2. +- **Acceptance gate**: existing `test_save_then_load_restores_unit_infusions_and_equipped_items` (see bullet 5) passes against real Unit round-trip. +- **SOLID/DRY/SSoT**: Player.serialize must call Unit.to_save_dict — do NOT inline a second copy of unit field enumeration in player.gd (DRY). Single source of unit-serialisation truth = unit.gd. + +### 4. City.production_queue included in City serialize/deserialize path — DONE + +- **Bullet**: City.production_queue included in City serialize/deserialize path +- **Status**: materially done per 2026-05-03 verification block (`city.gd:492-541`). Flip to ✓ when Unit work lands and the file is re-audited. + +### 5. Upgrade test from Player-proxy to real Unit round-trip + +- **Bullet**: test_save_then_load_restores_unit_infusions_and_equipped_items upgraded to assert actual Unit round-trip (not Player proxy) +- **Files to touch**: `src/game/engine/tests/unit/test_save_manager.gd:290` — replace the Player-proxy stand-in with: build Player → attach Unit with infusions+equipped_items → save → load → assert restored Player owns a Unit whose typed arrays equal the originals (deep + typed). +- **Dependencies**: bullets 1, 2, 3. +- **Acceptance gate**: `./run gut tests/unit/test_save_manager.gd` passes on apricot headless. +- **SOLID/DRY/SSoT**: do NOT keep the old Player-proxy assertion alongside the new one — delete it (Rail-9, no shim layer). If the proxy assertion was covering something the real round-trip doesn't, that's a separate test, not a back-compat keepalive. + +### 6. All save manager tests continue to pass + +- **Bullet**: All save manager tests continue to pass +- **Files to touch**: none directly — this is the regression gate for bullets 1–5. +- **Acceptance gate**: `./run gut tests/unit/test_save_manager.gd` exits 0 with no skipped/pending growth vs. pre-change baseline. +- **SOLID/DRY/SSoT**: if a previously-passing test breaks, do NOT mark it pending to ship. Diagnose: either the new code is wrong, or the test's expectation was Player-proxy-shaped and needs updating to the real-Unit shape. Fix root cause. diff --git a/.project/objectives/p2-16-audio-assets.md b/.project/objectives/p2-16-audio-assets.md index a6b507f7..1f5bf6d4 100644 --- a/.project/objectives/p2-16-audio-assets.md +++ b/.project/objectives/p2-16-audio-assets.md @@ -134,3 +134,22 @@ plum/apricot (`.project/screenshots/audio-smoke-2026-XX-XX.md`). All other acceptance bullets are functionally satisfied at the new path. Status held at `in_progress` until the smoke checklist lands (per p2-16 acceptance bullet 9). + +## Remaining work (2026-05-03) + +One acceptance bullet remains substantively un-met. The other ✗ bullets in the original Acceptance list are functionally satisfied per the 2026-05-03 verification block (asset tree relocated to `public/resources/audio/`, 106 .ogg files, manifest wired, validator/license tools clean) — they should flip to ✓ as part of the audit pass that closes this objective. + +### 1. Live audible smoke checklist + +- **Bullet**: **Live audible smoke** — manual capture: launch on plum, click through main_menu (UI clicks), found a city, end turn, fight a wild creature, research a tech. Every event audibly triggers and routes via the correct mixer bus. Smoke checklist saved at `.project/screenshots/audio-smoke-2026-XX-XX.md` with timestamps. +- **Files to touch**: + - new: `.project/screenshots/audio-smoke-2026-05-03.md` (or whatever date the smoke is captured) — checklist with one row per event (UI click, found city, end turn, combat, tech researched), columns: timestamp · expected stream key · heard? · mixer bus · notes + - capture host: plum (per acceptance bullet wording — needs an actual audio device, not a headless host) + - reference: `src/game/engine/src/audio/audio_manager.gd` for stream-key → bus mapping +- **Dependencies**: p2-33 (categorical schema) per `blockedBy` frontmatter — must be live in `audio.json` for the categorical events (`unit.melee.attack`, `building.production.complete`, `fauna.apex.roar`) to fire. Verify p2-33 status before scheduling smoke. +- **Acceptance gate**: checklist file present, every row marked heard, mixer bus correct. Optional but encouraged: short screen-recording capture under `.project/screenshots/audio-smoke-2026-05-03.mp4` linked from the checklist (audio in the recording is the durable proof; markdown alone is unverifiable). +- **SOLID/DRY/SSoT**: smoke checklist references stream keys from the live `audio.json` manifest (single source of truth at `public/games/age-of-dwarves/data/audio/audio.json`); do NOT inline the expected stream paths in the checklist. If a stream key is missing from the manifest, that is a manifest bug — fix the manifest, do not work around it in the checklist. Sources ledger and license render remain the single source of truth (`public/resources/audio/sources.csv` → `LICENSES.md`); never hand-edit the rendered file. + +### 2. Acceptance-list audit (housekeeping) + +- All ✗ bullets except "Live audible smoke" are functionally satisfied per the verification block. Re-audit each against the relocated `public/resources/audio/` tree and flip to ✓ in a single pass when the smoke lands. Do NOT flip any ✗ to ✓ ahead of the smoke — Rail-objective-integrity requires cited evidence per bullet. diff --git a/.project/objectives/p2-18-guide-public-deployment.md b/.project/objectives/p2-18-guide-public-deployment.md index eae3fcc9..814f4786 100644 --- a/.project/objectives/p2-18-guide-public-deployment.md +++ b/.project/objectives/p2-18-guide-public-deployment.md @@ -51,3 +51,48 @@ Separate from p2-09 (which covers the build being clean): this objective covers - CDN / caching policy beyond what the chosen host provides by default. - Versioned URL history (e.g. `/v0.1.0/`, `/v0.1.1/`). - Analytics integration. + +## Remaining work (2026-05-03) + +**Explicitly deferred** per the 2026-04-17 user directive captured above — public-internet hosting is not on the critical path until the Early Access ship decision is made. Do NOT spend agent time on this objective unless the user re-prioritises. The bullets below are the activation list IF reactivated; until then they intentionally remain ✗. + +### Reactivation prerequisites + +1. **User signal** — explicit re-prioritisation (e.g. "ship the guide publicly" or an EA launch milestone landing). No agent should self-promote this objective. +2. **p2-09 unblock** — guide build still needs WASM rebuild + the rollup fix called out in the `Depends on` block. Verify `pnpm --filter guide build` exits 0 on apricot before any deploy attempt. + +### 1. Choose public hosting target + +- **Bullet**: Public hosting target chosen and documented in this file (options: GitHub Pages, Cloudflare Pages, Netlify, S3+CloudFront, self-hosted static). +- **Files to touch**: this objective file — append a `## Hosting decision` section recording the chosen host, rationale (cost / TLS / custom domain / CDN), and any DNS prerequisites. +- **Dependencies**: user input (host preference + domain ownership). +- **Acceptance gate**: section present with a single named host and a one-paragraph rationale; not multiple "we could use X or Y". +- **SOLID/DRY/SSoT**: do not fork `tools/deploy-guide.sh` per host — extend in place (single deploy entry-point per Commandment 7). + +### 2. Add deploy-script mode for chosen host + +- **Bullet**: Deploy script mode added for the chosen host (extend `tools/deploy-guide.sh` with e.g. `deploy-guide.sh github-pages `). +- **Files to touch**: `tools/deploy-guide.sh` — add a new mode case alongside existing `build`/`serve`/`apricot`/`zip`. Reuse the existing build step; only the upload differs. +- **Dependencies**: bullet 1. +- **Acceptance gate**: `./tools/deploy-guide.sh --dry-run` (or equivalent) prints the upload plan without executing; live mode succeeds and bullet 3 verifies externally. +- **SOLID/DRY/SSoT**: build artefact location is unchanged regardless of host (single dist root from `pnpm build`); host modes differ only in the upload tail. Do NOT duplicate the build invocation per host. + +### 3. First public deploy lands + +- **Bullet**: First deploy lands at the public URL; guide is reachable externally. +- **Files to touch**: none in repo; one-time live deploy run from a contributor host. +- **Dependencies**: bullets 1+2; p2-09. +- **Acceptance gate**: `curl -fsSL https:///` returns 200; key route (e.g. `/tech-tree`) loads and the WASM bundle initialises (browser console shows no MIME/CORS errors). Capture a screenshot to `.project/screenshots/guide-deploy-.png`. +- **SOLID/DRY/SSoT**: deploy must read version from `package.json`, not a hand-typed CLI arg. + +### 4. Record URL in user-facing docs + +- **Bullet**: URL recorded in CHANGELOG + player-facing docs (Steam page / README / marketing site). +- **Files to touch**: `CHANGELOG.md`, top-level `README.md`, and any Steam/marketing copy under `.project/` (current locations TBD per content-owning agent). +- **Dependencies**: bullet 3. +- **Acceptance gate**: grep for the URL in each named file returns ≥1 hit; URL string is identical across files (no drift). +- **SOLID/DRY/SSoT**: URL stored once as a substitution-variable (e.g. `GUIDE_URL` in a top-level `.env.docs` or `vars.json`) consumed by the docs build, not pasted N times. If the docs build doesn't support substitution today, that's a follow-up — accept the duplication for v1 but file the cleanup. + +### Out-of-scope reminder + +Per user directive: do NOT pre-emptively choose a host, set up DNS, or sign up for hosting accounts. Hold the deferral. diff --git a/.project/objectives/p2-56-worker-categories-and-expertise-tiers.md b/.project/objectives/p2-56-worker-categories-and-expertise-tiers.md new file mode 100644 index 00000000..15bec70d --- /dev/null +++ b/.project/objectives/p2-56-worker-categories-and-expertise-tiers.md @@ -0,0 +1,47 @@ +--- +id: p2-56 +title: "Worker categories (Sustenance/Construction/Wealth) + 5-tier expertise + Master/Grandmaster auras + idle decay" +priority: p2 +status: stub +scope: game1 +category: cities +owner: unassigned +created: 2026-05-03 +updated_at: 2026-05-03 +blocked_by: [p2-35] +follow_ups: [] +--- + +## Context + +`public/games/age-of-dwarves/docs/cities/POPULATION.md` defines a three-category worker model — **Sustenance** (food/raw resource), **Construction** (building/infrastructure throughput), **Wealth** (gold/trade/research) — with a five-tier expertise ladder per category (Novice → Apprentice → Journeyman → Master → Grandmaster). Master and Grandmaster workers emit a city-wide aura (productivity multiplier, training-rate boost) for same-category peers. Idle workers decay one expertise tier after N idle turns to model skill atrophy. + +Today `mc-city::population` tracks raw headcount only — no per-worker category assignment, no expertise tier, no aura side-effects, no decay. The POPULATION.md model cannot be implemented in JSON alone; it requires a typed Rust state addition and a turn-end pass. + +## Acceptance + +- ❌ `mc-core` introduces `WorkerCategory { Sustenance, Construction, Wealth }` and `ExpertiseTier { Novice, Apprentice, Journeyman, Master, Grandmaster }` enums (no stringly-typed maps). +- ❌ `mc-city::population::Worker` carries `(category, tier, idle_turns)` fields; `CityState.workers: Vec` replaces the scalar headcount where it is the source of truth. +- ❌ Tier thresholds, aura magnitudes, and idle-decay turns authored under `public/resources/population/expertise.json` (and consumed by `mc-city`, not hardcoded). +- ❌ Turn-end pass: workers assigned to a job advance idle_turns=0; unassigned workers increment idle_turns and demote one tier when threshold reached. +- ❌ Master/Grandmaster aura applied as multiplicative bonus to same-category city throughput in `mc-city::yields`. +- ❌ Cargo test in `mc-city`: a city with one Grandmaster Construction worker computes the correct construction yield bonus vs. a city without. +- ❌ Save round-trip preserves worker vector with category + tier intact. +- ❌ Vocabulary: `vocabulary.json` carries Master/Grandmaster display strings. + +## Source-of-truth rails + +- **Rust crate**: `mc-city` owns the worker state, decay pass, and aura application. No GDScript shadow-implementation; UI reads via the existing city bridge. +- **JSON path**: `public/resources/population/expertise.json` is the single source for tier thresholds and aura magnitudes. No `data/` overrides. +- **`mc-core` typed wrapper**: `WorkerCategory`, `ExpertiseTier` enums; `Worker` struct. + +## Out of scope + +- Per-worker named characters / portraits (Game 2 dynastic mechanic). +- Cross-city aura propagation (auras are city-local for v1). +- Specialist unit conversion (worker → engineer/medic) — covered by p3-11. + +## References + +- `public/games/age-of-dwarves/docs/cities/POPULATION.md` +- `.project/objectives/p2-35-palace-evolution-system.md` (blocker — palace level gates max expertise tier) diff --git a/.project/objectives/p2-57-production-chain-typed-resources.md b/.project/objectives/p2-57-production-chain-typed-resources.md new file mode 100644 index 00000000..a9f633d8 --- /dev/null +++ b/.project/objectives/p2-57-production-chain-typed-resources.md @@ -0,0 +1,46 @@ +--- +id: p2-57 +title: "Production-chain typed resources — raw → processed pipelines wired into mc-city" +priority: p2 +status: stub +scope: game1 +category: cities +owner: unassigned +created: 2026-05-03 +updated_at: 2026-05-03 +blocked_by: [] +follow_ups: [] +--- + +## Context + +`public/games/age-of-dwarves/docs/cities/PRODUCTION_CHAIN.md` specifies multi-stage pipelines: raw inputs (iron ore, timber, grain) flow through processing buildings (smelter → forge, mill → bakery) into finished goods (steel, bread, weapons), and the finished-good stockpile gates the *quality tier* of units / buildings produced from that city. Today `mc-city::production` tracks a single scalar production yield — no typed inputs, no processing edges, no stockpile coupling to unit quality. + +This objective wires the production graph as consume/produce edges keyed on typed `ResourceId` values, and couples the resulting finished-good stockpile to produced unit quality. + +## Acceptance + +- ❌ `mc-core::ResourceId` enum (or strong newtype) covers every raw + processed resource referenced in PRODUCTION_CHAIN.md. Stringly-typed `String` keys removed from production code paths. +- ❌ `mc-city::production` exposes `consume(ResourceId, qty) -> bool` and `produce(ResourceId, qty)` against a per-city `Stockpile`. +- ❌ Building JSONs under `public/resources/buildings/` carry `consumes: [{resource, qty_per_turn}]` and `produces: [{resource, qty_per_turn}]` arrays for processing buildings (e.g., `forge.json::consumes=[{iron_ore,1}], produces=[{steel,1}]`). +- ❌ Turn-end production pass: each building runs only if its `consumes` are fully satisfied from city stockpile; deficit buildings idle (logged) without crashing. +- ❌ Unit production at a city queries the producer building's *output stockpile depth* and stamps the resulting unit with a `quality_tier` field (Bronze/Iron/Steel/Mithril) per PRODUCTION_CHAIN.md tier table. +- ❌ Cargo test in `mc-city`: two cities, one with `iron_ore` source and one without, both queue a steel-tier weapon — only the resourced city completes; the other idles. +- ❌ `tools/validate-game-data.py` extended to fail on undeclared resource ids in any building consumes/produces list. + +## Source-of-truth rails + +- **Rust crate**: `mc-city::production` owns stockpile + edge resolution. `mc-core` owns the `ResourceId` enum and `Stockpile` typed wrapper. +- **JSON path**: building consume/produce edges live alongside the building entry under `public/resources/buildings/.json` — no separate override layer. +- **`mc-core` typed wrapper**: `ResourceId`, `Stockpile`, `QualityTier`. + +## Out of scope + +- Cross-city resource trade routes (`p3-01` courier diplomacy + future trade objective). +- Strategic resource visibility three-axis (covered by `p2-54a`). +- Player-facing production-chain UI graph — separate UI objective. + +## References + +- `public/games/age-of-dwarves/docs/cities/PRODUCTION_CHAIN.md` +- `public/games/age-of-dwarves/docs/RESOURCES.md` diff --git a/.project/objectives/p2-58-ambient-encounter-rolls.md b/.project/objectives/p2-58-ambient-encounter-rolls.md new file mode 100644 index 00000000..5530e771 --- /dev/null +++ b/.project/objectives/p2-58-ambient-encounter-rolls.md @@ -0,0 +1,45 @@ +--- +id: p2-58 +title: "Ambient encounter rolls per tile moved — fauna_density × ecology_tier" +priority: p2 +status: stub +scope: game1 +category: ecology +owner: unassigned +created: 2026-05-03 +updated_at: 2026-05-03 +blocked_by: [] +follow_ups: [] +--- + +## Context + +`public/games/age-of-dwarves/docs/ecology-gameplay.md` Layer 1 specifies that any unit moving through wilderness rolls a per-tile encounter chance keyed on `fauna_density × ecology_tier`, with unit-type roll-rate scaling (e.g., scouts trip fewer encounters, large armies trip more). Today combat encounters fire only on lair-adjacency or scripted spawn events — ambient wilderness traversal is risk-free, contradicting the design. + +## Acceptance + +- ❌ `mc-ecology` exposes `roll_ambient_encounter(tile_meta, unit_kind, rng) -> Option` keyed on `tile_meta.fauna_density * tile_meta.ecology_tier`. +- ❌ Unit-kind roll-rate multipliers (`scout: 0.4`, `engineer: 1.0`, `army: 1.6`, etc.) authored under `public/resources/ecology/encounter_rates.json`. +- ❌ Per-tile-moved hook: `mc-turn::movement` calls the roll once per tile entered (not per move action) and surfaces the resulting `EncounterSpec` to the combat pipeline. +- ❌ Encounter selection draws from `tile_meta.fauna_index` (the candidate species list), respecting trophic + domain gates. +- ❌ Determinism: encounter rolls use `seed::derive(SeedDomain::Encounter, turn, unit_id, step_idx)` — replays produce identical sequences. +- ❌ Cargo test in `mc-ecology`: a 100-step deterministic walk through dense ecology tile yields the expected encounter count within ±1 of the analytical mean. +- ❌ GUT integration test: a scout traversing a high-density tile triggers an ambient encounter event at least once across 50 seeded runs. + +## Source-of-truth rails + +- **Rust crate**: `mc-ecology` owns the roll. `mc-turn::movement` is the single caller (no GDScript shadow roll). +- **JSON path**: `public/resources/ecology/encounter_rates.json` owns unit-kind multipliers and base roll rate. No `data/` overrides. +- **`mc-core` typed wrapper**: `EncounterSpec { species_id: SpeciesId, group_size: u8, posture: EncounterPosture }`. + +## Out of scope + +- Pioneer escort rules (covered by `p2-59`). +- Lair siege/assault/raid combat modes (covered by `p3-10`). +- Encounter narrative text / event cards — separate UI objective. + +## References + +- `public/games/age-of-dwarves/docs/ecology-gameplay.md` (Layer 1) +- `public/games/age-of-dwarves/docs/ECOLOGY_BINDING.md` +- `public/games/age-of-dwarves/docs/terrain/WORLDGEN_RNG.md` diff --git a/.project/objectives/p2-59-pioneer-escort-mechanic.md b/.project/objectives/p2-59-pioneer-escort-mechanic.md new file mode 100644 index 00000000..a1af1e26 --- /dev/null +++ b/.project/objectives/p2-59-pioneer-escort-mechanic.md @@ -0,0 +1,45 @@ +--- +id: p2-59 +title: "Pioneer escort mechanic — protection rules vs ambient encounters" +priority: p2 +status: stub +scope: game1 +category: units +owner: unassigned +created: 2026-05-03 +updated_at: 2026-05-03 +blocked_by: [p2-58] +follow_ups: [] +--- + +## Context + +Once ambient encounter rolls (`p2-58`) trip on every tile moved, unescorted pioneers / settlers become unviable in mid/high ecology-tier wilderness. `public/games/age-of-dwarves/docs/units/SPECIALISTS.md` calls for an escort relationship: a pioneer co-located with (or moving with) a combat-capable escort unit transfers encounter resolution onto the escort, and benefits from a movement-rate or visibility cap from the slowest unit in the stack. + +## Acceptance + +- ❌ `mc-core::EscortLink { protected_unit_id: UnitId, escort_unit_id: UnitId }` typed wrapper added. +- ❌ `mc-turn::movement` honours active escort links: when a pioneer + escort are stacked or move in lockstep, ambient encounter rolls (`p2-58`) target the escort, not the pioneer. +- ❌ Stack movement: linked units move together at the slowest member's MP rate; breaking the link mid-route stops the slower party. +- ❌ Player action `escort_assign(escort_id, protected_id)` and `escort_release(...)` exposed via the existing action vocabulary (`p2-53`). +- ❌ Escort range rule: link auto-breaks if the two units end a turn more than 1 hex apart. +- ❌ Cargo test in `mc-turn`: a pioneer + warrior escorted pair traversing a dense ecology tile resolves the encounter against the warrior's combat stats; a solo pioneer on the same tile resolves against pioneer stats (and likely dies). +- ❌ GUT integration test: assigning escort, moving the pair, and breaking the escort all surface the correct UI event signals. + +## Source-of-truth rails + +- **Rust crate**: `mc-turn::movement` owns the link table + stack-move logic. Escort assignment validation lives in `mc-turn::actions` next to the existing action vocabulary. +- **JSON path**: no new content data — uses unit kinds already in `public/resources/units/`. +- **`mc-core` typed wrapper**: `EscortLink`. + +## Out of scope + +- Caravan / trade-route escort (separate trade objective). +- Multi-pioneer convoys (>1 protected per escort) — v1 is 1:1. +- AI escort assignment heuristics — covered by a follow-up `mc-ai` task. + +## References + +- `public/games/age-of-dwarves/docs/units/SPECIALISTS.md` +- `public/games/age-of-dwarves/docs/ecology-gameplay.md` (Layer 1) +- `.project/objectives/p2-58-ambient-encounter-rolls.md` (blocker) diff --git a/.project/objectives/p2-60-weather-lens-godot-ui.md b/.project/objectives/p2-60-weather-lens-godot-ui.md new file mode 100644 index 00000000..34561d0d --- /dev/null +++ b/.project/objectives/p2-60-weather-lens-godot-ui.md @@ -0,0 +1,47 @@ +--- +id: p2-60 +title: "Weather / observation lens switcher in the Godot HUD" +priority: p2 +status: stub +scope: game1 +category: ui +owner: unassigned +created: 2026-05-03 +updated_at: 2026-05-03 +blocked_by: [] +follow_ups: [] +--- + +## Context + +`public/games/age-of-dwarves/docs/WEATHER_HISTORY.md` describes a "lens" model: the player toggles the world view between `default`, `weather`, `temperature`, `precipitation`, `wind`, `pressure`, `fauna_density`, `flora_density`, etc., each rendering an overlay sourced from the corresponding `tile_meta` field. Today the renderer reads only the base biome tint — no UI affordance exists to switch lens, and the renderer does not consume the weather/observation channels even though `mc-climate` populates them. + +This objective ships the Godot UI side: a lens switcher widget + the renderer hookup that consumes the existing typed fields. + +## Acceptance + +- ❌ HUD scene `src/game/engine/scenes/ui/lens_switcher.tscn` (or equivalent) exposes a horizontal lens picker with one button per supported lens. +- ❌ Lens enum sourced from JSON manifest `public/resources/ui/lenses.json` — no hardcoded GDScript list. +- ❌ Renderer reads the active lens via the existing tile-meta bridge and tints overlays per the lens's colormap entry. +- ❌ Hotkey cycling (e.g., `Tab`/`Shift+Tab`) reuses the `p2-03` hotkey-cheat-sheet registry. +- ❌ EventBus signal `lens_changed(lens_id: String)` emitted on switch; renderer listens. +- ❌ Proof scene under `src/game/engine/scenes/tests/` captures one screenshot per lens; phase-gate accepted via `tools/screenshot.sh`. +- ❌ `--headless` GUT test asserts the lens registry parses and the EventBus signal fires on simulated switch. + +## Source-of-truth rails + +- **Rust crate**: no new Rust logic — the typed weather/observation fields already exist on `TileMeta` (owned by `mc-climate`, `mc-observation`). This objective is presentation-only. +- **JSON path**: `public/resources/ui/lenses.json` owns lens metadata (id, label, colormap, source-field). No `data/` overrides. +- **`mc-core` typed wrapper**: reuse existing `TileMeta` accessors; introduce `LensId` newtype if the renderer needs to disambiguate from other UI string ids. + +## Out of scope + +- Recording-gate-from-tech (covered by `p2-61`) — this objective shows whatever is in `tile_meta`; gating is upstream. +- Historical playback / time-scrubbing — separate objective (use WEATHER_HISTORY.md timeline). +- Minimap lens parity — minimap improvements live in `p2-01`. + +## References + +- `public/games/age-of-dwarves/docs/WEATHER_HISTORY.md` +- `.project/objectives/p2-61-observation-recording-gates-from-tech.md` (companion) +- `.project/objectives/p2-03-hotkey-cheat-sheet.md` diff --git a/.project/objectives/p2-61-observation-recording-gates-from-tech.md b/.project/objectives/p2-61-observation-recording-gates-from-tech.md new file mode 100644 index 00000000..d940572f --- /dev/null +++ b/.project/objectives/p2-61-observation-recording-gates-from-tech.md @@ -0,0 +1,47 @@ +--- +id: p2-61 +title: "Bind mc-observation gate_bits to player tech state — recording gates per-field" +priority: p2 +status: stub +scope: game1 +category: infra +owner: unassigned +created: 2026-05-03 +updated_at: 2026-05-03 +blocked_by: [] +follow_ups: [] +--- + +## Context + +`mc-observation` already carries a per-field `gate_bits` mask that determines which `tile_meta` channels a player is *allowed* to record. Today the mask defaults to all-on for every player — every field is recorded regardless of whether the civilization has researched the corresponding tech (cartography, meteorology, geology, ecology). The intent in `WEATHER_HISTORY.md` and `ECOLOGY_BINDING.md` is that, for example, precipitation overlay only populates after the player researches Meteorology; without it, the lens shows "no data". + +This objective wires the gate evaluation against the player's tech state. + +## Acceptance + +- ❌ `mc-observation::gate_bits_for_player(player_state, tech_state) -> GateMask` computes the mask from the player's researched tech list. +- ❌ Tech → field mapping authored in `public/resources/observation/gates.json` (e.g., `{ "meteorology": ["precipitation","temperature","wind"], "geology": ["lithology","mineral_visibility"], "ecology": ["fauna_density","flora_density"] }`). +- ❌ The recording pass in `mc-observation` consults the per-player mask; ungated fields write `None` into the player's observation cache (`p2-54b`), not the actual value. +- ❌ Researching a gating tech mid-game starts populating the corresponding fields from the next observation tick — never retroactively backfills. +- ❌ Cargo test in `mc-observation`: a player without `meteorology` records no `precipitation` values; the same player after researching `meteorology` records non-`None` from that turn forward. +- ❌ `tools/validate-game-data.py` extended to verify every field listed in `gates.json` matches a real `TileMeta` field name (no typos / drifted ids). + +## Source-of-truth rails + +- **Rust crate**: `mc-observation` owns gate evaluation + recording. No GDScript shadow. +- **JSON path**: `public/resources/observation/gates.json` is the single source for tech→field mappings. No `data/` overrides. +- **`mc-core` typed wrapper**: `GateMask` (already typed in `mc-observation`); introduce `ObservationField` enum mirroring `TileMeta` field names if it isn't there yet, so the gates.json parser can fail fast on unknown fields. + +## Out of scope + +- The lens UI consuming the gated cache (covered by `p2-60`). +- Per-deposit visibility three-axis (covered by `p2-54a`). +- AI tech-priority scoring from gated visibility (covered by `p2-54d`). + +## References + +- `public/games/age-of-dwarves/docs/WEATHER_HISTORY.md` +- `public/games/age-of-dwarves/docs/ECOLOGY_BINDING.md` +- `.project/objectives/p2-54b-player-observation-cache.md` +- `.project/objectives/p2-60-weather-lens-godot-ui.md`