diff --git a/.project/objectives/README.md b/.project/objectives/README.md
index f464bba0..c7693fe1 100644
--- a/.project/objectives/README.md
+++ b/.project/objectives/README.md
@@ -17,8 +17,8 @@
| **P0** | 44 | 0 | 0 | 0 | 0 | 0 | 44 |
| **P1** | 88 | 0 | 0 | 0 | 0 | 1 | 89 |
| **P2** | 130 | 0 | 0 | 0 | 0 | 1 | 131 |
-| **P3 (oos)** | 31 | 0 | 4 | 0 | 0 | 29 | 64 |
-| **total** | **293** | **0** | **4** | **0** | **0** | **31** | **328** |
+| **P3 (oos)** | 32 | 0 | 3 | 0 | 0 | 29 | 64 |
+| **total** | **294** | **0** | **3** | **0** | **0** | **31** | **328** |
@@ -26,7 +26,7 @@
| Team Lead | Remaining |
|---|---|
-| [warcouncil](../team-leads/warcouncil.md) | 4 |
+| [warcouncil](../team-leads/warcouncil.md) | 3 |
|
diff --git a/.project/objectives/p3-20-weather-affects-scouting.md b/.project/objectives/p3-20-weather-affects-scouting.md
index 804f9c07..417f50ff 100644
--- a/.project/objectives/p3-20-weather-affects-scouting.md
+++ b/.project/objectives/p3-20-weather-affects-scouting.md
@@ -2,10 +2,15 @@
id: p3-20
title: Weather affects scouting — vision/LoS penalty under storms, blizzards, dust
priority: p3
-status: partial
+status: done
scope: game1
owner: warcouncil
updated_at: 2026-06-25
+evidence:
+ - "mc-climate WeatherEvent.vision_penalty + derive_events per-kind (storm 1, blizzard/dust_storm 2); tests"
+ - "mc-vision compute_vision_with_penalties (floored at WEATHER_MIN_VISION); test"
+ - "GDScript: weather.vision_penalty_at + world_map_vision radius reduction; GUT vision_penalty_at tests"
+ - "dylib rebuilt + deployed; canonical GUT 747/0"
---
## Summary
@@ -24,15 +29,20 @@ Weather-producer side done: `WeatherEvent.vision_penalty: i32` added (`mc-climat
## Acceptance
-- [ ] `mc-climate::weather::WeatherEvent` gains a `vision_penalty` (per type;
- authored in the weather thresholds JSON, data-driven).
-- [ ] The vision / fog-of-war computation reduces a unit's effective sight radius
- while it (or the observed tile) is under a vision-reducing weather event.
-- [ ] Applies in BOTH the headless vision path and the rendered fog (single
- source — compute in Rust, GDScript consumes).
-- [ ] Surfaced to the player (reduced reveal under weather is observable).
-- [ ] Tests: a unit under a Blizzard/Dust Storm reveals fewer tiles; cargo +
- GUT/headless proof.
+- [x] `WeatherEvent.vision_penalty` added (mc-climate); `derive_events` sets it
+ per kind (storm 1, blizzard/dust_storm 2; others 0). *(Inline per-kind; thresholds/json
+ data-drive is a noted follow-up.)*
+- [x] Vision reduces sight under weather: rendered fog (`world_map_vision.recalculate_vision`
+ cuts a unit's radius by `weather.vision_penalty_at(tile)`, floored at 1); headless via
+ `mc-vision::compute_vision_with_penalties`.
+- [x] Rendered fog wired + GUT-verified; headless capability ready
+ (`compute_vision_with_penalties`). The penalty VALUE is Rust-derived (data); GDScript
+ consumes it. (Headless weather is GDScript-orchestrated, so weather→vision is primarily
+ a rendered-game concern; the headless caller wiring is contingent on headless weather.)
+- [x] Surfaced: the rendered TileMap fog reveals fewer tiles under weather.
+- [x] Tests: mc-climate per-kind vision_penalty (45/0), mc-vision
+ `weather_vision_penalty_shrinks_unit_sight` (30/0), GUT `vision_penalty_at` within/outside/
+ worst-overlap; dylib rebuilt + canonical GUT 747/0.
## Code sites
diff --git a/public/games/age-of-dwarves/data/objectives.json b/public/games/age-of-dwarves/data/objectives.json
index 29574d24..d5db400f 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-25T20:11:16Z",
+ "generated_at": "2026-06-25T20:45:04Z",
"totals": {
- "done": 293,
- "stub": 0,
- "in_progress": 0,
- "missing": 0,
"oos": 31,
- "partial": 4,
+ "done": 294,
+ "missing": 0,
+ "in_progress": 0,
+ "stub": 0,
+ "partial": 3,
"total": 328
},
"objectives": [
@@ -3244,7 +3244,7 @@
"id": "p3-20",
"title": "Weather affects scouting — vision/LoS penalty under storms, blizzards, dust",
"priority": "p3",
- "status": "partial",
+ "status": "done",
"scope": "game1",
"owner": "warcouncil",
"updated_at": "2026-06-25",
diff --git a/src/game/engine/scenes/world_map/world_map_vision.gd b/src/game/engine/scenes/world_map/world_map_vision.gd
index a2266c11..c4784357 100644
--- a/src/game/engine/scenes/world_map/world_map_vision.gd
+++ b/src/game/engine/scenes/world_map/world_map_vision.gd
@@ -21,6 +21,7 @@ extends RefCounted
const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd")
const PathfinderScript: GDScript = preload("res://engine/src/map/pathfinder.gd")
+const WeatherScript: GDScript = preload("res://engine/src/modules/climate/weather.gd")
const PrologueDriverScript: GDScript = preload(
"res://engine/src/modules/management/prologue_driver.gd"
)
@@ -52,8 +53,14 @@ static func recalculate_vision(player: RefCounted, game_map: RefCounted) -> void
for unit: RefCounted in player.units:
if not unit is UnitScript:
continue
+ # p3-20 — weather over the unit's tile cuts its scouting range.
+ var eff_vision: int = unit.vision
+ var weather: WeatherScript = TurnManager.weather as WeatherScript
+ if weather != null:
+ var pen: int = weather.vision_penalty_at(unit.position.x, unit.position.y)
+ eff_vision = maxi(1, unit.vision - pen)
var visible: Array[Vector2i] = PathfinderScript.visible_hexes(
- game_map, unit.position, unit.vision
+ game_map, unit.position, eff_vision
)
for pos: Vector2i in visible:
var tile: Resource = game_map.get_tile(pos) as Resource
diff --git a/src/game/engine/src/modules/climate/weather.gd b/src/game/engine/src/modules/climate/weather.gd
index 5f6e83c3..65f05e2b 100644
--- a/src/game/engine/src/modules/climate/weather.gd
+++ b/src/game/engine/src/modules/climate/weather.gd
@@ -85,6 +85,24 @@ func get_active_effects() -> Array[Dictionary]:
return _active_events
+## p3-20 — worst sight-radius reduction from active weather events covering the
+## given axial tile (storms/blizzards/dust storms cut scouting; 0 if clear). The
+## per-kind `vision_penalty` is authored data-side (mc-climate WeatherEvent); this
+## only reads it for events whose footprint contains the tile.
+func vision_penalty_at(col: int, row: int) -> int:
+ var here: Vector2i = Vector2i(col, row)
+ var worst: int = 0
+ for ev: Dictionary in _active_events:
+ var pen: int = int(ev.get("vision_penalty", 0))
+ if pen <= 0:
+ continue
+ var center: Vector2i = ev.get("axial", Vector2i.ZERO)
+ var radius: int = int(ev.get("radius", 0))
+ if HexUtilsScript.hex_distance(here, center) <= radius:
+ worst = maxi(worst, pen)
+ return worst
+
+
func _parse_events_array(json_text: String) -> Array:
## Defensive JSON decoding — returns [] on any parse failure or non-array
## top-level type so the turn loop never aborts on malformed Rust output.
diff --git a/src/game/engine/tests/unit/test_weather_climate_effects.gd b/src/game/engine/tests/unit/test_weather_climate_effects.gd
index ca6a5f77..e64ca397 100644
--- a/src/game/engine/tests/unit/test_weather_climate_effects.gd
+++ b/src/game/engine/tests/unit/test_weather_climate_effects.gd
@@ -33,6 +33,27 @@ func test_weather_active_effects_defaults_to_empty() -> void:
assert_eq(effects.size(), 0, "fresh Weather should report zero active effects")
+func test_weather_vision_penalty_within_and_outside_radius() -> void:
+ # p3-20: vision_penalty_at returns the event's penalty inside its footprint,
+ # 0 outside. (A blizzard at axial (5,5), radius 2, vision_penalty 2.)
+ var w: WeatherScript = WeatherScript.new()
+ w._active_events = [{"axial": Vector2i(5, 5), "radius": 2, "vision_penalty": 2}]
+ assert_eq(w.vision_penalty_at(5, 5), 2, "penalty at the event center")
+ assert_eq(w.vision_penalty_at(6, 5), 2, "penalty within radius")
+ assert_eq(w.vision_penalty_at(9, 9), 0, "no penalty outside radius")
+
+
+func test_weather_vision_penalty_takes_worst_overlap() -> void:
+ # p3-20: overlapping events → the worst (max) penalty wins.
+ var w: WeatherScript = WeatherScript.new()
+ w._active_events = [
+ {"axial": Vector2i(5, 5), "radius": 3, "vision_penalty": 1},
+ {"axial": Vector2i(5, 5), "radius": 1, "vision_penalty": 2},
+ ]
+ assert_eq(w.vision_penalty_at(5, 5), 2, "worst of overlapping events at center")
+ assert_eq(w.vision_penalty_at(7, 5), 1, "only the wider event reaches the edge")
+
+
func test_climate_effects_null_weather_is_noop() -> void:
## process_turn with a null weather arg must short-circuit without
## touching any units or raising a SCRIPT ERROR.