From c30b74523b4d3bd06b31f6558e182201515910b3 Mon Sep 17 00:00:00 2001 From: Natalie Date: Thu, 25 Jun 2026 16:11:16 -0400 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=F0=9F=8C=AB=EF=B8=8F=20p3-20=20(consumer)=20=E2=80=94=20comput?= =?UTF-8?q?e=5Fvision=20shrinks=20sight=20under=20weather?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vision-consumer half of weather→scouting. compute_vision_with_penalties takes a per-tile vision-penalty map and reduces each unit's effective sight by the penalty at its tile (floored at WEATHER_MIN_VISION=1, so a unit always sees its own tile + immediate ring). compute_vision delegates with an empty map, so all existing callers are unchanged (no churn). refresh_for_player + compute_player_visible_set thread the penalty. Test: weather_vision_penalty_shrinks_unit_sight (radius-2 disk 19 → radius-1 disk 7 under penalty 1; floored under penalty 99); mc-vision 30/0. p3-20 stays partial — bridge/GDScript wiring (weather events → penalty map → compute_vision_with_penalties) + dylib/GUT next. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../p3-20-weather-affects-scouting.md | 2 +- .../games/age-of-dwarves/data/objectives.json | 12 ++-- src/simulator/crates/mc-vision/src/lib.rs | 65 ++++++++++++++++++- 3 files changed, 70 insertions(+), 9 deletions(-) diff --git a/.project/objectives/p3-20-weather-affects-scouting.md b/.project/objectives/p3-20-weather-affects-scouting.md index 313bd398..804f9c07 100644 --- a/.project/objectives/p3-20-weather-affects-scouting.md +++ b/.project/objectives/p3-20-weather-affects-scouting.md @@ -20,7 +20,7 @@ surprise — a missed gameplay lever for the weather system. ## Progress (2026-06-25) -Weather-producer side done: `WeatherEvent.vision_penalty: i32` added (`mc-climate`); `derive_events` sets it per kind (storm 1, blizzard 2, dust_storm 2; heat_wave/drought/flood 0). Tests assert the per-kind values (mc-climate 45/0). **Remaining:** `compute_vision` (mc-vision) consumes a per-tile vision-penalty to shrink a unit's effective sight under weather + the bridge/GDScript wiring (build the penalty map from live weather) + dylib/GUT. *(Per-kind values inline for now; data-drive to WeatherThresholds/climate_spec.json is a follow-up.)* +Weather-producer side done: `WeatherEvent.vision_penalty: i32` added (`mc-climate`); `derive_events` sets it per kind (storm 1, blizzard 2, dust_storm 2; heat_wave/drought/flood 0). Tests assert the per-kind values (mc-climate 45/0). Vision-consumer side DONE: `compute_vision_with_penalties` (mc-vision) shrinks a unit's effective sight by the per-tile penalty (floored at `WEATHER_MIN_VISION`=1); `compute_vision` delegates with an empty map so existing callers are unchanged. Test `weather_vision_penalty_shrinks_unit_sight` (mc-vision 30/0). **Remaining:** the bridge/GDScript wiring — build the per-tile penalty map from live weather events + call `compute_vision_with_penalties` in the playable vision path + dylib/GUT. *(Per-kind values inline for now; data-drive to WeatherThresholds/climate_spec.json is a follow-up.)* ## Acceptance diff --git a/public/games/age-of-dwarves/data/objectives.json b/public/games/age-of-dwarves/data/objectives.json index 61a367d7..29574d24 100644 --- a/public/games/age-of-dwarves/data/objectives.json +++ b/public/games/age-of-dwarves/data/objectives.json @@ -1,12 +1,12 @@ { - "generated_at": "2026-06-25T19:45:10Z", + "generated_at": "2026-06-25T20:11:16Z", "totals": { - "in_progress": 0, - "oos": 31, - "stub": 0, - "missing": 0, - "partial": 4, "done": 293, + "stub": 0, + "in_progress": 0, + "missing": 0, + "oos": 31, + "partial": 4, "total": 328 }, "objectives": [ diff --git a/src/simulator/crates/mc-vision/src/lib.rs b/src/simulator/crates/mc-vision/src/lib.rs index 993385fb..f427bc59 100644 --- a/src/simulator/crates/mc-vision/src/lib.rs +++ b/src/simulator/crates/mc-vision/src/lib.rs @@ -117,6 +117,11 @@ pub type PlayerId = u8; /// and `GridState::idx`. pub type HexCoord = (i32, i32); +/// p3-20 — a unit under a vision-reducing weather event never drops below this +/// effective sight radius, so it always sees its own tile + immediate ring even +/// in the worst blizzard. +pub const WEATHER_MIN_VISION: i32 = 1; + // ── Catalog ───────────────────────────────────────────────────────────────── /// Per-source vision lookup. Populated from JSON game packs by the @@ -429,11 +434,31 @@ pub fn compute_vision( state: &GameState, catalog: &VisionCatalog, prior: Option<&VisionState>, +) -> VisionState { + compute_vision_with_penalties(state, catalog, prior, &BTreeMap::new()) +} + +/// p3-20 — like [`compute_vision`], but `vision_penalties` (sight reduction per +/// tile, e.g. built from live weather events) shrinks the effective sight of any +/// unit standing on a penalised tile (floored at [`WEATHER_MIN_VISION`]). An +/// empty map reproduces [`compute_vision`] exactly. +#[must_use] +pub fn compute_vision_with_penalties( + state: &GameState, + catalog: &VisionCatalog, + prior: Option<&VisionState>, + vision_penalties: &BTreeMap, ) -> VisionState { let mut per_player: BTreeMap = BTreeMap::new(); for player in &state.players { let prior_pv = prior.and_then(|p| p.per_player.get(&player.player_index)); - let pv = refresh_for_player(state, player.player_index, catalog, prior_pv); + let pv = refresh_for_player( + state, + player.player_index, + catalog, + prior_pv, + vision_penalties, + ); per_player.insert(player.player_index, pv); } // p1-60 J — union allied players' visible sets into each member. @@ -547,6 +572,7 @@ pub fn refresh_for_player( player: PlayerId, catalog: &VisionCatalog, prior: Option<&PlayerVision>, + vision_penalties: &BTreeMap, ) -> PlayerVision { let Some(grid) = state.grid.as_ref() else { // No grid → no visibility. Returns empty for every player. @@ -556,7 +582,7 @@ pub fn refresh_for_player( return PlayerVision::default(); }; - let visible = compute_player_visible_set(grid, ps, catalog); + let visible = compute_player_visible_set(grid, ps, catalog, vision_penalties); // Carry-forward last-seen snapshot. // Step 1: start from prior.last_seen (tiles still stale). @@ -652,6 +678,7 @@ fn compute_player_visible_set( grid: &GridState, ps: &PlayerState, catalog: &VisionCatalog, + vision_penalties: &BTreeMap, ) -> BTreeSet { let mut visible: BTreeSet = BTreeSet::new(); @@ -673,6 +700,10 @@ fn compute_player_visible_set( } else { (base_radius, 0) }; + // p3-20 — weather over the observer's tile cuts its sight (storms cut a + // little, blizzards/dust storms cut more). Floored at WEATHER_MIN_VISION. + let radius = (radius - vision_penalties.get(¢re).copied().unwrap_or(0)) + .max(WEATHER_MIN_VISION); accumulate_visible_from_with_pierce(grid, centre, radius, pierce, &mut visible); } @@ -1214,6 +1245,36 @@ mod tests { assert!(pv.last_seen.is_empty(), "first turn has no stale tiles"); } + #[test] + fn weather_vision_penalty_shrinks_unit_sight() { + // p3-20: a vision-penalty at the observer's tile (e.g. a blizzard) cuts + // its effective sight; never below WEATHER_MIN_VISION. + let mut state = GameState::default(); + state.grid = Some(make_flat_grid(11, 11, "grassland")); + let mut ps = PlayerState::default(); + ps.player_index = 0; + ps.units.push(unit_at("scout", 5, 5)); + state.players.push(ps); + let mut cat = VisionCatalog::default(); + cat.insert_unit("scout", 2); + + // No penalty → radius-2 disk = 19 tiles. + assert_eq!(compute_vision(&state, &cat, None).for_player(0).unwrap().visible.len(), 19); + + // Penalty 1 at the unit's tile → effective radius max(1, 2-1)=1 → 7 tiles. + let mut pen: BTreeMap = BTreeMap::new(); + pen.insert((5, 5), 1); + let reduced = compute_vision_with_penalties(&state, &cat, None, &pen); + let pv = reduced.for_player(0).unwrap(); + assert_eq!(pv.visible.len(), 7, "penalty 1 → radius-1 disk = 7 tiles"); + assert!(pv.visible.contains(&(5, 5)), "still sees its own tile"); + + // Overwhelming penalty floors at WEATHER_MIN_VISION (radius 1), never 0. + pen.insert((5, 5), 99); + let floored = compute_vision_with_penalties(&state, &cat, None, &pen); + assert_eq!(floored.for_player(0).unwrap().visible.len(), 7, "floored at radius 1"); + } + #[test] fn unit_id_falls_back_to_default_when_absent_from_catalog() { let mut state = GameState::default();