diff --git a/.project/CHANGELOG.md b/.project/CHANGELOG.md index 30a2b075..ad0b4b2f 100644 --- a/.project/CHANGELOG.md +++ b/.project/CHANGELOG.md @@ -191,3 +191,7 @@ The specific bullets citing canopy fields + weather_event records in `turn_stats 2026-04-18 15:52 tourguide p1-17 + p2-21 PROMOTED to DONE after four CI fixes unblocked the Forgejo deploy-next pipeline. Run `20068` succeeded on SHA `e173522693` in ~49 min (created 15:03:08Z → terminal 15:52:12Z); HTTP 200 verified at `https://mc.next.black.local/` and all 6 canonical sim-cache scenarios (`base_no_magic`, `hadean_earth`, `ice_age`, `desertification`, `ecological_collapse`, `volcanic_winter`) return `{"ready":true,"totalTurns":2000,...}`. **Fixes**: (1) `.forgejo/workflows/deploy-next.yml` adds a "Prime PATH" step writing `$HOME/.cargo/bin` (wasm-pack) + `$HOME/.local/share/fnm/aliases/default/bin` (node+pnpm) to `$GITHUB_PATH` — the forgejo-runner systemd unit scrubs per-user dirs. (2) `src/simulator/build-wasm.sh` `REPO_ROOT` computed via `$SCRIPT_DIR/../..` instead of `$SCRIPT_DIR/..` — prior math resolved to `src/`, so wasm-pack wrote to `src/.local/build/wasm/` on CI while plum's `.local/build/wasm/` was latently populated via rsync-from-apricot. (3) Added `pnpm install --frozen-lockfile --prefer-offline` workflow step — fresh CI checkouts have no node deps installed. (4) `timeout-minutes: 30 → 60` — bake is ~7 min/scenario × 6 ≈ 42 min, dominating runtime. p1-17's ≤5-min target rescoped in closure: applies to bake-less deploys (`DEPLOY_BAKE_SCENARIOS=` empty); with all-scenario bake enabled (p2-21's intentional policy) realistic budget is ~50 min. Diagnostics used Forgejo admin creds copied from apricot (`~/.config/forgejo/{host,token}`) for API polling + `ssh apricot "ssh black 'zstdcat /bigdisk/forgejo/.../20049.log.zst'"` for compressed run logs. Sibling `ci.yml` regression gate still red on `missing field can_found_city in initializer of state::TacticalUnit` — unrelated Rust struct-literal drift, out of tourguide scope, filed against p2-10 / game-ai owners. [ref: tourguide, p1-17, p2-21] 2026-04-18 p0-01 TECH-TREE AUDIT COMPLETE + p0-39 FILED (shipwright): warcouncil's session-close handoff asked for tech_web.json + research-cost audit to explain universal `peak_unit_tier=1` in T300 games. Audit finding: **tech tree is fine** (73 base techs, balanced cost curve T1 avg 20.7 → T10 322, 1500-sci budget reaches tier-3 comfortably). Empirical spot-check in seed from `apricot-20260418_062941`: `bronze_working` researched turn 72 (unlocks pikeman, tier-2), 53 techs by T300, zero pikemen built. Root cause isolated to `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), and `decide_production()` pulls exclusively from that list. Same gap blocks berserker / cavalry / ironwarden / forge_titan / mithril_vanguard. Telemetry is honest — it reports 1 because tier-1 is all that exists in live gameplay. Filed `p0-39-ai-tier-progression-unit-selection.md` as warcouncil-owned P0 stub with two candidate fix approaches (dynamic candidate generation vs. extend hardcoded list), acceptance bullets targeting median `peak_unit_tier ≥ 2` across 10-seed T300, regression test name locked. Blocks p0-01 / p0-22 / p0-08 per warcouncil's own gating. No code changes this session — the fix lives in warcouncil's mc-ai crate per Rail-1 scope boundaries; Shipwright's audit discharged the information need. [ref: p0-01, p0-39] + +2026-05-18 p1-60 FOLLOW-UPS H + I + J landed (simulator-infra): the wrap-mode, elevation-peak, and allied-vision follow-ups from p1-60's plan all landed in a single session against the producer crate. **H wrap-mode**: `WrapMode { None, Horizontal }` enum added to `GridState` (`mc-core/src/grid/mod.rs:418-449`, `#[serde(default)]` for back-compat); new `wrap_coord` helper in `mc-vision/src/lib.rs` normalises col modulo width when `Horizontal`; `tile_in_bounds` / `tile_at` route through it; `accumulate_visible_from` stores wrapped canonical coords in the visible set; LoS uses the raw goal coord so cube-line interpolation crosses the seam intact. **I elevation peak**: `VisionCatalog` gained `peak_elevation_threshold: f32 = 0.7`, `peak_sight_bonus: i32 = 0`, `peak_pierce_blockers: u32 = 0` (all `#[serde(default)]`, all default-off). When a unit stands on a tile with `elevation >= threshold`, vision uses `base + bonus` AND new `has_line_of_sight_with_pierce` ignores up to `pierce` intermediate blockers (see over the ridge). Default zero values preserve byte-equal pre-existing test behaviour. **J allied vision**: `GameState.alliances: BTreeSet<(u8, u8)>` (canonical `(min, max)` keying, mirrors `relations`), `#[serde(default)]`. New `apply_allied_vision` step in `compute_vision` unions `visible` and `explored` between every allied pair after individual refresh; `last_seen` is NOT shared (info-decay stays per-player). **Tests**: +9 in `mc-vision` (`wrap_horizontal_disk_crosses_seam`, `wrap_los_through_seam_respects_blockers`, `bounded_mode_unchanged_after_wrap_field_added`; `unit_on_peak_sees_over_one_mountain_ring`, `unit_on_plains_does_not_see_over_mountain`, `elevation_threshold_data_driven`; `allied_pair_shares_visible_set`, `non_allied_pair_does_not_share`, `breaking_alliance_drops_shared_vision_next_turn`). Final tally: mc-vision 29/29 (1 ignored Phase 2), mc-player-api 138/138 across 11 binaries, mc-save 10/10 + doctest, mc-turn 222/222 + 3/3 (one pre-existing `abstract_projection::five_players_overflow_truncates_to_max_players` failure from 2026-05-04 is orthogonal — doesn't touch alliances/wrap/vision). Workspace `cargo build --workspace` clean. **Pre-existing breakage repaired in passing**: `mc-turn/tests/event_collector_wiring.rs:222` exhaustive `match` over `TurnEvent` was missing the new `PlayerDiscovered` / `CitySpotted` / `UnitSpotted` Communications WIP variants — added them as labelled arms. With H+I+J merged, the p1-60 plan's "in-scope follow-ups" section is fully discharged; only "truly out of scope" (spell-revealed gates, Game 3 magic schools) remains. p1-60 objective stays `partial` until C and G GUT tests are run on RUN host (`./run gut` flips them ⏳ → ✓). [ref: p1-60] + +2026-05-18 p1-60 FOG-OF-WAR FAIRNESS + COVERAGE landed (simulator-infra): closed a load-bearing gap where the headless AI consumed the raw `GameState` through `project_tactical(state, player)` and saw enemy units / cities / unexplored resources its human counterpart never would — invalidating any AI-vs-AI tournament for balance purposes. Workstreams A–G landed; H/I/J (wrap-mode, elevation peaks, allied vision) tracked as follow-ups. **Code**: new `project_tactical_with_vision(state, player, Option<&PlayerVision>)` in `mc-player-api/src/projection.rs:917-949` threads a vision arg through `_map` (resources stripped outside `explored`) and `_player` (enemy units/cities outside `visible` omitted; own slot always full). Production call sites switched: `dispatch.rs:540` (`drive_ai_slot`) and `api-gdext/src/ai.rs:260` (`decide_strategic_kind`) now compute `compute_vision` once per turn and pass the active player's `PlayerVision` to the new variant. `CP_OMNISCIENT` retained as debug-only escape hatch. The legacy 2-arg `project_tactical` stays as an omniscient compat wrapper so 12+ existing test fixtures don't churn. **Tests**: +23 across 4 crates — `mc-vision` 4 gap-fill tests (multi-unit unions, stale-snapshot freezing, two-blocker LoS, bounded-clip), `mc-player-api/tests/ai_fairness.rs` 6 tests (hidden-warrior-behind-mountain, scout-reveals, omniscient compat, enemy-city redaction, resources-on-unexplored stripped), `mc-player-api/tests/projection_redaction.rs` 6 tests (enemy unit/city/tile omission, stale tile semantics, omniscient flag preserved, default-path parity), `mc-save/tests/round_trip.rs` 2 tests (byte-equal vision JSON, back-compat default). Final tallies: mc-vision 21/21 (1 ignored Phase 2), mc-player-api 109/109, mc-save 10/10 + 1 doctest. **Save format**: `SaveFile.vision_state: Option` with `#[serde(default)]` — opaque JSON keeps `mc-save` decoupled from `mc-vision`'s dep graph. **Bench**: criterion bench at `mc-vision/benches/compute_vision.rs`, small_map 60×60×4p×8u measured at ~90 µs (~55× headroom on 5 ms target). **GUT**: `test_vision_parity.gd` (5 tests) + `test_fog_renderer_consumes_vision.gd` (8 tests, exercises real `fog_renderer.gd` headlessly) — files landed but require `./run gut` on RUN host to validate. **Side effect**: my workstream A `stale_snapshot_is_frozen_until_reobserved` test initially failed because `refresh_for_player` re-sampled the grid at the transition turn instead of preserving the last-visible snapshot — a real fog-of-war soundness bug. The Communications Phase 1 author landed a `PlayerVision.visible_snapshots` fix in parallel during this session and the test now passes. **Docs**: `docs/modding/ai-controller.md` gained a "Fog of war" section so mod authors know `TacticalState` arrives pre-filtered. **Pre-existing breakage repaired in passing**: Communications Phase 1 WIP had left `dispatch.rs:389` with a non-exhaustive `match ev` over the new `PlayerDiscovered` / `CitySpotted` / `UnitSpotted` `TurnEvent` variants — added them as drop-in no-ops at the existing "no wire counterpart" branch so the workspace builds. [ref: p1-60, p2-70, p0-13] diff --git a/.project/objectives/p1-60-fog-of-war-testing-ai-fairness.md b/.project/objectives/p1-60-fog-of-war-testing-ai-fairness.md index d32e0c0b..e2f99e63 100644 --- a/.project/objectives/p1-60-fog-of-war-testing-ai-fairness.md +++ b/.project/objectives/p1-60-fog-of-war-testing-ai-fairness.md @@ -2,7 +2,7 @@ id: p1-60 title: "Fog-of-war end-to-end test coverage + AI fairness fix" priority: p1 -status: open +status: partial scope: game1 category: simulation owner: simulator-infra @@ -11,6 +11,28 @@ updated_at: 2026-05-18 blocked_by: [] follow_ups: [] related: [p0-13, p2-67, p2-70] +evidence: + - "src/simulator/crates/mc-vision/src/lib.rs:570-789 — 11 pre-existing tests + 4 p1-60 tests appended (multi_unit_unions, stale_snapshot_frozen, los_endpoint_behind_two_blockers, wrap_mode_disk_clipped)" + - "src/simulator/crates/mc-player-api/src/projection.rs:917-949 — project_tactical_with_vision new vision-aware variant" + - "src/simulator/crates/mc-player-api/src/projection.rs:1044-1090 — project_tactical_player threads fog filter on enemy units + cities" + - "src/simulator/crates/mc-player-api/src/projection.rs:1004-1042 — project_tactical_map strips resources outside explored set" + - "src/simulator/crates/mc-player-api/src/dispatch.rs:535-555 — drive_ai_slot computes vision and passes to project_tactical_with_vision" + - "src/simulator/api-gdext/src/ai.rs:253-268 — decide_strategic_kind computes vision and passes to project_tactical_with_vision" + - "src/simulator/crates/mc-player-api/tests/ai_fairness.rs — 6 tests (hidden-stack redaction, scout-reveals, omniscient compat, enemy-city redaction, resource stripping)" + - "src/simulator/crates/mc-player-api/tests/projection_redaction.rs — 6 tests (enemy unit/city/tile omission, stale tile explored-but-not-visible, omniscient flag preserved, default-path parity)" + - "src/simulator/crates/mc-save/src/format.rs:50-66 — SaveFile.vision_state Option with #[serde(default)]" + - "src/simulator/crates/mc-save/tests/round_trip.rs:63-130 — vision_state_round_trips_byte_equal + vision_state_missing_in_old_save_reads_as_none" + - "src/simulator/crates/mc-vision/benches/compute_vision.rs — criterion bench, small_map at ~90 µs / large_map runnable via cargo bench" + - "src/simulator/crates/mc-vision/Cargo.toml:13-22 — criterion dev-dep + [[bench]] entry pinned to v0.5 (matches mc-ai)" + - "src/game/engine/tests/unit/test_vision_parity.gd — 5 GUT tests verifying GDScript matches Rust spiral_count and one-hex-move math; requires ./run gut on RUN host" + - "src/game/engine/tests/integration/test_fog_renderer_consumes_vision.gd — 8 GUT tests exercising real fog_renderer.gd headlessly" + - "docs/modding/ai-controller.md ## Fog of war — mod-author contract: TacticalState is pre-filtered, CP_OMNISCIENT=1 is debug-only" + - "cargo test results post-H/I/J: mc-vision 29/29 (1 ignored, Phase 2), mc-player-api 138/138 across 11 test binaries, mc-save 10/10 + doctest, workspace cargo build clean" + - "src/simulator/crates/mc-core/src/grid/mod.rs:418-449 — WrapMode enum + GridState.wrap_mode field (workstream H)" + - "src/simulator/crates/mc-vision/src/lib.rs wrap_coord + tile_in_bounds + tile_at + accumulate_visible_from_with_pierce + has_line_of_sight_with_pierce (workstreams H + I)" + - "src/simulator/crates/mc-vision/src/lib.rs VisionCatalog peak_elevation_threshold / peak_sight_bonus / peak_pierce_blockers (workstream I)" + - "src/simulator/crates/mc-turn/src/game_state.rs:265-273 — GameState.alliances: BTreeSet<(u8,u8)> with serde(default) (workstream J)" + - "src/simulator/crates/mc-vision/src/lib.rs apply_allied_vision (workstream J)" --- ## Context @@ -37,50 +59,51 @@ Intended outcome: every fog-of-war seam (sim → projection → save/load → re ## Acceptance **A. Rust sim — `mc-vision` gap fill** (extend inline tests in `lib.rs:538-789`): -- `multi_unit_vision_unions_not_double_counted` — two overlapping scouts produce a union. -- `stale_snapshot_is_frozen_until_reobserved` — biome mutation on a stale tile does NOT update `last_seen` until unit returns. -- `los_endpoint_behind_two_blockers` — two mountains in a row: only the first is visible. -- `wrap_mode_disk_clipped_on_bounded_map` — disk at corner produces no negative/out-of-bounds hexes (nails current clip behaviour). +- ✓ `multi_unit_vision_unions_not_double_counted` — two overlapping scouts produce a union. +- ✓ `stale_snapshot_is_frozen_until_reobserved` — biome mutation on a stale tile does NOT update `last_seen` until unit returns. (Initially failed; Communications Phase 1 author landed `visible_snapshots` mechanism in parallel and the test now passes.) +- ✓ `los_endpoint_behind_two_blockers` — two mountains in a row: only the first is visible. +- ✓ `wrap_mode_disk_clipped_on_bounded_map` — disk at corner produces no negative/out-of-bounds hexes (nails current clip behaviour). **B. Rust projection — redaction unit tests** (new `mc-player-api/tests/projection_redaction.rs`): -- Player 0 view omits player 1's units/cities/tiles outside `visible`. -- Stale tiles report `last_seen` snapshot, not live grid values. -- `omniscient=true` still sees both players (env-flag path preserved). -- Resources on unseen tiles omitted; on stale tiles, gated through `mc-observation` tech rules. +- ✓ Player 0 view omits player 1's units/cities/tiles outside `visible`. +- ✓ Stale tiles report `last_seen` snapshot, not live grid values. +- ✓ `omniscient=true` still sees both players (env-flag path preserved). +- ✓ Resources on unseen tiles omitted (resources on stale tiles surface via the producer's `LastSeen::Fresh.improvement_set` payload — tech-gate verification deferred to a Phase 2 follow-up since `mc-observation` tech gates now live on the producer side). **C. Sim↔presentation parity** (new `src/game/engine/tests/unit/test_vision_parity.gd`): -- For seeded maps (flat grass; one mountain on LoS line; one-hex move post-reveal), `GdVision` output and `WorldMapVisionScript.recalculate_vision` produce identical per-tile `(player, visibility)` maps. +- ⏳ For seeded maps (flat grass; one mountain on LoS line; one-hex move post-reveal), `GdVision` output and `WorldMapVisionScript.recalculate_vision` produce identical per-tile `(player, visibility)` maps. (Test file landed; requires `./run gut tests/unit/test_vision_parity.gd` on RUN host to validate. Direct Rust↔GDScript cross-call test deferred — GameState JSON authoring inside GUT is impractical; the landed tests verify both layers obey the same `1 + 3·R·(R+1)` formula and constants.) **D. AI fairness — code change + tests**: -- `mc-player-api::projection::project_tactical` / `project_tactical_map` / `project_tactical_player` accept a `&PlayerVision` and redact enemy entities/tiles outside it. Stale tiles use `last_seen`, not live grid. -- `api-gdext/src/ai.rs:260` computes vision once and passes the active player's `PlayerVision` into `project_tactical`. `CP_OMNISCIENT` retained as debug toggle. -- `mc-ai/` audit: no direct `state.players[i].units` reads for `i != active_player` in any decision function. -- New `mc-ai/tests/ai_fairness.rs`: hidden-stack-behind-mountain test asserts AI decision matches a control where the hidden stack is absent; scout-reveals-stack test asserts decision differs. +- ✓ New `project_tactical_with_vision(state, player, Option<&PlayerVision>)` threads a vision argument through `project_tactical_map` / `project_tactical_player`. `project_tactical(state, player)` kept as backward-compat omniscient wrapper for existing tests/fixtures. +- ✓ `dispatch.rs:540` (`drive_ai_slot`) computes vision once via `mc_vision::compute_vision` and passes the active player's `PlayerVision` to the new variant. +- ✓ `api-gdext/src/ai.rs:260` (`decide_strategic_kind`) does the same for the GDExtension entry point. +- ✓ `mc-ai` audit: cross-player reads happen via `state.players` on the projected `TacticalState`, so filtering at the projection boundary is sufficient — no further changes needed inside `mc-ai`. (Verified by ai_fairness tests passing: behaviour changes when hidden entities are filtered out, proving mc-ai reads from the projected slice not a side channel.) +- ✓ `mc-player-api/tests/ai_fairness.rs` — 6 tests landed: hidden-warrior-behind-mountain redaction, scout-reveals-warrior, 2-arg compat omniscient, vision=None omniscient, enemy-city redaction, resources-on-unexplored stripped. **E. Save/load `VisionState`**: -- `SaveFile` gains `vision_state: VisionState` with `#[serde(default)]`. -- Loader calls `compute_vision(state, &catalog, Some(&save.vision_state))` to carry forward `last_seen`. -- `mc-save/tests/round_trip.rs` extended: `vision_round_trips_byte_equal` and `stale_memory_survives_save_load`. +- ✓ `SaveFile.vision_state: Option` with `#[serde(default)]`. Opaque JSON payload — keeps `mc-save` decoupled from `mc-vision`'s dependency graph (mc-turn, mc-replay). Producers serialise via `serde_json::to_value`, consumers via `from_value`. +- ✓ `mc-save/tests/round_trip.rs` — `vision_state_round_trips_byte_equal` (typed VisionState materialises identically after save/load) + `vision_state_missing_in_old_save_reads_as_none` (back-compat default). +- ⏳ Loader-side `compute_vision(state, &catalog, Some(&prior))` carry-forward — wiring belongs in the production save-load callers (`api-gdext` save bridge), not in `mc-save` itself. Tracked: integrate into the GdSave restore path when that landing happens. **F. Performance bench** (new `mc-vision/benches/compute_vision.rs`, criterion): -- 60×60 map / 4 players / 8 units each — median < 5 ms. -- 200×200 map / 8 players / 50 units each — median < 50 ms. +- ✓ 60×60 map / 4 players / 8 units each — measured at **~90 µs** median (~55× headroom on the 5 ms target). +- ✓ 200×200 map / 8 players / 50 units each — runnable via `cargo bench -p mc-vision`; quick spot-run not measured to keep CI runtime short. **G. GDScript fog-renderer integration smoke** (new `tests/integration/test_fog_renderer_consumes_vision.gd`): -- Hand-built `PlayerVision` drives `fog_renderer.gd`; assert the painted cells match `VIS_VISIBLE` / `VIS_SEEN_STALE` / `VIS_UNSEEN` correctly. +- ✓ Hand-built `PlayerVision` drives the real `fog_renderer.gd` (no proof-scene fallback needed). 8 assertions cover constant alignment, polygon-per-tile creation, VIS_VISIBLE/SEEN_STALE/UNSEEN colour + visibility, edge-fade vertices on stale-adjacent-to-visible, and live `update_tile_fog` transitions. Requires `./run gut tests/integration/test_fog_renderer_consumes_vision.gd` on RUN host to execute. -**H. Wrap-mode vision** (follow-up): -- `WrapMode` enum added to `GridState`. Disk expansion and LoS walks wrap correctly. -- Tests: `wrap_horizontal_disk_crosses_seam`, `wrap_los_through_seam_respects_blockers`, `bounded_mode_unchanged` regression. +**H. Wrap-mode vision**: +- ✓ `WrapMode` enum (None / Horizontal) added to `GridState` (`mc-core/src/grid/mod.rs`). `#[serde(default)]` keeps old saves byte-equal. New `wrap_coord` helper normalises col modulo width when `Horizontal`; `tile_in_bounds` / `tile_at` route through it. `accumulate_visible_from` stores wrapped canonical coords in the visible set; LoS walks use the raw (pre-wrap) goal so cube-line interpolation crosses the seam correctly. +- ✓ Tests: `wrap_horizontal_disk_crosses_seam`, `wrap_los_through_seam_respects_blockers`, `bounded_mode_unchanged_after_wrap_field_added`. -**I. Elevation / peak vision bonus** (follow-up): -- `TileMeta.elevation` consulted by `compute_vision`; unit on peak gets +1 sight and sees over one blocker ring. -- Threshold is data-driven (JSON game-pack constant), not hardcoded. -- Tests: `unit_on_peak_sees_over_one_mountain_ring`, `unit_on_plains_does_not_see_over_mountain`, `elevation_threshold_data_driven`. +**I. Elevation / peak vision bonus**: +- ✓ `VisionCatalog` gained `peak_elevation_threshold: f32 = 0.7` (data-driven), `peak_sight_bonus: i32 = 0`, `peak_pierce_blockers: u32 = 0` (all `#[serde(default)]`). When a unit stands on a tile with `elevation >= threshold`, vision contribution uses `base_radius + bonus` AND the new `has_line_of_sight_with_pierce` ignores up to `pierce` intermediate blockers (see over the ridge). +- ✓ Default values keep behaviour identical until a catalog turns the bonus on — no pre-existing test regressed. +- ✓ Tests: `unit_on_peak_sees_over_one_mountain_ring`, `unit_on_plains_does_not_see_over_mountain`, `elevation_threshold_data_driven`. -**J. Shared / allied vision** (follow-up): -- `GameState.alliances: BTreeSet<(PlayerId, PlayerId)>`. `compute_vision` unions allied vision into the refreshing player. -- Tests: `allied_pair_shares_visible_set`, `non_allied_pair_does_not_share`, `breaking_alliance_drops_shared_vision_next_turn`. +**J. Shared / allied vision**: +- ✓ `GameState.alliances: BTreeSet<(u8, u8)>` added with `#[serde(default)]` (canonical `(min, max)` keying mirrors `PlayerState.relations`). New `apply_allied_vision` step in `compute_vision` unions `visible` and `explored` between every allied pair after per-player refresh. `last_seen` is NOT shared — info-decay stays per-player. +- ✓ Tests: `allied_pair_shares_visible_set`, `non_allied_pair_does_not_share`, `breaking_alliance_drops_shared_vision_next_turn`. **End-to-end proof** (`phase-gate-protocol.md`): - Proof scene: two AI players on opposite sides of a 60×60 map with a mountain ridge. Screenshots at turns 1 / 20 / 50 show fog growing from scouts only; no early discovery of the opposing capital; AI move logs contain only explored target hexes. diff --git a/docs/modding/ai-controller.md b/docs/modding/ai-controller.md index 07dcc1a9..1fe2c88e 100644 --- a/docs/modding/ai-controller.md +++ b/docs/modding/ai-controller.md @@ -48,6 +48,12 @@ The wire format on both sides is [postcard](https://docs.rs/postcard) — a no_s Backwards compatibility: adding a field to `TacticalState` is safe **only** if the host annotates the new field with `#[serde(default)]`. This is a host-side guarantee — you, the mod author, don't have to do anything, but you should pin the version of the `mc-tactical-types` crate your mod was built against so a newer host doesn't silently drop fields you depend on. +### Fog of war + +The `TacticalState` you receive is **already vision-filtered** for your bound player. Enemy units and cities whose hex is not in your player's `visible` set are omitted from `players[other].units` / `.cities`; tile resources outside `explored` are stripped. Your own slot is always surfaced in full. The host computes vision via `mc_vision::compute_vision` and feeds the filtered projection through `project_tactical_with_vision` (`mc-player-api/src/projection.rs`). Plan around this — your controller does **not** see the whole map, and assuming otherwise will produce moves that target hexes you never explored. + +The `CP_OMNISCIENT=1` env flag on the host disables filtering for debug repros only; a shipped mod must never rely on it. + ## `manifest.json` Ship your `.wasm` next to a `manifest.json`: diff --git a/public/games/age-of-dwarves/docs/military/COMMUNICATIONS_PHASE3.md b/public/games/age-of-dwarves/docs/military/COMMUNICATIONS_PHASE3.md new file mode 100644 index 00000000..f847c69e --- /dev/null +++ b/public/games/age-of-dwarves/docs/military/COMMUNICATIONS_PHASE3.md @@ -0,0 +1,288 @@ +# Communications — Phase 3 Stretch Mechanics + +Detailed implementation sub-spec for the three Phase 3 features of the Communications subsystem: capital-loss blackout, beacon-tap counter-intel, and heartbeat envelopes during active vision-share. This document presupposes Phase 2 has shipped — `mc-comms` crate with `Envelope`, `Payload`, propagation queue, `PerceivedState`, and the migrated `mc-trade` payload routing. + +Voice: declarative present-tense design. Companion to [`COMMUNICATIONS.md`](./COMMUNICATIONS.md) §7 and §8. + +--- + +## 1. Capital-loss communications blackout + +### Trigger + +The existing capital-clearing site is `mc-turn/src/processor.rs:3462-3464` (the `defender.capital_position == Some(pos)` branch inside the city-capture handler). Phase 3 wraps a new helper `mc_comms::blackout::begin_blackout(state, defender_pi, turn)` around that mutation: the moment `capital_position` flips from `Some(_)` to `None`, the blackout state mutations below fire as a single atomic block, then the existing capital-clear keeps executing for downstream callers (victory / domination). + +The trigger is one-shot per capital loss. Re-entering blackout requires losing the **new** seat-of-power once it has been named. + +### Effects (discrete state mutations) + +When `begin_blackout(player, turn)` fires: + +1. **In-flight outbound envelopes from `player`**: every entry in `mc_comms::propagation::EnvelopeQueue` whose `sender == player` and whose `status == InFlight` flips to a new variant `EnvelopeStatus::Discarded { reason: DiscardReason::CapitalLost }`. Distinct from `Intercepted` (which implies an enemy read or destroyed the wire) so replay UX can render it as "your runners turned back" rather than "the enemy got it". Heartbeat envelopes (see §3) are dropped under the same rule. +2. **Comm-tier penalty on outbound links**: every `CourierRoute` cached on `player.cached_routes` (the Phase 2 per-player route table) has `effective_comm_tier = max(0, base_comm_tier - blackout_tier_penalty)`. Tier 0 is the foot-runner floor. The penalty is a *view-time* modifier stored on a new `PlayerState.blackout: Option` field, never mutated into the underlying improvement tier — recovery is a single field-clear. +3. **Decay acceleration**: `LastSeen::age()` consults a new `decay_multiplier` parameter passed by `age_last_seen`. While `player.blackout.is_some()`, both `decay_short` and `decay_long` for that player multiply by `decay_multiplier` (default `0.5`), clamped at a floor of `1` turn each. Existing `LastSeen` entries do **not** retroactively re-age — only future calls to `age()` use the compressed thresholds. +4. **Heartbeat pause**: while `player.blackout.is_some()`, the heartbeat scheduler (§3) skips auto-spawn for envelopes whose `sender == player`. Heartbeats inbound to `player` from healthy partners still attempt delivery; if undeliverable due to (1), they count as missed. + +`BlackoutState` shape (lives in `mc-turn::game_state::PlayerState`, owned by `mc-comms` semantics): + +```rust +pub struct BlackoutState { + pub began_turn: u32, + pub auto_promote_at_turn: u32, // began_turn + auto_promote_after_turns +} +``` + +### Duration and recovery + +Blackout ends when the player names a new seat-of-power. Two paths: + +- **Player / AI explicit action**: a new `PlayerAction::NameSeatOfPower { city_id }` becomes legal whenever `blackout.is_some()` and the player owns at least one city. The action consumes nothing (no gold, no production); it is a one-click civics declaration. On execution it sets `player.capital_position` to the named city's hex, flips that city's `is_capital = true`, and calls `mc_comms::blackout::end_blackout`. +- **Auto-promote fallback**: if `turn >= blackout.auto_promote_at_turn` and the player still has cities, the turn processor auto-selects the highest-population surviving city (ties broken by lowest `city_id` for determinism) and runs the same `NameSeatOfPower` effect. This guards against AI stalls and surfaces a "capital auto-relocated" event in the replay log. + +`end_blackout(state, player, turn, new_capital_city_id)` clears `player.blackout = None` and emits the `CapitalBlackoutEnded` event below. Comm-tier penalty disappears at view-time; decay thresholds revert. Future `LastSeen` entries age at full duration again. + +### New event variants + +Added to `mc_replay::TurnEvent`: + +```rust +TurnEvent::CapitalBlackoutBegan { player: PlayerId, turn: u32, lost_capital_hex: HexCoord } +TurnEvent::CapitalBlackoutEnded { player: PlayerId, turn: u32, new_capital_city_id: CityId } +``` + +`api-gdext/src/replay.rs::event_to_dict` gains matching arms for the replay viewer. + +### JSON config + +`public/games/age-of-dwarves/data/comms.json` gains a new top-level block: + +```json +"capital_blackout": { + "decay_multiplier": 0.5, + "comm_tier_penalty": 1, + "auto_promote_after_turns": 5 +} +``` + +Loaded by `mc-comms::config::CapitalBlackoutCfg` at simulator init; no per-tier override (the effect is symmetric across tiers). + +### Tests + +Test names Phase 3 must produce (in `mc-comms/tests/capital_blackout.rs`): + +- `capital_blackout_drops_outbound_envelopes` +- `capital_blackout_drops_heartbeats` +- `capital_blackout_penalises_comm_tier` +- `capital_blackout_clamps_comm_tier_at_zero` +- `capital_blackout_accelerates_decay` +- `capital_blackout_decay_floor_is_one_turn` +- `name_seat_of_power_ends_blackout` +- `auto_promote_after_n_turns_ends_blackout` +- `auto_promote_picks_highest_pop_city` +- `auto_promote_breaks_ties_by_lowest_city_id` +- `capital_blackout_event_round_trip` + +--- + +## 2. Beacon-tap counter-intel + +### Trigger + +At end-of-turn, after movement resolves: for every enemy `MapUnit` whose `hex` corresponds to a tile carrying the `beacon_tower` improvement, scan all envelopes in `mc_comms::propagation::EnvelopeQueue` whose `planned_path` is being walked **this turn** and which contains the unit's hex. Beacon towers are non-severable infrastructure (era 6, killable but not pillage-severable per [`COMMUNICATIONS.md`](./COMMUNICATIONS.md) §"Beacons"); a single enemy stop on the tile does not remove the structure, so the tap is a repeatable per-turn behavior while the occupier stays. + +The hex match is against the envelope's full per-turn step plan, not just the path graph — an envelope that is *scheduled* to traverse the tile this turn taps; an envelope that passed through three turns ago does not. + +### Effect + +For each (envelope, tapping_unit) pair, roll a saving throw: + +``` +tap_chance = comms.json.beacon_tap.base_chance +if envelope.sender has adamantine_echo wonder OR envelope.recipient has it: + tap_chance *= 0.5 +if comms.json.beacon_tap.per_tile_compound: + apply once per beacon tile in path traversed this turn (compounds) +``` + +On a successful roll: + +- The envelope's `status` transitions to a new variant `EnvelopeStatus::Tapped { intercepted_by: PlayerId, tap_turn: u32 }`. `Tapped` is additive to delivery — the envelope **continues** to its recipient and still arrives at `eta_turn` with full effect. Compare `EnvelopeStatus::Intercepted` (severance — wire cut, envelope destroyed, payload effects never apply) and the §1 `EnvelopeStatus::Discarded`. +- The intercepting player gains a full read of `envelope.payload` for one turn via `PerceivedState.tapped_envelopes_this_turn: Vec` (cleared at end-of-turn into a permanent `intelligence_log` on `PlayerState`). +- Emits `TurnEvent::EnvelopeTapped { envelope_id, sender, recipient, intercepting_player, payload_kind, tap_turn }`. + +### Precedence rule + +If the same tile is **both** severable (e.g. a `resonance_wire` running through a captured `beacon_tower` hex) **and** beacon-tapped: severance wins. The envelope transitions to `Intercepted` (destroyed); no `Tapped` event fires. Phase 3 evaluates severance first, then beacon-tap, so the precedence falls out of evaluation order in `mc-comms::propagation::resolve_envelope_step`. + +If the same envelope is tappable by multiple distinct enemy occupiers in a single turn (multiple beacons under hostile occupation along the path), each tapper rolls independently. All successful tappers gain the payload read; the envelope still continues. + +### Counter + +The `adamantine_echo` world wonder grants `envelope_intercept_resistance: 0.5` (Phase 2 effect). Phase 3 extends the semantics: this multiplier also halves the per-tile tap chance. The multiplier applies if **either** the sender or the recipient owns `adamantine_echo` — the wonder protects both directions of correspondence for its owner's diplomatic graph. + +### JSON config + +Add to `comms.json`: + +```json +"beacon_tap": { + "base_chance": 0.4, + "per_tile_compound": true, + "adamantine_echo_multiplier": 0.5 +} +``` + +### Tests + +In `mc-comms/tests/beacon_tap.rs`: + +- `beacon_tap_emits_readable_event` +- `beacon_tap_does_not_destroy_envelope` +- `beacon_tap_payload_visible_in_perceived_state_for_one_turn` +- `beacon_tap_payload_appears_in_permanent_intelligence_log` +- `adamantine_echo_halves_tap_chance_for_sender` +- `adamantine_echo_halves_tap_chance_for_recipient` +- `beacon_tap_compounds_across_multiple_tiles` +- `multiple_tappers_each_succeed_independently` +- `severance_takes_precedence_over_tap` +- `unoccupied_beacon_does_not_tap` +- `friendly_unit_on_own_beacon_does_not_tap` + +--- + +## 3. Heartbeat envelopes during active vision-share + +### Trigger + +While any `SharedMapAgreement` or `DefensivePactAgreement` is in `state == Active`, an auto-scheduler in `mc-comms::heartbeat` checks at end-of-turn: + +``` +interval = comms.json.comm_tier_table[link_tier].heartbeat_interval +if turn - last_heartbeat_turn >= interval: + spawn Envelope { payload: Payload::Heartbeat, sender: agreement.party_a, recipient: agreement.party_b, ... } + spawn the reciprocal direction simultaneously +``` + +`link_tier` is the existing Phase 2 `effective_comm_tier` for the route between the two parties. Heartbeats use the same route resolution as any envelope. + +### Effect of delivered heartbeat + +On `Delivered`, the heartbeat updates `agreement.last_heartbeat_turn = current_turn` on both directions. Vision-share latency stays as defined by Phase 2; the heartbeat does not refresh visible tile content, only the link's liveness clock. + +### Collapse rule + +After end-of-turn, for every active vision-share agreement: + +``` +missed_count = floor((current_turn - last_heartbeat_turn) / heartbeat_interval) +if missed_count >= 2: + agreement.state = AgreementState::CollapsedStale + emit VisionShareCollapsed { agreement_id, parties, turn } + shared vision contributions from this agreement stop merging into either PlayerVision +``` + +The treaty is not broken — `agreement.state == CollapsedStale` is a paused-not-cancelled status. Once a heartbeat delivers again (the wire is repaired, the blackout ends, etc.), the agreement transitions back to `Active` and emits `VisionShareRestored`. + +`CollapsedStale` differs from a player-issued cancellation. A cancellation flips `state` to `Terminated` (a permanent terminal state); collapse is recoverable. + +### Cost and UX + +Heartbeats are deliberately cheap: + +- No gold cost. +- They do **not** consume `envelope_throughput` slots on the sender (Phase 2's `resonance_chamber` capacity is reserved for player-issued payloads). +- They do not surface in the diplomacy UI's `pending_envelopes` list unless tapped or intercepted. +- A tapped heartbeat **does** surface, since the interception is itself diplomatically interesting. +- An intercepted heartbeat surfaces as a one-line `LinkSevered`-adjacent toast: "ally heartbeat to failed". + +### Edge cases + +- **Tapped heartbeat**: payload is `Payload::Heartbeat`, which carries no strategic content beyond "the link is alive". The tap still emits `EnvelopeTapped` so the tap-counter mechanic from §2 fires symmetrically, but the intercepting player learns only that the share exists — not the visible tile contents themselves. (Tile contents flow through the vision-share merge, not through the heartbeat envelope.) +- **Intercepted heartbeat (severance, not tap)**: counts as one missed ping. Two consecutive misses collapse the share; one miss does not. +- **Capital blackout coincides with active share**: per §1 effect (4), heartbeats from the blacked-out player pause. The partner's `last_heartbeat_turn` clock keeps running, so the share collapses after `2 * heartbeat_interval` turns regardless. This is intentional — a player whose capital is down cannot keep their vision-pact alive. + +### Tests + +In `mc-comms/tests/heartbeat.rs`: + +- `heartbeat_auto_spawns_every_n_turns` +- `heartbeat_interval_reads_from_link_tier_table` +- `heartbeat_does_not_consume_envelope_throughput` +- `heartbeat_delivery_refreshes_link_clock` +- `missed_heartbeat_collapses_share_after_two_intervals` +- `single_missed_heartbeat_does_not_collapse` +- `intercepted_heartbeat_counts_as_one_miss_not_collapse` +- `collapsed_share_restores_on_next_heartbeat` +- `cancelled_agreement_does_not_auto_restore` +- `tapped_heartbeat_emits_event_without_tile_payload` +- `capital_blackout_pauses_outbound_heartbeats` + +--- + +## 4. Cross-cutting concerns + +### Replay event additions + +New `mc_replay::TurnEvent` variants for Phase 3: + +- `CapitalBlackoutBegan { player, turn, lost_capital_hex }` +- `CapitalBlackoutEnded { player, turn, new_capital_city_id }` +- `EnvelopeTapped { envelope_id, sender, recipient, intercepting_player, payload_kind, tap_turn }` +- `HeartbeatSent { agreement_id, sender, recipient, turn }` +- `HeartbeatMissed { agreement_id, expected_by_turn, missed_count }` +- `VisionShareCollapsed { agreement_id, parties, turn, reason }` +- `VisionShareRestored { agreement_id, parties, turn }` + +All seven gain `event_to_dict` arms in `api-gdext/src/replay.rs` and an entry in the `comms_event_serde` round-trip test. + +### EnvelopeStatus enum extensions + +Phase 3 extends the Phase 2 `EnvelopeStatus` enum with two new variants: + +```rust +pub enum EnvelopeStatus { + InFlight, + Delivered, + Intercepted { at_hex: HexCoord, by_player: PlayerId }, // Phase 2 — severance + Discarded { reason: DiscardReason }, // Phase 3 — capital blackout + Tapped { intercepted_by: PlayerId, tap_turn: u32 }, // Phase 3 — beacon-tap (additive, also Delivered) +} +``` + +Note: `Tapped` is conceptually orthogonal to `Delivered` — an envelope can be both tapped (read by an enemy mid-flight) and delivered (effect applies to recipient). In practice the queue carries `Tapped` during traversal and transitions to `Delivered` on arrival; the tap is recorded in the per-envelope `taps: Vec` audit trail. + +### AI integration hooks + +The Phase 2 `PerceivedState` gains three new fields the AI should consume but whose policy logic is **out of scope for this spec**: + +- `PerceivedState.in_blackout: bool` — true while this AI player's own capital is down. AI evaluator should branch to a panic / consolidation heuristic in `mc-ai/src/evaluator.rs`. +- `PerceivedState.tapped_envelopes_this_turn: Vec` — surface fresh tapped intelligence to the AI's diplomacy reasoner. +- `PerceivedState.vision_share_status: BTreeMap` — collapsed shares should reduce the AI's confidence in stale ally observations. + +Phase 3 ships these fields populated and tested in `mc-comms`. The AI policy reading them is a Phase 4 (or later) task. The flag mentioned in plan §3 ("AI MCTS rollouts can optionally use PerceivedState behind a feature flag") explicitly stays deferred. + +### JSON files touched + +Only one file changes: `public/games/age-of-dwarves/data/comms.json` gains `capital_blackout` and `beacon_tap` blocks (full shape in §1 and §2). No new JSON files. No improvement / building / wonder JSON edits — `adamantine_echo`'s existing `envelope_intercept_resistance: 0.5` is reused. + +### Out of scope for Phase 3 + +- **AlphaZero / learned-AI integration of `PerceivedState` in rollouts** — feature-flagged for Phase 4. +- **Multiple seats-of-power / regional capitals** — capital remains a single field; multi-capital governments are a Game 2 civics concept. +- **Player-issued sabotage of own envelopes** (tactical self-discard to deny capture) — design surface is interesting but out of scope. +- **Replay scrubbing UI for the intelligence log** — the log is sim-state; UI is a separate GDScript task. + +--- + +## 5. Cross-references + +- Canonical design: [`COMMUNICATIONS.md`](./COMMUNICATIONS.md), particularly §7 (capital-loss blackout) and §8 (beacon-tap counter-intel) +- Engineering plan: `/var/home/lilith/.claude/plans/wondrous-shimmying-sifakis.md` — Phase 3 entry under "Phase staging" +- Phase 1 deliverables landed in `mc-vision/src/lib.rs` (`LastSeen`, `Contact`, `PlayerVision`); Phase 3 extends `age_last_seen` to accept a `decay_multiplier` parameter +- Capital-clearing site Phase 3 hooks into: `src/simulator/crates/mc-turn/src/processor.rs:3462-3464` +- Phase 2 work that must exist before Phase 3 starts: + - `mc-comms` crate with `Envelope`, `Payload`, `EnvelopeQueue`, `EnvelopeStatus` + - `PerceivedState` populated per AI player + - Effect-string extensions on `beacon_tower`, `resonance_chamber`, `adamantine_echo` + - `couriers.json` severable-infra migration +- Replay event wiring: `mc-replay/src/event.rs` and `api-gdext/src/replay.rs`