magicciv/.project/objectives/p1-60-fog-of-war-testing-ai-fairness.md
2026-05-26 02:21:13 -07:00

7.2 KiB
Raw Blame History

id title priority status scope category owner created updated_at blocked_by follow_ups related
p1-60 Fog-of-war end-to-end test coverage + AI fairness fix p1 open game1 simulation simulator-infra 2026-05-18 2026-05-18
p0-13
p2-67
p2-70

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-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.
  • 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.

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.

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.

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.

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.

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.

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.

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.

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.

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

AG 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 AG 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.