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:
LOSEnginenow accepts an optionalclassificationparameter. When present, vegetation height (modulated by seasonalvegetation_density) is added to the surface elevation model. The scalar raycaster path checks vegetation canopy alongside buildings.set_vegetation_density()is called per-tick fromengine.pyusing 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_tonsfield toUnitdataclass (default 0.0). Persisted throughget_state()/set_state()with backward-compatible default for legacy checkpoints. - Bridge capacity enforcement: In battle.py
_execute_movement, whenenable_bridge_capacityis True, units near bridges are checked againstBridge.capacity_tons. Overweight units are blocked. A_WEIGHT_DEFAULTSdict 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 fromengine.pyafterupdate_fire_zones(). - Environmental fatigue:
FatigueManager.accumulate()gains atemperature_stresskeyword 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 = Falseenable_bridge_capacity: bool = Falseenable_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¶
-
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. -
Scalar path forced with classification: When
classificationis 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. -
Weight heuristic over YAML: Rather than modifying 100+ YAML unit files, a
_WEIGHT_DEFAULTSdict 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. -
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.pyafterupdate_fire_zones()keeps it at the right abstraction level. -
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¶
- 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.
- D2: Bridge capacity uses hardcoded weight defaults — not all unit types covered. YAML
weight_tonsfield exists but no YAML files modified. - 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.
- D4: Ford crossing doesn't check river fordability (
is_fordable()) — it checksis_in_water()+ford_points_near(). Full river-specific fordability with seasonal water levels is a future enhancement. - 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(notenvironment/obscurants.pyas spec'd) — better cohesion with existingIncendiaryDamageEngine. - 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.pyorengine.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.mdlists phantom historical scenarios. Pre-existing issue, not Phase 78 regression.
Performance: No Degradation¶
8920 unit tests in 91.5s. No slowdown from new code.