13 KiB
| id | title | priority | status | scope | category | owner | created | updated_at | blocked_by | follow_ups | related | evidence | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| p1-60 | Fog-of-war end-to-end test coverage + AI fairness fix | p1 | partial | game1 | simulation | simulator-infra | 2026-05-18 | 2026-05-18 |
|
|
Context
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-visionhas 11 inline tests covering radius math, mountain/dense-forest LoS,last_seencarry-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_visionis the security boundary, but every existing call site usesomniscient=true(tests/legal_actions_round_trip.rs:35); the redaction path itself has no unit tests.api-gdext/src/ai.rs:260callsproject_tactical(state, …)which (mc-player-api/src/projection.rs:917) hands the AI the rawGameState. 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 (seefeedback_balance_philosophy.md).SaveFile(mc-save/src/format.rs:28) does not persistVisionState; fog memory is silently rebuilt at load time from current unit positions, which destroys "stale" memory.last_seensnapshots 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. GDScriptworld_map_vision.gdis 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 throughproject_tactical(917) closes the AI omniscience hole.CP_OMNISCIENTenv 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 updatelast_seenuntil unit returns. (Initially failed; Communications Phase 1 author landedvisible_snapshotsmechanism 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_seensnapshot, not live grid values. - ✓
omniscient=truestill sees both players (env-flag path preserved). - ✓ Resources on unseen tiles omitted (resources on stale tiles surface via the producer's
LastSeen::Fresh.improvement_setpayload — tech-gate verification deferred to a Phase 2 follow-up sincemc-observationtech 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),
GdVisionoutput andWorldMapVisionScript.recalculate_visionproduce identical per-tile(player, visibility)maps. (Test file landed; requires./run gut tests/unit/test_vision_parity.gdon 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 same1 + 3·R·(R+1)formula and constants.)
D. AI fairness — code change + tests:
- ✓ New
project_tactical_with_vision(state, player, Option<&PlayerVision>)threads a vision argument throughproject_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 viamc_vision::compute_visionand passes the active player'sPlayerVisionto the new variant. - ✓
api-gdext/src/ai.rs:260(decide_strategic_kind) does the same for the GDExtension entry point. - ✓
mc-aiaudit: cross-player reads happen viastate.playerson the projectedTacticalState, so filtering at the projection boundary is sufficient — no further changes needed insidemc-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.vision_state: Option<serde_json::Value>with#[serde(default)]. Opaque JSON payload — keepsmc-savedecoupled frommc-vision's dependency graph (mc-turn, mc-replay). Producers serialise viaserde_json::to_value, consumers viafrom_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-gdextsave bridge), not inmc-saveitself. 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 — 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
PlayerVisiondrives the realfog_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 liveupdate_tile_fogtransitions. Requires./run gut tests/integration/test_fog_renderer_consumes_vision.gdon RUN host to execute.
H. Wrap-mode vision:
- ✓
WrapModeenum (None / Horizontal) added toGridState(mc-core/src/grid/mod.rs).#[serde(default)]keeps old saves byte-equal. Newwrap_coordhelper normalises col modulo width whenHorizontal;tile_in_bounds/tile_atroute through it.accumulate_visible_fromstores 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:
- ✓
VisionCataloggainedpeak_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 withelevation >= threshold, vision contribution usesbase_radius + bonusAND the newhas_line_of_sight_with_pierceignores up topierceintermediate 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:
- ✓
GameState.alliances: BTreeSet<(u8, u8)>added with#[serde(default)](canonical(min, max)keying mirrorsPlayerState.relations). Newapply_allied_visionstep incompute_visionunionsvisibleandexploredbetween every allied pair after per-player refresh.last_seenis 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.
Non-goals
- Spell-revealed / scrying tile gates — Game 3 (magic schools).
- Per-tile weather or time-of-day vision modulators — future.
Sequencing
A–G land first (core fog correctness + AI fairness, single PR set). H, I, J each get their own PR set; can land in any order once A–G is in.
Verification
cd src/simulator && cargo test -p mc-vision -p mc-player-api -p mc-save -p mc-ai
cd src/simulator && cargo bench -p mc-vision # spot-check, not CI-gated
./run verify
./run gut tests/unit/test_fog_of_war.gd
./run gut tests/unit/test_fog_of_war_vision.gd
./run gut tests/unit/test_vision_parity.gd
./run gut tests/integration/test_fog_renderer_consumes_vision.gd
Plan companion: /var/home/lilith/.claude/plans/update-plan-moonlit-bentley.md.