- "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)"
`p0-13` (tri-state fog) and `p2-70` (Rust `mc-vision` producer) both shipped, but the contract *between* the Rust producer and its consumers — the player-API projection, the GDScript renderer, the save format, and the AI — is not under test. Today:
-`mc-vision` has 11 inline tests covering radius math, mountain/dense-forest LoS, `last_seen` carry-forward, per-player isolation, and serde determinism (`src/simulator/crates/mc-vision/src/lib.rs:570-789`).
- GDScript GUT covers tri-state transitions and ring counts (`tests/unit/test_fog_of_war.gd`, `test_fog_of_war_vision.gd`).
- Nothing asserts the Rust and GDScript visibility sets agree on a seeded map.
-`mc-player-api::projection::project_view_with_vision` is the security boundary, but every existing call site uses `omniscient=true` (`tests/legal_actions_round_trip.rs:35`); the redaction path itself has no unit tests.
- **`api-gdext/src/ai.rs:260` calls `project_tactical(state, …)` which (`mc-player-api/src/projection.rs:917`) hands the AI the raw `GameState`. The AI sees unexplored tiles, hidden enemy units, hidden cities.** This violates the Game-1 design contract and invalidates any AI-vs-AI tournament for balance purposes (see `feedback_balance_philosophy.md`).
-`SaveFile` (`mc-save/src/format.rs:28`) does not persist `VisionState`; fog memory is silently rebuilt at load time from current unit positions, which destroys "stale" memory.
-`last_seen` snapshots are not proven frozen until re-observation. No test for movement-revealed enemies, multi-unit union, wrap, or perf regression.
Intended outcome: every fog-of-war seam (sim → projection → save/load → renderer → AI consumer) has a regression test, and the AI plays the same fog-of-war game the human plays.
## Source-of-truth rails
- Rust `mc-vision::compute_vision` (already public, `src/simulator/crates/mc-vision/src/lib.rs:225`) remains the only producer. GDScript `world_map_vision.gd` is already marked deprecated and must become a pure consumer.
-`mc-player-api::projection::project_view_with_vision` (`projection.rs:104`) is the only public read path for non-omniscient clients. Threading vision through `project_tactical` (917) closes the AI omniscience hole.
-`CP_OMNISCIENT` env flag kept for debug/repro only; default off in AI.
- No new JSON content; sight ranges already in `public/games/age-of-dwarves/data/units/*.json`.
## 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. (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).
- ✓ 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).
- ⏳ 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.)
- ✓ 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.)
- ⏳ 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.
- ✓ 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.
- ✓ `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.
- ✓ `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.
- ✓ `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.
- 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.