Skip to content

Phase 78: P2 Environment Wiring

Block: 8 (Consequence Enforcement & Scenario Expansion) Status: Complete Tests: 49

Summary

Wired 6 remaining P2-priority environment items deferred from Phases 59–62 (Block 7). These connect existing infrastructure (SeasonsEngine, HydrographyManager, TerrainClassification, FatigueManager, IncendiaryDamageEngine) to behavioral enforcement in the movement, LOS, and battle loops.

What Was Built

78a: Ice Crossing & Vegetation LOS (~11 tests)

  • Ice crossing: MovementEngine.is_on_ice() checks if a position is a water cell with ice thickness >0.3m. In battle.py _execute_movement, frozen water allows traversal at 50% speed; unfrozen water blocks movement.
  • Vegetation LOS blocking: LOSEngine now accepts an optional classification parameter. When present, vegetation height (modulated by seasonal vegetation_density) is added to the surface elevation model. The scalar raycaster path checks vegetation canopy alongside buildings. set_vegetation_density() is called per-tick from engine.py using the SeasonsEngine snapshot.
  • Blocked-by attribution: LOS results now correctly attribute blocking to "vegetation", "building", or "terrain" based on which obstruction is dominant at the blocking point.

78b: Bridge Capacity & Ford Crossing (~11 tests)

  • Unit weight: Added weight_tons field to Unit dataclass (default 0.0). Persisted through get_state()/set_state() with backward-compatible default for legacy checkpoints.
  • Bridge capacity enforcement: In battle.py _execute_movement, when enable_bridge_capacity is True, units near bridges are checked against Bridge.capacity_tons. Overweight units are blocked. A _WEIGHT_DEFAULTS dict provides realistic weights for key vehicle types without modifying YAML.
  • Ford crossing: When a unit encounters water, nearby ford points are checked. If available, movement proceeds at 30% speed. Without ford or ice, water blocks movement.

78c: Fire Spread & Environmental Fatigue (~27 tests)

  • Fire spread cellular automaton: IncendiaryDamageEngine.spread_fire() checks 8 cardinal/ordinal directions around each active fire zone. Spread probability ∝ combustibility × (1 − vegetation_moisture) × wind_factor. Wind biases spread downwind (2×) vs upwind (0.3×). Max 50 zones cap prevents runaway. Called from engine.py after update_fire_zones().
  • Environmental fatigue: FatigueManager.accumulate() gains a temperature_stress keyword parameter. In battle.py, WBGT >28°C or wind chill <-20°C computes a stress multiplier that accelerates fatigue for all active units. Rate formula: rate *= (1.0 + temperature_stress).

CalibrationSchema (3 new fields)

  • enable_ice_crossing: bool = False
  • enable_bridge_capacity: bool = False
  • enable_environmental_fatigue: bool = False

Vegetation LOS uses existing enable_seasonal_effects. Fire spread uses existing enable_fire_zones. Ford crossing bundled with enable_bridge_capacity.

Design Decisions

  1. Vegetation as surface elevation, not separate check: Vegetation height is added to the surface model (max(building_h, veg_h)) in the scalar raycaster. This naturally handles the geometry — rays from above clear canopy, rays at ground level are blocked. No special air-unit exemption needed.

  2. Scalar path forced with classification: When classification is present, the vectorized LOS path is skipped in favor of the scalar path. This matches the existing pattern for infrastructure (buildings). The vectorized path remains the fast default when neither buildings nor vegetation are present.

  3. Weight heuristic over YAML: Rather than modifying 100+ YAML unit files, a _WEIGHT_DEFAULTS dict in battle.py provides realistic weights for key vehicle types. Unknown types default to 0.0 (no enforcement). This is correct — infantry/archers shouldn't be weight-checked.

  4. Fire spread in engine.py, not battle.py: Fire spread is a per-tick environmental process, not a per-engagement effect. Placing it in engine.py after update_fire_zones() keeps it at the right abstraction level.

  5. Temperature stress as rate multiplier: Rather than a separate fatigue path, temperature stress multiplies the existing fatigue rate. This is additive with altitude penalty (both can apply simultaneously).

Files Modified

File Changes
stochastic_warfare/simulation/calibration.py 3 new CalibrationSchema fields
stochastic_warfare/entities/base.py weight_tons field + get_state/set_state
stochastic_warfare/terrain/los.py classification param, set_vegetation_density(), vegetation blocking in scalar raycaster
stochastic_warfare/movement/engine.py is_on_ice() method
stochastic_warfare/movement/fatigue.py temperature_stress param on accumulate()
stochastic_warfare/combat/damage.py spread_fire() method on IncendiaryDamageEngine
stochastic_warfare/simulation/battle.py Ice/bridge/ford gates in _execute_movement, env fatigue in tick loop
stochastic_warfare/simulation/engine.py Fire spread wiring, vegetation density LOS update

Test Files

File Tests
tests/unit/test_phase78_ice_vegetation.py 11
tests/unit/test_phase78_bridge_ford.py 11
tests/unit/test_phase78_fire_spread.py 6
tests/unit/test_phase78_fatigue_env.py 6
tests/unit/test_phase78_structural.py 15
Total 49

Known Limitations & Deferrals

  1. D1: Vegetation LOS forces scalar path — performance impact when many LOS checks go through forested terrain. Vectorized vegetation_height_at_batch() could be added later for the vectorized path.
  2. D2: Bridge capacity uses hardcoded weight defaults — not all unit types covered. YAML weight_tons field exists but no YAML files modified.
  3. D3: Fire spread is stochastic per-tick — no accumulation across ticks. A cell that doesn't ignite this tick may ignite next tick with independent probability.
  4. D4: Ford crossing doesn't check river fordability (is_fordable()) — it checks is_in_water() + ford_points_near(). Full river-specific fordability with seasonal water levels is a future enhancement.
  5. D5: Ice thickness threshold (0.3m) is hardcoded — not configurable per-scenario. Real ice load capacity depends on ice type and vehicle weight.

Lessons Learned

  • Vegetation LOS is geometrically correct but surprising: A ray from 100m altitude to a ground target passes through 15m canopy near the target. This is physically correct — aerial observation relies on sensors, not optical LOS through forest. Test expectations must match the geometry.
  • Surface model unification works well: Adding vegetation to max(building_h, veg_h) in the existing scalar raycaster required minimal code change and naturally handles all observer/target height combinations.
  • Fire spread cap is essential: Without the 50-zone cap, fire in coniferous forest with dry vegetation and wind could create hundreds of zones in a few ticks, causing quadratic performance issues.

Postmortem

Scope: On Target

  • 6/6 P2 items wired. 49 tests delivered vs 28 planned (structural tests add 15).
  • Fire spread placed in combat/damage.py (not environment/obscurants.py as spec'd) — better cohesion with existing IncendiaryDamageEngine.
  • Ice crossing uses is_on_ice() + battle.py gate (not pathfinding graph edges) — simpler, consistent with existing patterns.

Quality: High

  • All new public methods have type hints and docstrings.
  • Edge cases covered: zero weight, thin ice, winter density, max zone cap, water blocking.
  • 15 structural tests verify wiring via source inspection.
  • No TODOs, FIXMEs, or bare print() in new code.

Integration: Fully Wired

  • Every new method called from battle.py or engine.py.
  • 3 CalibrationSchema fields gated in battle.py.
  • Per-tick vegetation density update in engine.py.
  • Gap: No scenario YAML enables new flags (by design — opt-in, default False).

Deficits: 5 Documented (D1–D5)

All acceptable limitations. No blocking issues.

Cross-Doc Audit: 18/19 PASS

  • Check 17 FAIL (HIGH): docs/guide/scenarios.md lists phantom historical scenarios. Pre-existing issue, not Phase 78 regression.

Performance: No Degradation

8920 unit tests in 91.5s. No slowdown from new code.