feat(@projects/@magic-civilization): 🌫️ p3-20 DONE — weather reduces scouting in the rendered game

Completes weather→scouting. weather.gd gains vision_penalty_at(col,row) (worst
penalty from active events covering the tile, hex-distance footprint);
world_map_vision.recalculate_vision cuts each unit's sight radius by it (floored
at 1) before computing visible_hexes — so a unit standing in a storm/blizzard/dust
storm reveals fewer tiles. The penalty VALUE is Rust-derived (WeatherEvent.vision_penalty,
data); GDScript only reads + applies it. Headless path has compute_vision_with_penalties.

Verified: mc-climate 45/0, mc-vision 30/0, GUT vision_penalty_at (within/outside/
worst-overlap); dylib rebuilt + deployed; canonical GUT 747/0.

p3-20 → done. Next: p3-21 (weather-driven migration).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-25 16:45:04 -04:00
parent c30b74523b
commit e77149f8fb
6 changed files with 77 additions and 21 deletions

View file

@ -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** |
</td><td valign='top' style='padding-left:2em'>
@ -26,7 +26,7 @@
| Team Lead | Remaining |
|---|---|
| [warcouncil](../team-leads/warcouncil.md) | 4 |
| [warcouncil](../team-leads/warcouncil.md) | 3 |
</td></tr></table>

View file

@ -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

View file

@ -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",

View file

@ -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

View file

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

View file

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