|
| 1 | +# Walker P2 — Unify the Obstacle Model |
| 2 | + |
| 3 | +_Plan date: 2026-07-26. Depends on P1 (docs/walker-audit.md): `state/WalkerRouteState`, |
| 4 | +`recovery/RouteRecovery` + harness, `geometry/WalkerPathGeometry`, and the pre-existing |
| 5 | +`door/` + `obstacle/` packages are in place._ |
| 6 | + |
| 7 | +## Status / outcome (2026-07-26) |
| 8 | + |
| 9 | +The plan below was executed **partially and deliberately** — the parts that pay off were taken, the parts |
| 10 | +that would trade a working system for architectural tidiness were **not**. What shipped: |
| 11 | + |
| 12 | +- ✅ **Abstractions + registry** (`obstacle/`: `PlannedEdge`, `ObstacleResolution`, `ObstacleResolver`, |
| 13 | + `ObstacleRegistry`, `LiveScene`, `WalkerActions`) — headless-tested. |
| 14 | +- ✅ **`MineableResolver` + `TransportResolver`** — decision logic pure, headless-tested. |
| 15 | +- ✅ **Dispatch cutover in the recovery block** (`Rs2Walker.resolveRecoveryObstacle`) — one |
| 16 | + `ObstacleResolution` switch replacing the inline rockfall mine + the stepping-stone override. |
| 17 | + **Live-verified** (user walked door route + MLM rockfall + River Lum stones, "works fine"). |
| 18 | +- ✅ **Rockfall fully migrated + legacy deleted** — all three rockfall sites route through |
| 19 | + `MineableResolver` (`resolveRockfallOnSegment`/`resolveRockfallOnEdge`); `applyRockfall` and |
| 20 | + `handleRockfallInRawSegment` are gone. The complete adapter→cutover→delete lifecycle on one obstacle type. |
| 21 | +- ✅ **Door decision cores harnessed** (`Rs2DoorClassifierTest`, `Rs2DoorGeometryTest`) — the "tests first" |
| 22 | + net, without touching the cascade. |
| 23 | + |
| 24 | +**Deliberately NOT done (and why):** the **door cascade** and the **transport interaction** (`handleTransports`, |
| 25 | +charter ships / stairs / precomputed continuations) were left on their existing paths. Unlike rockfall (an |
| 26 | +isolated, per-edge obstacle that fit the model cleanly), these are **stateful, order-dependent cascades** |
| 27 | +whose complexity is largely *essential* (real scenario diversity), not the accidental sprawl this plan |
| 28 | +assumed. Forcing them into per-edge `resolve()` would **reorder** working recovery logic for limited gain — |
| 29 | +a poor risk/reward on the walker's subtlest, working subsystem. The `door/` package is already well- |
| 30 | +decomposed and now tested at the decision level, which is the right end state for it. Revisit only if a |
| 31 | +specific symptom demands it — targeted, harness-pinned — not as a wholesale rewrite. `Rs2LiveScene` (the |
| 32 | +live read adapter) is retained for a future per-edge dispatch model but is currently unused in production |
| 33 | +(the shipped dispatch never needed the scene view). |
| 34 | + |
| 35 | +The plan as originally written follows, for context. |
| 36 | + |
| 37 | +## Problem being solved |
| 38 | + |
| 39 | +The walker plans on a static/overlay collision map, then discovers at runtime that a planned edge is |
| 40 | +blocked or gated by something the map can't model: a closed **door**/gate, a **rockfall**/rockslide, an |
| 41 | +agility **shortcut** (stepping stone, grapple, pipe), or a **transport** (stairs, ladder, cave, teleport, |
| 42 | +boat, fairy ring). Today each of these has its **own bespoke handler and its own recovery path**: |
| 43 | + |
| 44 | +- doors: `handleDoors`×2, `handleDoorsInRawSegment`, `handleUnresolvedDoorNearRawPath`, |
| 45 | + `tryResolvePathAdjacentBlocker`, `tryResolveNearbyDoorBlocker`, the whole `door/` package, plus |
| 46 | + door-attempt bookkeeping (`lastDoorAttempt*`, `nextDoorInteractionAllowedAtMs`, `rawScanFocusedDoor*`). |
| 47 | +- rockfalls: `obstacle/Rs2ObstacleHandler` (already returns a `RockfallResult`). |
| 48 | +- shortcuts/transports: the ~760-line `handleTransports`, `handleTransportsInRawSegment`, |
| 49 | + `finishHandledTransport`, `RouteRecovery.findReachableTransportOriginAhead` (the stepping-stone fix). |
| 50 | + |
| 51 | +They are gated differently, fire at different points in `processWalk`, and interact — which is why a fix |
| 52 | +in one (rockfall passable → smoother walks through → recovery clicks the far bank) breaks another. That is |
| 53 | +the whack-a-mole. **P2 collapses all of it into one path so a fix lands in one place, and thousands of |
| 54 | +lines of special-casing get deleted rather than reorganised.** |
| 55 | + |
| 56 | +## The one principle |
| 57 | + |
| 58 | +> When the live scene contradicts a planned edge, identify what blocks it and resolve it uniformly: |
| 59 | +> walk to the interaction tile if needed, perform the one right interaction, wait for it to clear, then |
| 60 | +> continue the route. If nothing can resolve it, record the real blocked edge and recalculate. |
| 61 | +
|
| 62 | +Every door/rockfall/shortcut/transport is an instance of this. There is exactly one dispatch. |
| 63 | + |
| 64 | +## Core abstractions (new, in `util/walker/obstacle/`) |
| 65 | + |
| 66 | +``` |
| 67 | +PlannedEdge { WorldPoint from, to; boolean adjacent; } // the route step in question |
| 68 | +LiveScene // read-only injected view: reachable set, transports map, tile objects/actions, |
| 69 | + // player tile. NO Rs2* statics inside resolvers -> harness-testable. |
| 70 | +ObstacleResolution enum { CROSSED, INTERACTED, WALK_TO_ORIGIN(WorldPoint), WAITING, ABORT(reason), |
| 71 | + NOT_APPLICABLE } |
| 72 | +ObstacleResolver interface { |
| 73 | + boolean handles(PlannedEdge edge, LiveScene scene); // pure, cheap classification |
| 74 | + ObstacleResolution resolve(PlannedEdge edge, LiveScene scene, WalkerActions io); // may act |
| 75 | +} |
| 76 | +ObstacleRegistry // ordered list of resolvers; first that handles() wins. |
| 77 | +``` |
| 78 | + |
| 79 | +`WalkerActions` is the thin imperative shell (interact-with-object, click-tile, sleepUntil) — the only |
| 80 | +part that touches the live client. Resolvers keep their **decision** logic pure (classification + |
| 81 | +what-to-do), so each is exercised headlessly by the harness exactly like `RouteRecoveryTest` does now. |
| 82 | + |
| 83 | +## Resolvers (each replaces a pile) |
| 84 | + |
| 85 | +| Resolver | handles() when the edge is blocked by… | resolve() | |
| 86 | +|----------|----------------------------------------|-----------| |
| 87 | +| `DoorResolver` | an openable door/gate on the edge (uses `door/Rs2DoorDetection`/`Rs2DoorClassifier`) | open it, wait for the edge to open | |
| 88 | +| `MineableResolver` | a rockfall/rockslide on/adjacent (folds in `Rs2ObstacleHandler`) | mine it, wait for it to clear | |
| 89 | +| `TransportResolver` | the edge is a transport/shortcut origin (stepping stone, ladder, cave, teleport) | `WALK_TO_ORIGIN` if not standing on it (the stepping-stone fix), else take it | |
| 90 | + |
| 91 | +Adding a new dynamic obstacle later = one new resolver, registered — never another branch in `processWalk`. |
| 92 | + |
| 93 | +## Resolution flow (replaces the cascade) |
| 94 | + |
| 95 | +`processWalk`'s "stuck at unreachable tile" recovery and the post-transport segment handlers both become: |
| 96 | + |
| 97 | +``` |
| 98 | +PlannedEdge edge = nextBlockedPlannedEdge(path, player, scene); // the frontier we can't cross |
| 99 | +if (edge != null) { |
| 100 | + switch (registry.resolve(edge, scene, io)) { |
| 101 | + case WALK_TO_ORIGIN(t): clickToward(t); return; // e.g. step onto the stone |
| 102 | + case INTERACTED / WAITING: return; // door opening / rock mining |
| 103 | + case CROSSED: continue; |
| 104 | + case ABORT(r): liveStore.markBlocked(edge); recalculate(); return; // learn + replan |
| 105 | + case NOT_APPLICABLE: fall through to the plain minimap-recovery click. |
| 106 | + } |
| 107 | +} |
| 108 | +``` |
| 109 | + |
| 110 | +One place. The ~40 handlers become 3 resolvers behind `resolve()`. |
| 111 | + |
| 112 | +## Migration — strangler, ordered (each step compile+harness green, live-test the behavior ones) |
| 113 | + |
| 114 | +1. **Define the abstractions** above + a `LiveScene`/`WalkerActions` adapter over the current |
| 115 | + `Rs2Player`/`Rs2Tile`/`Rs2GameObject`/`Rs2PathApi` calls. No behavior change. |
| 116 | +2. **Adapter resolvers first (no rewrite):** wrap the *existing* `Rs2DoorHandler`, `Rs2ObstacleHandler`, |
| 117 | + and `handleTransports` logic as `DoorResolver`/`MineableResolver`/`TransportResolver` that delegate to |
| 118 | + today's code. Register them. Still no behavior change — just reachable through one interface. |
| 119 | +3. **Cut over the dispatch:** replace the recovery/segment-handler cascade in `processWalk` with the |
| 120 | + single `registry.resolve(edge)` call above. This is the first behavior-affecting step → **live-test** |
| 121 | + (door route, MLM rockfall, stepping stone). Because the resolvers still wrap the old code, behavior |
| 122 | + should be identical; the cutover just proves the single path. |
| 123 | +4. **Rewrite resolvers into pure decision + thin action, one at a time**, deleting the wrapped legacy |
| 124 | + methods as each is replaced. Start with `TransportResolver` (already partly pure via |
| 125 | + `findReachableTransportOriginAhead`), then `MineableResolver` (already clean), then `DoorResolver` |
| 126 | + (the biggest deletion). Each rewrite is harness-tested headlessly + one live walk. |
| 127 | +5. **Delete** the now-dead special-cases and their bookkeeping state. |
| 128 | + |
| 129 | +## Deletion targets (the payoff) |
| 130 | + |
| 131 | +- The ~20 door methods + `door/`'s overlap with `processWalk`, and door-attempt state fields. |
| 132 | +- The special-case branches inside the ~760-line `handleTransports` that duplicate segment/recovery logic. |
| 133 | +- The duplicated recovery paths (`route-fold-continuation`, per-obstacle `*InRawSegment` scans, the |
| 134 | + far-tile fallback's obstacle guesses). Net: target four-figure line reduction, not reorganisation. |
| 135 | + |
| 136 | +## Testing |
| 137 | + |
| 138 | +Every resolver's `handles()`/`resolve()` decision is pure and headless-tested in |
| 139 | +`recovery`/`obstacle` test classes using in-memory `LiveScene` fixtures (the pattern `RouteRecoveryTest` |
| 140 | +already establishes). `WalkerActions` is mocked. The only thing needing a live walk is the **dispatch |
| 141 | +cutover (step 3)** and each **resolver rewrite (step 4)** — a handful of walks, not one per fix. |
| 142 | + |
| 143 | +## Where the cataloged "weirdness" gets fixed |
| 144 | + |
| 145 | +Each symptom the user is cataloging (far-side clicks, oscillation, mis-timed door probes, transports not |
| 146 | +taken) is a property of **one** resolver or the single dispatch — fix it there, once, with a harness test |
| 147 | +that pins it. No more "fix here, break there," because there is no longer a "there." |
| 148 | + |
| 149 | +## Risks & mitigation |
| 150 | + |
| 151 | +- **Behavior drift during cutover** → steps 2–3 keep the *old* logic behind the interface, so the cutover |
| 152 | + is a dispatch change, not a logic change; live-tested. |
| 153 | +- **Un-unit-tested `processWalk`** → the strangler keeps `processWalk` mostly intact; only the recovery |
| 154 | + dispatch is swapped, and each resolver rewrite is guarded by harness + one live walk. |
| 155 | +- **Scope creep** → resolvers are added/rewritten one at a time; the branch is always shippable. |
0 commit comments