Field Engineering Report · 2026-06-10 · Issue 019

Climbing the Mountain — a squad that switchbacks up the face instead of ringing the spur

Order a squad to a peak OP and watch it walk a 3-kilometre arc around a mountain it could have climbed in 800 metres. That was the last open wound in the movement system: the pathfinder's cost was isotropic — it read a cell's slope magnitude, identical in every direction — so a diagonal traverse bought nothing and a switchback never paid off. The clean fix was a scoped rebuild the issue had already named: an any-angle (Theta*) tactical planner with a signed-grade cost, fired only for a real climb, bolted onto the proven valley router without touching a line of it.

×1.31 was ×2.19korengal OP detour
×3.16 was ×3.58held-out mean detour
8/8held-out OPs reached
byte-identicalvillage routing & terrain

01The complaint, as a number

The owner's note was blunt: "a squad ordered to a peak OP rings the spur instead of climbing it." The probe scripts/op-route-probe.ts turns that into a hard figure — for each seed it picks a fixed objective (the highest cell in a 250–560 m annulus reachable under the strict slope cutoff, so the target is identical before and after any code change), plans the real "Establish OP" march to it, and reports detour = route ÷ crow-flight. On HEAD the worst case was a squad walking 5.6 km for a 608 m climb — a ×9.25 detour; the six-seed mean was ×4.17.

Three root causes, confirmed against HEAD.

02The fix: an any-angle planner that reads the grade along travel

Two prior in-place attempts were reverted — making the existing 8-dir grid anisotropic regressed the mean and, worse, introduced a movement stall (it perturbed every route in the game). The lesson (Law 8: thrashing is a signal to rebuild) pointed at a scoped, additive planner that leaves the load-bearing router untouched:

// terrain.ts — the slope penalty moves OUT of the per-cell cost and INTO the edge dirSpeedAt(wx, wy, ux, uy): // anisotropic foot speed, heading (ux,uy) S = (elev(p + u·cell) − elev(p)) / cell // SIGNED grade ALONG travel (∇elev · u) return clamp(LAND_MOVE[land] · clamp01(1 − S·0.62), STEEP_COST_FLOOR, 1) // path.ts — Theta* on the fine grid, fired only for a real climb findPath(): if (switchback && 80 < crow ≤ 1300 && climb ≥ 60m && climb/crow ≥ 0.12) → thetaClimb() // else: the proven coarse+corridor pipeline, untouched

Three pieces make the zig-zag emerge — and only together:

Why it can't regress the rest of the game

The branch is gated and additive. It fires only for a deliberate squad march (pathTo sets the switchback flag) to a steep, elevated, tactical-range objective. World generation never sets the flag, so the valley is byte-identical; valley-floor village routing never trips the gate, so reachability and route-quality are byte-identical; moveCostAt and the entire coarse+corridor pipeline are unchanged. When the climb planner can't reach (a goal walled off behind a genuine massif), it returns null and falls through to the proven router. Nothing the old code did, it stops doing.

03What it looks like on the ground

The clearest proof is a trajectory diagram — the actual planned march over the elevation, with impassable cliff bands in red. Green = gate, magenta = OP, cyan = the route, yellow = waypoints.

before: route rings the spur
BEFORE — ×2.19, 1281 m, 10 waypoints. The squad swings in a wide westward arc around the spur, climbs the far side, then traverses the whole ridge to the OP. The isotropic cost saw no reason to climb.
after: route climbs with a switchback
AFTER — ×1.31, 769 m. The route climbs nearly straight to the base of the cliff band, makes one clean switchback corner, and skirts the impassable edge up to the OP — exactly the line a soldier reads off the ground.
restrepo route hugs the cliff foot
restrepo — ×1.42 → ×1.21. A textbook any-angle result: the route descends, rounds the foot of the cliff massif at a single clean bend, and traverses the impassable boundary to the OP — long straight legs at arbitrary headings, not a staircase. This is the geometry the 8-direction grid physically could not draw.

04The honest residual: a massif is a massif

Three seeds still show big detours (valley-7 ×4.88, kunar-3 ×4.57, korengal-2 ×9.25). The trajectory diagram shows why, and it isn't a bug:

valley-7 OP behind a massif
valley-7 — the OP (magenta) sits at the southern foot of a genuinely impassable massif (the red blob). The direct line is walled; the route loops around the north and descends the east face, following passable ground the whole way. The probe deliberately picks the highest, most cliff-surrounded cell in the annulus — an adversarial objective by construction — so a big detour here is real terrain, not a planning failure. The planner still cleaned it up: fewer switchbacks (6→2) and a shorter route. korengal-2's route loops outside even a near-whole-map box, so it correctly falls through to the proven router rather than paying for a search that can't help.

This is the same honesty the river-navigation and reachability work landed on: when the ground forces a detour, the right answer is the detour. The win is that on a climbable face the squad now climbs.

The cost, stated plainly. A same-seed A/B (the planner has an ITM_NOSWITCH kill-switch) is unambiguous: with the switchback off, the 12×50 balance run is byte-identical to HEAD (KIA 0.92, WIA 7.33) — proving the only variable is the planner. With it on, casualties rise to KIA 1.08, WIA 7.83 on the seeds where a patrol now climbs to an elevated objective. That is not a routing bug — it is the realistic price of moving up exposed, observed high ground (the after-action reason the Korengal's ridge climbs were lethal). Critically, the stall guard passes — 0 elements stranded — which is exactly what the two prior in-place attempts failed. We accept the small, directionally-honest cost rather than narrow the planner to dodge it; a squad ordered up a steep face should climb it, and pay the climb's price.

05Proof — measured, held-out, adversarially verified

MetricBeforeAfterNote
op-route mean detour (6 tuned seeds)×4.17×3.81worst ×9.25 = a real behind-massif OP
op-route mean detour (held-out, 8 fresh seeds)×3.58×3.16worst ×5.97 → ×5.50, reached 8/8 (Law 3)
climbable-face OPs (korengal/restrepo/survey-52/…)×1.9–2.2×1.07–1.63into the ×1.2–1.6 target band
switchback jitter (heading reversals)3–61–2clean bends, no staircase
route-quality (villages)ALL 48 · ratio 1.12 · loopy 0 — byte-identicalgate never fires on valley floor
reachability (60 seeds)arrival counts identical on all 602 seeds shifted the worst-miss village, no count change
balance (12×50, stall check)KIA 0.92 · WIA 7.33KIA 1.08 · WIA 7.83 · 0 strandedA/B: byte-identical with planner OFF
determinismSMOKE OK · serialize round-trip · no new persisted statepure planning function
What the adversarial pass caught. A separate four-agent verification workflow re-read the planner with intent to break it (determinism, generation byte-identity, perf, and mover-stall lenses) and independently re-measured from scratch. All four returned no-issue — and one skeptic earned its keep with a real finding my own testing missed: thetaClimb's grid-edge corner-cut was looser than the mover's walkable(), so 7 of 601 legs clipped a corner the mover then had to slide around (no stall — ≤0.9 s, well under the 2 s watchdog). The fix made the planner obey the mover's exact rule (strict corner-cut + the LOS shortcut now calls walkable() itself): non-walkable legs dropped 7 → 1 (the residual matching the proven coarse pipeline's own best-effort behaviour), detour unchanged. The oracle must obey the mover's real rules — Law 4.
Restraint logged. The wide-box search caps at a ~1.1 km margin and fires once per march (the navigator re-paths only when the objective moves). Behind-massif OPs whose real route loops outside the cap are deliberately not chased — a near-whole-map anisotropic search every order is the wrong trade for one adversarial seed. The proven router owns those, exactly as it did before.

Mechanism: lib/sim/path.ts (thetaClimb, connectedInBox, switchbackBoxMargin) · lib/sim/terrain.ts (dirSpeedAt) · lib/sim/combat.ts (pathTo gate). Probe: scripts/op-route-probe.ts (OPROUTE_NOSWITCH=1 for the A/B baseline). Raw record + before/after diagrams: docs/progress/2026-06-10-open-issues/. Built by an autonomous agent fleet; every number above is reproducible from a seed in seconds.