diff --git a/.github/workflows/shortest-path-upstream-drift.yml b/.github/workflows/shortest-path-upstream-drift.yml new file mode 100644 index 00000000000..14c45bb800a --- /dev/null +++ b/.github/workflows/shortest-path-upstream-drift.yml @@ -0,0 +1,90 @@ +name: Shortest Path upstream drift + +on: + schedule: + - cron: "17 6 * * 1" + workflow_dispatch: + pull_request: + paths: + - ".github/workflows/shortest-path-upstream-drift.yml" + - "scripts/check-shortest-path-upstream.py" + - "scripts/check-shortest-path-boundary.py" + - "scripts/check-shortest-path-vendored-core.py" + - "scripts/compare-shortest-path-transports.py" + - "scripts/compare-shortest-path-planners.py" + - "scripts/evaluate-walker-shadow-evidence.py" + - "scripts/evaluate-walker-rollout-evidence.py" + - "scripts/report-shortest-path-planner-performance.py" + - "scripts/shortest-path-planner-corpus.json" + - "scripts/shortest-path-planner-harness/**" + - "scripts/shortest-path-upstream-baseline.json" + - "scripts/shortest-path-vendored-core-baseline.json" + - "scripts/shortest-path-transport-baseline.json" + - "scripts/tests/test_compare_shortest_path_transports.py" + - "scripts/tests/test_compare_shortest_path_planners.py" + - "scripts/tests/test_evaluate_walker_shadow_evidence.py" + - "scripts/tests/test_evaluate_walker_rollout_evidence.py" + - "scripts/tests/test_report_shortest_path_planner_performance.py" + - "scripts/tests/test_check_shortest_path_boundary.py" + - "scripts/tests/test_check_shortest_path_vendored_core.py" + - "runelite-client/src/upstreamPlanner/**" + - "runelite-client/src/main/java/net/runelite/client/plugins/microbot/**" + - "runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/**" + - "docs/evidence/walker/**" + +permissions: + contents: read + +jobs: + check-upstream: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 11 + cache: gradle + - name: Test semantic transport comparison + run: python3 -m unittest scripts/tests/test_compare_shortest_path_transports.py + - name: Test planner comparison orchestration + run: python3 -m unittest scripts/tests/test_compare_shortest_path_planners.py + - name: Test live planner shadow evidence evaluation + run: python3 -m unittest scripts/tests/test_evaluate_walker_shadow_evidence.py + - name: Test planner rollout evidence evaluation + run: python3 -m unittest scripts/tests/test_evaluate_walker_rollout_evidence.py + - name: Test planner performance evidence evaluation + run: python3 -m unittest scripts/tests/test_report_shortest_path_planner_performance.py + - name: Test vendored planner pin verification + run: python3 -m unittest scripts/tests/test_check_shortest_path_vendored_core.py + - name: Enforce vendored planner pin and patch surface + run: scripts/check-shortest-path-vendored-core.py + - name: Compare local and reviewed upstream planners + run: scripts/compare-shortest-path-planners.py --require-all + - name: Test shortest-path boundary check + run: python3 -m unittest scripts/tests/test_check_shortest_path_boundary.py + - name: Enforce shortest-path plugin-state boundary + run: python3 scripts/check-shortest-path-boundary.py + - name: Compare reviewed Shortest Path baseline with upstream + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: scripts/check-shortest-path-upstream.py + - name: Enforce reviewed semantic transport baseline + run: scripts/compare-shortest-path-transports.py --check-baseline --limit 20 + - name: Verify tracked live-shadow evidence + run: | + scripts/evaluate-walker-shadow-evidence.py \ + docs/evidence/walker/2026-08-05/shadow-input-*.json \ + --json-output /tmp/walker-shadow-evidence.json \ + --markdown-output /tmp/walker-shadow-evidence.md + cmp /tmp/walker-shadow-evidence.json \ + docs/evidence/walker/2026-08-05/shadow-evidence.json + - name: Verify tracked F2P selection and rollback evidence + run: | + scripts/evaluate-walker-rollout-evidence.py \ + docs/evidence/walker/2026-08-05/rollout-normal-input.json \ + docs/evidence/walker/2026-08-05/rollout-rollback-input.json \ + --json-output /tmp/walker-rollout-evidence.json \ + --markdown-output /tmp/walker-rollout-evidence.md + cmp /tmp/walker-rollout-evidence.json \ + docs/evidence/walker/2026-08-05/rollout-evidence.json diff --git a/docs/F2P_WEBWALKER_HARNESS.md b/docs/F2P_WEBWALKER_HARNESS.md index 62a97745659..f101731af0e 100644 --- a/docs/F2P_WEBWALKER_HARNESS.md +++ b/docs/F2P_WEBWALKER_HARNESS.md @@ -35,6 +35,164 @@ scripts/run-f2p-webwalker-harness.sh F2P-15 The runner forwards route settings through `microbot.test.webwalker.*` system properties because the Gradle `runTest` task only propagates `microbot.test.*` properties into the launched client JVM. +`TestRunnerPlugin` starts before ordinary plugins and clears the persisted enabled flag for the selected +test target. It starts that target only after a game tick reports `LOGGED_IN`, a local player is present and +the welcome-screen Play widget is no longer visible, then clears the enabled flag again while leaving the +target active for the current process. This readiness contract is intentional: RuneLite can report +`LOGGED_IN` and expose a local player before the welcome overlay has stopped blocking interaction, and a +persisted harness flag must not start its private route timeout during the next client's login sequence. + +## Planner modes and evidence + +Planner selection is explicit and defaults to `LOCAL`: + +- `LOCAL` runs only the local planner; +- `SHADOW` executes local and compares upstream asynchronously; +- `UPSTREAM_F2P_CANARY` calculates both candidates for non-members policy and selects upstream only after a + semantic match. Members-policy requests remain local. + +Enable shadow evidence for a harness run with: + +```bash +MICROBOT_WEBWALKER_PLANNER_MODE=SHADOW \ +scripts/run-f2p-webwalker-harness.sh F2P-17 +``` + +`MICROBOT_WEBWALKER_UPSTREAM_PLANNER_SHADOW=true` remains a harness compatibility alias for `SHADOW`; new +automation should set the mode directly. + +The harness waits up to two minutes for the bounded shadow worker to settle and embeds the same +coordinate-free schema-v2 object served by `/walker/shadow` under `shadowEvidence` in `result.json`. An +enabled run fails if it submits no comparison, leaves work pending, or observes a semantic divergence or +planner failure. Extract and run the full coverage evaluator with: + +```bash +jq '.shadowEvidence' ~/.runelite/test-results/f2p-webwalker/result.json \ + > build/walker-shadow-snapshot.json +scripts/evaluate-walker-shadow-evidence.py \ + build/walker-shadow-snapshot.json \ + --json-output build/walker-shadow-evidence.json \ + --markdown-output build/walker-shadow-evidence.md +``` + +A single route can prove that evidence collection works but cannot satisfy the production selection gate's +cross-category minimums. Run the representative route mix described in `docs/walker-planner-selection-gate.md`. + +The accepted 2026-08-05 aggregate combines 12 independently validated fresh-client snapshots and passes the +full F2P live gate: 141/141 semantic matches, 75 active routes, 15 active replans, 11 recovery replans, 39 +underground comparisons, 18 walking-only cave selections and 71 exact arrivals, with no divergence, planner +failure, pending/discarded work, unreachable outcome or exit. Keep the inputs separate and use the evaluator; +do not treat one long process or hand-added counters as equivalent evidence. + +Run the opt-in F2P selection canary separately: + +```bash +MICROBOT_WEBWALKER_PLANNER_MODE=UPSTREAM_F2P_CANARY \ +MICROBOT_WEBWALKER_OUTPUT_DIR=/tmp/microbot-f2p-canary \ +scripts/run-f2p-webwalker-harness.sh F2P-17 +``` + +The canary keeps the active route calculating until both planners have completed, then atomically exposes one +selected route. It fails evidence collection if an ordinary canary run records a semantic divergence, planner +failure or no upstream selection. The accepted 2026-08-05 underground run completed five repetitions, made +ten upstream selections and recorded ten arrivals with no divergence or failure. + +The test-only forced-failure mode proves the release-independent local fallback without changing production +failure handling: + +```bash +MICROBOT_WEBWALKER_PLANNER_MODE=UPSTREAM_F2P_CANARY \ +MICROBOT_WEBWALKER_FORCE_UPSTREAM_FAILURE=true \ +MICROBOT_WEBWALKER_EXPECT_LOCAL_FALLBACK=true \ +MICROBOT_WEBWALKER_OUTPUT_DIR=/tmp/microbot-f2p-rollback \ +scripts/run-f2p-webwalker-harness.sh F2P-17 +``` + +The failure hook is honored only in test mode. An accepted rollback run requires planner failures and local +failure fallbacks, requires zero upstream selections, and still requires every live route arrival. The +2026-08-05 run recorded ten injected failures, ten local fallbacks and ten arrivals. Terminal outcomes are +bound to the generation-matched ready route, so `LOCAL` walks and members-policy walks under the F2P canary +cannot inflate these counts; the normal and forced-failure F2P-17 runs each retained all ten eligible arrivals. + +Evaluate the two fresh-client artifacts as one release decision instead of reviewing their embedded checks +independently: + +```bash +scripts/evaluate-walker-rollout-evidence.py \ + /tmp/microbot-f2p-canary/result.json \ + /tmp/microbot-f2p-rollback/result.json \ + --json-output build/walker-f2p-rollout-evidence.json \ + --markdown-output build/walker-f2p-rollout-evidence.md +``` + +The paired evaluator requires the pinned candidate, distinct client sessions, the same required route set, +settled accounting, ten normal upstream selections and arrivals, and ten forced failure fallbacks and +arrivals. It also requires one coordinate-free canary-readiness timing sample per completed comparison, +rejects a submission-to-ready maximum above `2,000 ms`, and rejects average non-search overhead above `250 ms` +per decision after subtracting both measured searches. Readiness includes executor queueing, both searches, +semantic comparison, selection/fallback and route materialization. Its report is coordinate-free and rejects +exception-message exposure. The fresh 2026-08-05 pair passes with no failure, shortfall or warning: normal +readiness averaged `304.3 ms`, peaked at `645.7 ms` and averaged `172.0 ms` of non-search overhead; forced +rollback averaged `260.8 ms`, peaked at `675.1 ms` and averaged `123.4 ms` of non-search overhead. + +Prerequisite-bearing selection-gate routes are intentionally excluded from the default fresh-account suite. +Run them explicitly on a suitable profile: + +```bash +MICROBOT_WEBWALKER_PLANNER_MODE=SHADOW \ +scripts/run-f2p-webwalker-harness.sh F2P-18 +``` + +It first performs ten real `compareRoutes` calls and requires all ten explicit bank-to-target shadow legs to +settle and select an item-gated transport. It then performs three Lumbridge-to-Champions' Guild repetitions, +disables agility shortcuts and teleports, enables canoes, and requires at least five completed `CANOE` shadow +selections in addition to exact arrivals. The accepted 2026-08-05 rerun produced 49/49 matching comparisons, +10/10 matching item-gated bank legs and six exact terminal arrivals. + +The representative terminal-travel slice requires at least 90 coins for two outbound journeys and one reverse +setup journey: + +```bash +MICROBOT_WEBWALKER_PLANNER_MODE=SHADOW \ +scripts/run-f2p-webwalker-harness.sh F2P-19 +``` + +It disables agility shortcuts and teleports, enables ships, and requires at least three completed +`TERMINAL_TRAVEL` shadow selections in addition to exact arrivals. + +The ordinary-replan slice injects twelve replans only while a long surface route is active and requires each +one to finish in the upstream shadow before accepting the final arrival: + +```bash +MICROBOT_WEBWALKER_PLANNER_MODE=SHADOW \ +scripts/run-f2p-webwalker-harness.sh F2P-20 +``` + +The recovery slice queues its replans through a test-only hook that is consumed by the walker thread. This +exercises the same recovery evidence context as a real stall without manufacturing a client-thread sleep or +calling the recovery helper from the harness thread. Three repetitions produce at least five alternating Port +Sarim / Rimmington legs; each sufficiently long outbound or reverse setup leg attempts two progress-gated +recovery replans. The verifier uses observed results rather than requested injections and requires ten +completed comparisons plus five recovered arrivals. The accepted 2026-08-05 session produced 11/11 matching +recovery comparisons and six recovered arrivals with no exit or unreachable outcome: + +```bash +MICROBOT_WEBWALKER_PLANNER_MODE=SHADOW \ +scripts/run-f2p-webwalker-harness.sh F2P-21 +``` + +The existing spell-teleport stress harness can capture a separate fresh-session slice: + +```bash +MICROBOT_GE_LUMBRIDGE_ITERATIONS=3 \ +MICROBOT_GE_LUMBRIDGE_UPSTREAM_PLANNER_SHADOW=true \ +scripts/run-ge-lumbridge-teleport-harness.sh +``` + +Extract its `shadowEvidence` as a second file and pass both snapshots to the evaluator. It validates every +session before aggregation and rejects duplicate session start identities. Do not concatenate JSON or add +counters by hand. + ## Agent Loop 1. Run the full suite. @@ -64,4 +222,16 @@ The runner forwards route settings through `microbot.test.webwalker.*` system pr | F2P-14 | `3092,3245,0` | `3109,3341,0` | Draynor Manor approach | | F2P-15 | `3109,3341,0` | `3106,3363,0` | Draynor Manor door/object handling | | F2P-16 | `3106,3363,0` | `3092,3245,0` | Reverse manor exit behavior | -| F2P-17 | current live player tile | `3237,9858,0` | Captures current origin, then walks to Varrock Sewers 5 times on a F2P world with agility shortcuts and teleports disabled | +| F2P-17 | `3236,3458,0` | `3237,9858,0` | Walks from the fixed Varrock surface manhole to the sewers 5 times on a F2P world with agility shortcuts and teleports disabled; setup climbs out before every repetition, so a prior run ending underground cannot turn the case into a no-op | + +## Selection-gate routes + +These prerequisite-bearing routes are available by explicit ID and are not included by the default `all` +filter. + +| ID | From | To | Coverage | +|---|---:|---:|---| +| F2P-18 | `3243,3237,0` | `3199,3344,0` | Ten explicit item-gated bank-to-target comparisons plus three River Lum canoe repetitions and five or more `CANOE` planner selections | +| F2P-19 | `3029,3217,0` | `2956,3146,0` | Two Port Sarim-to-Musa Point repetitions with three or more fare-gated `TERMINAL_TRAVEL` planner selections | +| F2P-20 | `3029,3217,0` | `2946,3368,0` | Long surface walk with twelve deliberately injected and settled `ACTIVE_REPLAN` comparisons | +| F2P-21 | `3029,3217,0` | `2957,3214,0` | Five alternating surface walks with two walker-thread `RECOVERY_REPLAN` comparisons each and five recovered arrivals | diff --git a/docs/decisions/adr-0005-walker-transport-execution-boundary.md b/docs/decisions/adr-0005-walker-transport-execution-boundary.md new file mode 100644 index 00000000000..4b5847c73ec --- /dev/null +++ b/docs/decisions/adr-0005-walker-transport-execution-boundary.md @@ -0,0 +1,58 @@ +# ADR 0005: Separate Transport Description from Execution Capability + +- Status: Accepted (2026-08-05) + +## Context +Shortest Path transport data describes where an edge goes and what it requires, while Microbot also +needs to execute the edge against a live game client. Those responsibilities cannot be treated as the +same contract: imported data can describe a route that Microbot does not yet know how to operate, and +some local transports carry behavior that a flattened data value cannot preserve. In particular, +`PohTransport` owns an executable POH action and seasonal handlers are a pluggable runtime API. + +Converting every interaction handler directly from the local `Transport` class to the immutable +`Rs2TransportEdge` value would remove that behavior or recreate it through type switches and legacy +adapters. Retaining an unrestricted concrete planner object as the public route contract would instead +prevent a future upstream planner adapter. + +## Decision +Represent every selected route transport with: + +- an immutable, planner-independent `Rs2TransportEdge` description; +- an explicit `Rs2TransportExecutor` capability naming the Microbot runtime branch that owns it; and +- for terminal `SHIP`, `NPC` and `BOAT` travel, an explicit planner-independent interaction mode; +- only for the local engine, an opaque package-private payload containing the exact selected local + transport needed by the existing handlers. + +The pure planner-side `TransportExecutionRegistry` is authoritative for whether a local catalog row is +executable. Unregistered rows fail closed during transport refresh and cannot be selected merely because +their data was imported. Runtime dispatch also rejects `UNSUPPORTED` as a defensive invariant. The local +payload is never recovered by matching endpoints or rescanning the mutable catalog. + +Terminal catalog families describe a journey rather than the live target kind: rows may point at an NPC +or a scene object. The runtime resolves that kind from the configured semantic name/action near the exact +selected origin. The registry still owns the interaction sequence. Direct travel and a known dialogue +destination flow are separate modes; rows needing an unimplemented destination selection remain +unregistered even when another row in the same catalog family is executable. + +## Consequences +- Upstream catalog convergence cannot silently create routes that stall at an unimplemented interaction. +- An upstream planner adapter can emit the same immutable edge and executor capability without exposing + its model classes. +- POH and other behavior-bearing transports remain correct while their handlers are gradually moved + behind Microbot-owned execution interfaces. +- The concrete local payload remains an internal implementation detail rather than a migration target by + itself; replacing it requires an equivalent executable capability, not a blanket signature rewrite. +- Missing executor families become explicit delivery work. At adoption, the non-Lumbridge home teleports + and hot-air-balloon network were deliberately fail-closed; the shared exact-name home-teleport executor + subsequently closed the former gap. The balloon network now has an exact six-destination map executor + and observed-landing contract; its static, dual-engine and locked-account fail-closed evidence is complete, + while a successful flight remains pending on an account with an unlocked station. +- Terminal-travel coverage cannot be inferred from `TransportType`. The current audit deliberately keeps + 41 multi-step rows fail-closed: 30 `Board` rows for the multi-destination Boat/Boaty networks, six + destination-selecting Rowboat rows and five unimplemented `Talk-to` rows. Their exact interaction + groups are pinned by the catalog capability test. +- Inventory-item interactions are capabilities too. The Barrows dig executor is registered only for the + six reviewed mound-to-individual-crypt pairs with an exact one-spade requirement; arbitrary object-less + `Dig` rows remain unregistered. Individual-crypt stairs use ordinary object execution and a representative + surface-mound anchor because the live exit spawn can vary. Randomized sarcophagus-to-tunnel entry remains + outside the static catalog until the executor can consume observed run state. diff --git a/docs/decisions/adr-0006-upstream-first-planner-convergence.md b/docs/decisions/adr-0006-upstream-first-planner-convergence.md new file mode 100644 index 00000000000..dd817a43206 --- /dev/null +++ b/docs/decisions/adr-0006-upstream-first-planner-convergence.md @@ -0,0 +1,103 @@ +# ADR 0006: Establish an Upstream-Compatible Planner Boundary and Selection Gate + +- Status: Architecture and F2P evidence gates accepted (2026-08-05); + default/release selection pending explicit approval + +## Context + +Microbot needs Shortest Path's current collision, transport semantics and planner improvements, but it also +owns live interaction, recovery, banking and automation policy that the display plugin does not. Selectively +copying transport families improved correctness, yet continuing that work before proving an interchangeable +planner would preserve a second search implementation indefinitely behind a facade that still read mutable +local configuration. + +The reviewed upstream planner also does not retain the exact selected transport in its public `PathStep`. +Endpoint rematching is ambiguous when multiple transports share an origin and destination, so production +execution cannot safely adopt it without exact edge identity. + +## Decision + +Make production-capable upstream planner convergence the next walker milestone: + +- freeze opportunistic broad transport-family copying after the reviewed ship slice; continue parity work + required by the adapter or selection gate, incident-driven data fixes and pinned collision updates; +- dispatch planning through Microbot-owned `Rs2RouteRequest`, immutable `Rs2RoutePolicy`, `Rs2RoutePlanner` + and `Rs2RouteResult` contracts; +- resolve mutable client/config state before engine dispatch; an engine may not read Microbot plugin globals; +- inject Microbot executor capability as catalog-admission policy outside the pathfinder core; +- retain exact selected source identity only as an opaque package-private adapter payload; +- run the reviewed upstream engine in shadow mode against real requests before selecting it for execution; +- accept or reject the upstream core against a pinned commit, expanded corpus, runtime evidence and explicit + performance report. + +Planner selection is an explicit three-state policy, not two interacting booleans: + +- `LOCAL` selects only the local planner and remains the default; +- `SHADOW` executes the local route and compares the upstream result asynchronously; +- `UPSTREAM_F2P_CANARY` calculates both candidates for non-members policy, publishes no executable route + until comparison is complete, selects upstream only for a semantic match, and otherwise retains local. + +The local result is a containment fallback, not an absolute correctness oracle. A semantic divergence is +therefore observable and rejecting evidence even though the canary conservatively executes the local route. +The active route remains calculating until one final candidate is selected atomically. Upstream results are +temporarily materialized into the legacy `Pathfinder` view so existing execution and overlay consumers retain +exact selected transport identity; that compatibility shell must be removed with the local planner after the +fallback sunset. + +## Consequences + +- `LOCAL` remains the production default until the final reachable revision passes the evidence gates and a + release explicitly changes the scoped policy. +- New local search algorithms require a pinned incident or benchmark and must preserve the engine boundary. +- Upstream schema/data work continues where required for the adapter, but semantic-debt reduction alone is no + longer the primary walker milestone. +- The production package now contains the reviewed upstream core in an isolated source set. Its exact-identity, + live-collision and walking-cost hooks remain a small declared adapter patch surface, enforced by an offline + tree digest and optional byte-for-byte comparison with the pinned checkout. The reviewed budget is six + patched upstream files and one adapter-added file; growth requires an amendment to this ADR explaining why + the hook cannot remain outside the core or be contributed upstream. +- Shadow mode is default-off and cannot select a route for execution. Synchronous queries, ordinary active + walker routes and cave-route selection publish bounded comparison evidence while the local core remains + authoritative. +- Walker execution, recovery, live collision capture and automation policy remain Microbot-owned whichever + planner is selected. +- The production switch is not implied by this ADR. The repeatable headless thresholds and live-shadow + requirements are defined in `docs/walker-planner-selection-gate.md`; until they pass, the accepted decision + is to preserve the upstream-compatible boundary while retaining the local core as authoritative. +- Upstream is the maintenance-preferred candidate, not a predetermined production winner. The selection + decision weighs correctness, runtime reliability, performance and maintenance cost through the same + engine-neutral contract. +- Passing the selection gate permits a controlled rollout; it does not remove the local core. The first + upstream-selecting release is a match-gated canary limited to F2P policy because that is the scope of the + accepted live evidence. It is not yet an upstream-authoritative release: an otherwise valid upstream route + with a different cost or selected transport still falls back to local. Expanding selection to members policy + requires a separate representative live-shadow slice for + members-only executor, requirement and network behavior. Every upstream-authoritative scope must retain a + tested, release-independent local-planner fallback and dual-planner telemetry during staged rollout. Any + confirmed upstream planner failure or unexplained semantic divergence on a request selected for execution + triggers rollback to the local planner. +- A later upstream-authoritative mode must not turn the local result into a permanent correctness oracle or + accept arbitrary differences. Before that mode exists, Microbot must define an engine-independent route + validity check and a digest-pinned reviewed-divergence policy. Only a route that satisfies the resolved + request and immutable planning snapshot, materializes exact executable transport identities and either + matches or has a reviewed divergence may be selected. Failure, invalid materialization or an unclassified + divergence retains local and trips the rollout rollback signal. Merely adding such a mode does not authorize + enabling it or changing the default. +- The fallback has an explicit sunset instead of becoming permanent architecture. The local planner becomes + eligible for removal only after every supported rollout scope has used upstream authoritatively for at + least two completed releases and has accumulated at least 1,000 settled production dual-planner + comparisons with no unresolved semantic divergence or planner failure. The rollback path must also have a + passing release test and there must be no open incident that requires the local core. At that point the next + planned release removes the local planner; extending it requires a dated decision record naming the incident + and a new expiry condition. +- The opt-in F2P canary and rollback behavior were validated live on 2026-08-05 using the underground + Varrock Sewers object-transition route. The normal run made ten upstream selections and ten arrivals with + no divergence or failure. A separate test-only injected-failure run made zero upstream selections, recorded + ten local failure fallbacks and still produced ten arrivals. This validates the selector and independent + fallback mechanism; it does not by itself authorize changing the default or cutting a release. +- Canary evidence also records the coordinate-free duration from route submission through both searches, + semantic comparison, selection/fallback and route materialization. The paired release evaluator requires + one timing sample per completed comparison, a maximum readiness time of 2,000 ms and an aggregate + non-search overhead average no greater than 250 ms per decision. This measures the user-visible dual-running + cost that the independent engine benchmark cannot prove without making a fixed-cost short route fail a + noisy ratio gate. diff --git a/docs/evidence/walker/2026-08-05/README.md b/docs/evidence/walker/2026-08-05/README.md new file mode 100644 index 00000000000..5b8b159cb33 --- /dev/null +++ b/docs/evidence/walker/2026-08-05/README.md @@ -0,0 +1,53 @@ +# Walker planner evidence — 2026-08-05 + +This directory makes the accepted F2P upstream-planner decision evidence reviewable from the repository. +All live inputs are deliberately coordinate-free and omit client logs, screenshots, account/profile data and +session credentials. + +The evidence covers the pinned upstream core at +`ff8e961b32120175709df9630ece9468cc11347f`. The performance report identifies the evaluated Microbot +revision as `3d05797e7f768dedcb56762a8fafd7896c9560cd`; later changes that add only this evidence directory do +not alter the evaluated planner code. + +## Reproduce the live-shadow aggregate + +```bash +scripts/evaluate-walker-shadow-evidence.py \ + docs/evidence/walker/2026-08-05/shadow-input-*.json \ + --json-output /tmp/walker-shadow-evidence.json \ + --markdown-output /tmp/walker-shadow-evidence.md + +cmp /tmp/walker-shadow-evidence.json \ + docs/evidence/walker/2026-08-05/shadow-evidence.json +``` + +The twelve fresh-client inputs reproduce the accepted 141-comparison, 71-arrival aggregate byte-for-byte. + +## Reproduce the selection and rollback aggregate + +```bash +scripts/evaluate-walker-rollout-evidence.py \ + docs/evidence/walker/2026-08-05/rollout-normal-input.json \ + docs/evidence/walker/2026-08-05/rollout-rollback-input.json \ + --json-output /tmp/walker-rollout-evidence.json \ + --markdown-output /tmp/walker-rollout-evidence.md + +cmp /tmp/walker-rollout-evidence.json \ + docs/evidence/walker/2026-08-05/rollout-evidence.json +``` + +The two inputs are minimal projections of the harness results containing every field consumed by the paired +evaluator. They reproduce its accepted report byte-for-byte while excluding route coordinates and verbose +runtime logs. + +## Performance evidence + +`performance-evidence.json` is the accepted five-sample aggregate for the exact evaluated planner revision. +The underlying headless reports can be regenerated with the protocol in +`docs/walker-planner-selection-gate.md`; they are not live-account artifacts. + +## Scope + +These artifacts justify an explicitly approved, match-gated F2P canary. They do not justify members-policy +selection, a permissive upstream-authoritative mode, or changing the default from `LOCAL` without a separate +release decision. diff --git a/docs/evidence/walker/2026-08-05/performance-evidence.json b/docs/evidence/walker/2026-08-05/performance-evidence.json new file mode 100644 index 00000000000..e3d3782eaa8 --- /dev/null +++ b/docs/evidence/walker/2026-08-05/performance-evidence.json @@ -0,0 +1,296 @@ +{ + "cases": [ + { + "absoluteBudgetMillis": 2000.0, + "category": "overland", + "failures": [], + "id": "lumbridge-short-overland", + "localMaxMillis": 99.019434, + "localMedianMillis": 76.185268, + "localMedianNodes": 285.0, + "localMedianPeakHeapDeltaBytes": 27551232.0, + "relativeOrNoiseBudgetMillis": 297.058302, + "sampleCount": 5, + "status": "PASS", + "upstreamMaxMillis": 21.899429, + "upstreamMedianMillis": 12.382486, + "upstreamMedianNodes": 271.0, + "upstreamMedianPeakHeapDeltaBytes": 0.0, + "upstreamToLocalMedianRatio": 0.16253123897916855, + "upstreamToLocalNodeRatio": 0.9508771929824561 + }, + { + "absoluteBudgetMillis": 2000.0, + "category": "overland", + "failures": [], + "id": "lumbridge-grand-exchange-overland", + "localMaxMillis": 373.403736, + "localMedianMillis": 350.240528, + "localMedianNodes": 84538.0, + "localMedianPeakHeapDeltaBytes": 52222464.0, + "relativeOrNoiseBudgetMillis": 1120.211208, + "sampleCount": 5, + "status": "PASS", + "upstreamMaxMillis": 185.329399, + "upstreamMedianMillis": 154.315538, + "upstreamMedianNodes": 90120.0, + "upstreamMedianPeakHeapDeltaBytes": 8388608.0, + "upstreamToLocalMedianRatio": 0.4405987476126692, + "upstreamToLocalNodeRatio": 1.066029477867941 + }, + { + "absoluteBudgetMillis": 2000.0, + "category": "network", + "failures": [], + "id": "ambiguous-network-transport", + "localMaxMillis": 4.292129, + "localMedianMillis": 3.524774, + "localMedianNodes": 121.0, + "localMedianPeakHeapDeltaBytes": 25165824.0, + "relativeOrNoiseBudgetMillis": 100.0, + "sampleCount": 5, + "status": "PASS", + "upstreamMaxMillis": 3.598972, + "upstreamMedianMillis": 2.068314, + "upstreamMedianNodes": 122.0, + "upstreamMedianPeakHeapDeltaBytes": 0.0, + "upstreamToLocalMedianRatio": 0.5867933660427591, + "upstreamToLocalNodeRatio": 1.0082644628099173 + }, + { + "absoluteBudgetMillis": 2000.0, + "category": "bank", + "failures": [], + "id": "bank-required-network-without-bank", + "localMaxMillis": 286.813015, + "localMedianMillis": 257.87394, + "localMedianNodes": 84234.0, + "localMedianPeakHeapDeltaBytes": 21422880.0, + "relativeOrNoiseBudgetMillis": 860.4390450000001, + "sampleCount": 5, + "status": "PASS", + "upstreamMaxMillis": 207.017926, + "upstreamMedianMillis": 96.371651, + "upstreamMedianNodes": 90120.0, + "upstreamMedianPeakHeapDeltaBytes": 8388608.0, + "upstreamToLocalMedianRatio": 0.37371613044730306, + "upstreamToLocalNodeRatio": 1.0698767718498468 + }, + { + "absoluteBudgetMillis": 2000.0, + "category": "network-executor", + "failures": [], + "id": "hot-air-balloon-network", + "localMaxMillis": 2.808077, + "localMedianMillis": 2.253269, + "localMedianNodes": 91.0, + "localMedianPeakHeapDeltaBytes": 20971520.0, + "relativeOrNoiseBudgetMillis": 100.0, + "sampleCount": 5, + "status": "PASS", + "upstreamMaxMillis": 2.219177, + "upstreamMedianMillis": 0.701498, + "upstreamMedianNodes": 92.0, + "upstreamMedianPeakHeapDeltaBytes": 0.0, + "upstreamToLocalMedianRatio": 0.3113245688819222, + "upstreamToLocalNodeRatio": 1.010989010989011 + }, + { + "absoluteBudgetMillis": 2000.0, + "category": "spell-provider", + "failures": [], + "id": "spell-with-raw-runes", + "localMaxMillis": 2.305256, + "localMedianMillis": 1.713581, + "localMedianNodes": 89.0, + "localMedianPeakHeapDeltaBytes": 25165824.0, + "relativeOrNoiseBudgetMillis": 100.0, + "sampleCount": 5, + "status": "PASS", + "upstreamMaxMillis": 1.351633, + "upstreamMedianMillis": 0.220607, + "upstreamMedianNodes": 90.0, + "upstreamMedianPeakHeapDeltaBytes": 0.0, + "upstreamToLocalMedianRatio": 0.12874033967463458, + "upstreamToLocalNodeRatio": 1.0112359550561798 + }, + { + "absoluteBudgetMillis": 2000.0, + "category": "spell-provider", + "failures": [], + "id": "spell-with-staff-and-tome", + "localMaxMillis": 3.493296, + "localMedianMillis": 2.703174, + "localMedianNodes": 89.0, + "localMedianPeakHeapDeltaBytes": 25165824.0, + "relativeOrNoiseBudgetMillis": 100.0, + "sampleCount": 5, + "status": "PASS", + "upstreamMaxMillis": 0.195671, + "upstreamMedianMillis": 0.141178, + "upstreamMedianNodes": 90.0, + "upstreamMedianPeakHeapDeltaBytes": 0.0, + "upstreamToLocalMedianRatio": 0.05222675269886437, + "upstreamToLocalNodeRatio": 1.0112359550561798 + }, + { + "absoluteBudgetMillis": 2000.0, + "category": "spell-provider", + "failures": [], + "id": "spell-missing-ordinary-item", + "localMaxMillis": 177.675785, + "localMedianMillis": 153.985545, + "localMedianNodes": 84503.0, + "localMedianPeakHeapDeltaBytes": 67553328.0, + "relativeOrNoiseBudgetMillis": 533.027355, + "sampleCount": 5, + "status": "PASS", + "upstreamMaxMillis": 63.327724, + "upstreamMedianMillis": 45.509493, + "upstreamMedianNodes": 90120.0, + "upstreamMedianPeakHeapDeltaBytes": 8388608.0, + "upstreamToLocalMedianRatio": 0.29554392913958255, + "upstreamToLocalNodeRatio": 1.0664710128634487 + }, + { + "absoluteBudgetMillis": 2000.0, + "category": "underground", + "failures": [], + "id": "white-wolf-tunnel-underground", + "localMaxMillis": 2.889083, + "localMedianMillis": 2.030352, + "localMedianNodes": 432.0, + "localMedianPeakHeapDeltaBytes": 20971520.0, + "relativeOrNoiseBudgetMillis": 100.0, + "sampleCount": 5, + "status": "PASS", + "upstreamMaxMillis": 0.42612, + "upstreamMedianMillis": 0.376064, + "upstreamMedianNodes": 431.0, + "upstreamMedianPeakHeapDeltaBytes": 0.0, + "upstreamToLocalMedianRatio": 0.18522108481681993, + "upstreamToLocalNodeRatio": 0.9976851851851852 + }, + { + "absoluteBudgetMillis": 2000.0, + "category": "surface-underground-surface", + "failures": [], + "id": "white-wolf-surface-tunnel-surface", + "localMaxMillis": 13.271966, + "localMedianMillis": 11.996352, + "localMedianNodes": 5133.0, + "localMedianPeakHeapDeltaBytes": 25165824.0, + "relativeOrNoiseBudgetMillis": 100.0, + "sampleCount": 5, + "status": "PASS", + "upstreamMaxMillis": 17.723235, + "upstreamMedianMillis": 10.202539, + "upstreamMedianNodes": 5365.0, + "upstreamMedianPeakHeapDeltaBytes": 0.0, + "upstreamToLocalMedianRatio": 0.8504701262517138, + "upstreamToLocalNodeRatio": 1.0451977401129944 + }, + { + "absoluteBudgetMillis": 2000.0, + "category": "unreachable", + "failures": [], + "id": "tempoross-cove-unreachable", + "localMaxMillis": 524.264707, + "localMedianMillis": 487.072839, + "localMedianNodes": 272634.0, + "localMedianPeakHeapDeltaBytes": 184549376.0, + "relativeOrNoiseBudgetMillis": 1572.7941210000001, + "sampleCount": 5, + "status": "PASS", + "upstreamMaxMillis": 203.796131, + "upstreamMedianMillis": 189.934025, + "upstreamMedianNodes": 273098.0, + "upstreamMedianPeakHeapDeltaBytes": 16777216.0, + "upstreamToLocalMedianRatio": 0.38994994134747885, + "upstreamToLocalNodeRatio": 1.0017019153883961 + }, + { + "absoluteBudgetMillis": 2000.0, + "category": "wilderness", + "failures": [], + "id": "wilderness-interior", + "localMaxMillis": 16.32895, + "localMedianMillis": 14.470686, + "localMedianNodes": 6603.0, + "localMedianPeakHeapDeltaBytes": 25165824.0, + "relativeOrNoiseBudgetMillis": 100.0, + "sampleCount": 5, + "status": "PASS", + "upstreamMaxMillis": 15.642663, + "upstreamMedianMillis": 13.024141, + "upstreamMedianNodes": 6654.0, + "upstreamMedianPeakHeapDeltaBytes": 0.0, + "upstreamToLocalMedianRatio": 0.9000361834953782, + "upstreamToLocalNodeRatio": 1.007723761926397 + } + ], + "comparability": { + "excludedCases": [ + { + "id": "bank-required-network-from-bank", + "reason": "bank-aware workflow shapes differ: Microbot composes searches while the reviewed upstream searches bank state in one pass" + }, + { + "id": "bank-required-network-via-detour", + "reason": "bank-aware workflow shapes differ: Microbot composes searches while the reviewed upstream searches bank state in one pass" + }, + { + "id": "spell-with-banked-raw-runes", + "reason": "bank-aware workflow shapes differ: Microbot composes searches while the reviewed upstream searches bank state in one pass" + }, + { + "id": "spell-with-combination-staff", + "reason": "documented input-policy divergence" + } + ], + "includedCaseIds": [ + "lumbridge-short-overland", + "lumbridge-grand-exchange-overland", + "ambiguous-network-transport", + "bank-required-network-without-bank", + "hot-air-balloon-network", + "spell-with-raw-runes", + "spell-with-staff-and-tome", + "spell-missing-ordinary-item", + "white-wolf-tunnel-underground", + "white-wolf-surface-tunnel-surface", + "tempoross-cove-unreachable", + "wilderness-interior" + ] + }, + "evidenceShortfalls": [], + "failures": [], + "identity": { + "corpusSha256": "ddbfead28f7e2a4d602ce19dda6273a37c9eccc9392ff7b9e2cf31a58166060d", + "embeddedUpstreamRevision": "ff8e961b32120175709df9630ece9468cc11347f", + "localRevision": "3d05797e7f768dedcb56762a8fafd7896c9560cd", + "runeliteVersion": "1.12.34.1", + "schemaVersion": 3, + "upstreamIdentityPatchSha256": "db9639cd476cae166a9c7212c9c0aaf81c0114658a2ae6bc3ba37dd181906d7f", + "upstreamRevision": "ff8e961b32120175709df9630ece9468cc11347f" + }, + "minimumSamples": 5, + "performanceFailures": [], + "sampleCount": 5, + "schemaVersion": 1, + "suite": { + "localMedianMillis": 1365.252475, + "upstreamMedianMillis": 555.170459, + "upstreamToLocalMedianRatio": 0.40664307090891744 + }, + "thresholds": { + "caseRatioSlackMillis": 100.0, + "maximumCaseUpstreamMaxMillis": 2000.0, + "maximumCaseUpstreamToLocalMaxRatio": 3.0, + "maximumSuiteUpstreamToLocalMedianRatio": 1.5 + }, + "verdict": "ACCEPTED", + "warnings": [ + "Node expansion and peak-heap deltas are diagnostic only; the engines use different data structures and heap baselines." + ] +} diff --git a/docs/evidence/walker/2026-08-05/rollout-evidence.json b/docs/evidence/walker/2026-08-05/rollout-evidence.json new file mode 100644 index 00000000000..188e18ed75c --- /dev/null +++ b/docs/evidence/walker/2026-08-05/rollout-evidence.json @@ -0,0 +1,166 @@ +{ + "candidateEngineId": "shortest-path-upstream@ff8e961b32120175709df9630ece9468cc11347f", + "evidenceShortfalls": [], + "failures": [], + "maximumCanaryNonSearchOverheadMillis": 250.0, + "maximumCanaryPlanningMillis": 2000.0, + "minimumArrivalsPerPhase": 10, + "minimumComparisonsPerPhase": 10, + "normal": { + "canaryPerformance": { + "localSearchNanosMax": 195612938, + "localSearchNanosTotal": 1306019743, + "nonSearchOverheadAverageMs": 172.0033795, + "planningAverageMs": 304.3032568, + "planningLocalRatio": 2.330005028109288, + "planningMaxMs": 645.703232, + "planningNanosMax": 645703232, + "planningNanosTotal": 3043032568, + "planningSamples": 10, + "upstreamSearchNanosMax": 3398986, + "upstreamSearchNanosTotal": 16979030, + "upstreamSearchSamples": 10 + }, + "coverage": { + "ACTIVE_ROUTE": { + "completed": 10, + "divergences": 0, + "failures": 0, + "matches": 10 + }, + "UNDERGROUND_COORDINATES": { + "completed": 10, + "divergences": 0, + "failures": 0, + "matches": 10 + } + }, + "execution": { + "arrived": 10, + "exited": 0, + "recoveryArrived": 0, + "recoveryExited": 0, + "recoveryTerminal": 0, + "recoveryUnreachable": 0, + "terminal": 10, + "unreachable": 0 + }, + "routes": [ + { + "id": "F2P-17", + "repetitions": 5, + "status": "PASS", + "successfulAttempts": 5 + } + ], + "selectedRoutes": [ + "F2P-17" + ], + "startedAtEpochMillis": 1785954308178, + "status": "PASS", + "totals": { + "completed": 10, + "discarded": 0, + "divergences": 0, + "failures": 0, + "localFallbackDivergences": 0, + "localFallbackFailures": 0, + "matches": 10, + "pending": 0, + "routeShapeDifferences": 0, + "staleResults": 0, + "submitted": 10, + "upstreamCanarySelections": 10 + }, + "transportExecutors": { + "OBJECT": { + "completed": 10, + "divergences": 0, + "failures": 0, + "matches": 10 + } + } + }, + "requiredRoutes": [ + "F2P-17" + ], + "reviewedCommit": "ff8e961b32120175709df9630ece9468cc11347f", + "rollback": { + "canaryPerformance": { + "localSearchNanosMax": 300513979, + "localSearchNanosTotal": 1373533694, + "nonSearchOverheadAverageMs": 123.42671, + "planningAverageMs": 260.78007940000003, + "planningLocalRatio": 1.8986070785097173, + "planningMaxMs": 675.124258, + "planningNanosMax": 675124258, + "planningNanosTotal": 2607800794, + "planningSamples": 10, + "upstreamSearchNanosMax": 0, + "upstreamSearchNanosTotal": 0, + "upstreamSearchSamples": 0 + }, + "coverage": { + "ACTIVE_ROUTE": { + "completed": 10, + "divergences": 0, + "failures": 10, + "matches": 0 + }, + "UNDERGROUND_COORDINATES": { + "completed": 10, + "divergences": 0, + "failures": 10, + "matches": 0 + } + }, + "execution": { + "arrived": 10, + "exited": 0, + "recoveryArrived": 0, + "recoveryExited": 0, + "recoveryTerminal": 0, + "recoveryUnreachable": 0, + "terminal": 10, + "unreachable": 0 + }, + "routes": [ + { + "id": "F2P-17", + "repetitions": 5, + "status": "PASS", + "successfulAttempts": 5 + } + ], + "selectedRoutes": [ + "F2P-17" + ], + "startedAtEpochMillis": 1785954482113, + "status": "PASS", + "totals": { + "completed": 10, + "discarded": 0, + "divergences": 0, + "failures": 10, + "localFallbackDivergences": 0, + "localFallbackFailures": 10, + "matches": 0, + "pending": 0, + "routeShapeDifferences": 0, + "staleResults": 0, + "submitted": 10, + "upstreamCanarySelections": 0 + }, + "transportExecutors": { + "OBJECT": { + "completed": 10, + "divergences": 0, + "failures": 10, + "matches": 0 + } + } + }, + "schemaVersion": 1, + "verdict": "ACCEPTED", + "warnings": [] +} diff --git a/docs/evidence/walker/2026-08-05/rollout-normal-input.json b/docs/evidence/walker/2026-08-05/rollout-normal-input.json new file mode 100644 index 00000000000..0118998513b --- /dev/null +++ b/docs/evidence/walker/2026-08-05/rollout-normal-input.json @@ -0,0 +1,466 @@ +{ + "script": "F2P Web Walker Harness", + "exitCode": 0, + "exitReason": "completed", + "errors": [], + "plannerMode": "UPSTREAM_F2P_CANARY", + "expectLocalFallback": false, + "shadowSettled": true, + "checks": [ + { + "name": "F2P-17 setup", + "passed": true + }, + { + "name": "F2P-17 route", + "passed": true + }, + { + "name": "planner comparison and selection evidence", + "passed": true + } + ], + "selectedRoutes": [ + "F2P-17" + ], + "routes": [ + { + "id": "F2P-17", + "repetitions": 5, + "successfulAttempts": 5, + "passed": true, + "walkerState": "ARRIVED" + } + ], + "shadowEvidence": { + "schemaVersion": 2, + "enabled": true, + "plannerMode": "UPSTREAM_F2P_CANARY", + "candidateEngineId": "shortest-path-upstream@ff8e961b32120175709df9630ece9468cc11347f", + "startedAtEpochMillis": 1785954308178, + "totals": { + "submitted": 10, + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0, + "staleResults": 0, + "discarded": 0, + "pending": 0, + "routeShapeDifferences": 0, + "upstreamCanarySelections": 10, + "localFallbackDivergences": 0, + "localFallbackFailures": 0 + }, + "coverage": { + "SYNCHRONOUS_QUERY": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "ACTIVE_ROUTE": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + }, + "ACTIVE_REPLAN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "RECOVERY_REPLAN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SURFACE_COORDINATES_ONLY": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNDERGROUND_COORDINATES": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + }, + "WALKING_ONLY_SELECTED": { + "completed": 5, + "matches": 5, + "divergences": 0, + "failures": 0 + }, + "USES_TRANSPORT": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + }, + "SELECTS_ITEM_GATED_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ITEMS_ENABLED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_DIRECT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_TO_BANK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_FROM_BANK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_FROM_BANK_SELECTS_ITEM_GATED_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "LIVE_COLLISION_ENABLED": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + }, + "LIVE_COLLISION_CONSULTED": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + } + }, + "transportExecutors": { + "BARROWS_DIG": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CANOE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CHARTER_SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "FAIRY_RING": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GNOME_GLIDER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "HOT_AIR_BALLOON": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "ITEM_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_CARPET": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_MUSHTREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MINIGAME_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "OBJECT": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + }, + "POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SEASONAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPELL_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPIRIT_TREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TERMINAL_TRAVEL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WILDERNESS_OBELISK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNSUPPORTED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + } + }, + "transportTypes": { + "TRANSPORT": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + }, + "AGILITY_SHORTCUT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GRAPPLE_SHORTCUT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BOAT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CANOE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CHARTER_SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "FAIRY_RING": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL_WHISTLE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GNOME_GLIDER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MINECART": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPIRIT_TREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_BOX": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_LEVER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_PORTAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_PORTAL_POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_MINIGAME": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_ITEM": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_SPELL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_SPELL_HOME": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WILDERNESS_OBELISK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_CARPET": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "HOT_AIR_BALLOON": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_MUSHTREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SEASONAL_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "NPC": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNKNOWN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + } + }, + "execution": { + "terminal": 10, + "arrived": 10, + "unreachable": 0, + "exited": 0, + "recoveryTerminal": 0, + "recoveryArrived": 0, + "recoveryUnreachable": 0, + "recoveryExited": 0 + }, + "canaryPerformance": { + "planningSamples": 10, + "planningNanosTotal": 3043032568, + "planningNanosMax": 645703232, + "localSearchNanosTotal": 1306019743, + "localSearchNanosMax": 195612938, + "upstreamSearchSamples": 10, + "upstreamSearchNanosTotal": 16979030, + "upstreamSearchNanosMax": 3398986 + } + } +} diff --git a/docs/evidence/walker/2026-08-05/rollout-rollback-input.json b/docs/evidence/walker/2026-08-05/rollout-rollback-input.json new file mode 100644 index 00000000000..34a3f2fd8ed --- /dev/null +++ b/docs/evidence/walker/2026-08-05/rollout-rollback-input.json @@ -0,0 +1,493 @@ +{ + "script": "F2P Web Walker Harness", + "exitCode": 0, + "exitReason": "completed", + "errors": [], + "plannerMode": "UPSTREAM_F2P_CANARY", + "expectLocalFallback": true, + "shadowSettled": true, + "checks": [ + { + "name": "F2P-17 setup", + "passed": true + }, + { + "name": "F2P-17 route", + "passed": true + }, + { + "name": "planner comparison and selection evidence", + "passed": true + } + ], + "selectedRoutes": [ + "F2P-17" + ], + "routes": [ + { + "id": "F2P-17", + "repetitions": 5, + "successfulAttempts": 5, + "passed": true, + "walkerState": "ARRIVED" + } + ], + "shadowEvidence": { + "schemaVersion": 2, + "enabled": true, + "plannerMode": "UPSTREAM_F2P_CANARY", + "candidateEngineId": "shortest-path-upstream@ff8e961b32120175709df9630ece9468cc11347f", + "startedAtEpochMillis": 1785954482113, + "totals": { + "submitted": 10, + "completed": 10, + "matches": 0, + "divergences": 0, + "failures": 10, + "staleResults": 0, + "discarded": 0, + "pending": 0, + "routeShapeDifferences": 0, + "upstreamCanarySelections": 0, + "localFallbackDivergences": 0, + "localFallbackFailures": 10 + }, + "coverage": { + "SYNCHRONOUS_QUERY": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "ACTIVE_ROUTE": { + "completed": 10, + "matches": 0, + "divergences": 0, + "failures": 10 + }, + "ACTIVE_REPLAN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "RECOVERY_REPLAN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SURFACE_COORDINATES_ONLY": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNDERGROUND_COORDINATES": { + "completed": 10, + "matches": 0, + "divergences": 0, + "failures": 10 + }, + "WALKING_ONLY_SELECTED": { + "completed": 5, + "matches": 0, + "divergences": 0, + "failures": 5 + }, + "USES_TRANSPORT": { + "completed": 10, + "matches": 0, + "divergences": 0, + "failures": 10 + }, + "SELECTS_ITEM_GATED_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ITEMS_ENABLED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_DIRECT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_TO_BANK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_FROM_BANK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_FROM_BANK_SELECTS_ITEM_GATED_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "LIVE_COLLISION_ENABLED": { + "completed": 10, + "matches": 0, + "divergences": 0, + "failures": 10 + }, + "LIVE_COLLISION_CONSULTED": { + "completed": 10, + "matches": 0, + "divergences": 0, + "failures": 10 + } + }, + "transportExecutors": { + "BARROWS_DIG": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CANOE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CHARTER_SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "FAIRY_RING": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GNOME_GLIDER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "HOT_AIR_BALLOON": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "ITEM_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_CARPET": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_MUSHTREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MINIGAME_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "OBJECT": { + "completed": 10, + "matches": 0, + "divergences": 0, + "failures": 10 + }, + "POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SEASONAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPELL_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPIRIT_TREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TERMINAL_TRAVEL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WILDERNESS_OBELISK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNSUPPORTED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + } + }, + "transportTypes": { + "TRANSPORT": { + "completed": 10, + "matches": 0, + "divergences": 0, + "failures": 10 + }, + "AGILITY_SHORTCUT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GRAPPLE_SHORTCUT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BOAT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CANOE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CHARTER_SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "FAIRY_RING": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL_WHISTLE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GNOME_GLIDER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MINECART": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPIRIT_TREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_BOX": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_LEVER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_PORTAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_PORTAL_POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_MINIGAME": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_ITEM": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_SPELL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_SPELL_HOME": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WILDERNESS_OBELISK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_CARPET": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "HOT_AIR_BALLOON": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_MUSHTREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SEASONAL_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "NPC": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNKNOWN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + } + }, + "execution": { + "terminal": 10, + "arrived": 10, + "unreachable": 0, + "exited": 0, + "recoveryTerminal": 0, + "recoveryArrived": 0, + "recoveryUnreachable": 0, + "recoveryExited": 0 + }, + "canaryPerformance": { + "planningSamples": 10, + "planningNanosTotal": 2607800794, + "planningNanosMax": 675124258, + "localSearchNanosTotal": 1373533694, + "localSearchNanosMax": 300513979, + "upstreamSearchSamples": 0, + "upstreamSearchNanosTotal": 0, + "upstreamSearchNanosMax": 0 + }, + "latestFailure": { + "status": "FAILED", + "shadowEngineId": "shortest-path-upstream@ff8e961b32120175709df9630ece9468cc11347f", + "invocation": "ACTIVE_ROUTE", + "coverage": [ + "ACTIVE_ROUTE", + "UNDERGROUND_COORDINATES", + "USES_TRANSPORT", + "LIVE_COLLISION_ENABLED", + "LIVE_COLLISION_CONSULTED" + ], + "terminationMatches": false, + "endpointMatches": false, + "costComparable": false, + "costMatches": false, + "selectedTransportsMatch": false, + "pathMatches": false, + "transportExecutors": [ + "OBJECT" + ], + "transportTypes": [ + "TRANSPORT" + ], + "localSearchNanos": 92475298, + "shadowSearchNanos": -1, + "failureType": "IllegalStateException" + } + } +} diff --git a/docs/evidence/walker/2026-08-05/shadow-evidence.json b/docs/evidence/walker/2026-08-05/shadow-evidence.json new file mode 100644 index 00000000000..1ebb63a2c27 --- /dev/null +++ b/docs/evidence/walker/2026-08-05/shadow-evidence.json @@ -0,0 +1,340 @@ +{ + "candidateEngineId": "shortest-path-upstream@ff8e961b32120175709df9630ece9468cc11347f", + "coverage": [ + { + "completed": 75, + "divergences": 0, + "failures": 0, + "matches": 75, + "required": 75, + "status": "PASS", + "tag": "ACTIVE_ROUTE" + }, + { + "completed": 15, + "divergences": 0, + "failures": 0, + "matches": 15, + "required": 15, + "status": "PASS", + "tag": "ACTIVE_REPLAN" + }, + { + "completed": 11, + "divergences": 0, + "failures": 0, + "matches": 11, + "required": 10, + "status": "PASS", + "tag": "RECOVERY_REPLAN" + }, + { + "completed": 102, + "divergences": 0, + "failures": 0, + "matches": 102, + "required": 60, + "status": "PASS", + "tag": "SURFACE_COORDINATES_ONLY" + }, + { + "completed": 39, + "divergences": 0, + "failures": 0, + "matches": 39, + "required": 20, + "status": "PASS", + "tag": "UNDERGROUND_COORDINATES" + }, + { + "completed": 18, + "divergences": 0, + "failures": 0, + "matches": 18, + "required": 10, + "status": "PASS", + "tag": "WALKING_ONLY_SELECTED" + }, + { + "completed": 97, + "divergences": 0, + "failures": 0, + "matches": 97, + "required": 20, + "status": "PASS", + "tag": "USES_TRANSPORT" + }, + { + "completed": 59, + "divergences": 0, + "failures": 0, + "matches": 59, + "required": 5, + "status": "PASS", + "tag": "SELECTS_ITEM_GATED_TRANSPORT" + }, + { + "completed": 10, + "divergences": 0, + "failures": 0, + "matches": 10, + "required": 10, + "status": "PASS", + "tag": "BANK_ROUTE_FROM_BANK" + }, + { + "completed": 10, + "divergences": 0, + "failures": 0, + "matches": 10, + "required": 5, + "status": "PASS", + "tag": "BANK_ROUTE_FROM_BANK_SELECTS_ITEM_GATED_TRANSPORT" + }, + { + "completed": 137, + "divergences": 0, + "failures": 0, + "matches": 137, + "required": 25, + "status": "PASS", + "tag": "LIVE_COLLISION_CONSULTED" + } + ], + "evidenceProfile": "f2p", + "evidenceShortfalls": [], + "execution": { + "arrived": 71, + "exited": 0, + "recoveryArrived": 6, + "recoveryExited": 0, + "recoveryTerminal": 6, + "recoveryUnreachable": 0, + "terminal": 71, + "unreachable": 0 + }, + "failures": [], + "latestDivergence": null, + "latestFailure": null, + "latestRouteShapeDifference": { + "costComparable": true, + "costMatches": true, + "coverage": [ + "ACTIVE_ROUTE", + "SURFACE_COORDINATES_ONLY", + "LIVE_COLLISION_ENABLED", + "LIVE_COLLISION_CONSULTED" + ], + "endpointMatches": true, + "invocation": "ACTIVE_ROUTE", + "localSearchNanos": 457838171, + "pathMatches": false, + "selectedTransportsMatch": true, + "shadowEngineId": "shortest-path-upstream@ff8e961b32120175709df9630ece9468cc11347f", + "shadowSearchNanos": 289589123, + "status": "MATCH", + "terminationMatches": true, + "transportExecutors": [], + "transportTypes": [] + }, + "minimumCompleted": 100, + "minimumDistinctTransportExecutors": 4, + "minimumRecoveryArrivals": 5, + "minimumSessions": 1, + "minimumWalkerArrivals": 50, + "observedTransportExecutors": [ + "CANOE", + "OBJECT", + "SPELL_TELEPORT", + "TERMINAL_TRAVEL" + ], + "plannerMode": null, + "reviewedCommit": "ff8e961b32120175709df9630ece9468cc11347f", + "schemaVersion": 1, + "sessionCount": 12, + "startedAtEpochMillis": 1785934189237, + "totals": { + "completed": 141, + "discarded": 0, + "divergences": 0, + "failures": 0, + "matches": 141, + "pending": 0, + "routeShapeDifferences": 76, + "staleResults": 2, + "submitted": 141 + }, + "transportExecutorGroups": [ + { + "completed": 42, + "executors": [ + "OBJECT", + "BARROWS_DIG" + ], + "group": "LOCAL_TRANSITION", + "required": 5, + "status": "PASS" + }, + { + "completed": 5, + "executors": [ + "ITEM_TELEPORT", + "MINIGAME_TELEPORT", + "SPELL_TELEPORT", + "POH", + "SEASONAL" + ], + "group": "TELEPORT", + "required": 5, + "status": "PASS" + }, + { + "completed": 50, + "executors": [ + "CANOE", + "FAIRY_RING", + "GNOME_GLIDER", + "HOT_AIR_BALLOON", + "MAGIC_CARPET", + "MAGIC_MUSHTREE", + "QUETZAL", + "SPIRIT_TREE", + "WILDERNESS_OBELISK" + ], + "group": "NETWORK", + "required": 5, + "status": "PASS" + }, + { + "completed": 4, + "executors": [ + "CHARTER_SHIP", + "TERMINAL_TRAVEL" + ], + "group": "TERMINAL_TRAVEL", + "required": 3, + "status": "PASS" + } + ], + "transportExecutors": { + "BARROWS_DIG": { + "completed": 0, + "divergences": 0, + "failures": 0, + "matches": 0 + }, + "CANOE": { + "completed": 50, + "divergences": 0, + "failures": 0, + "matches": 50 + }, + "CHARTER_SHIP": { + "completed": 0, + "divergences": 0, + "failures": 0, + "matches": 0 + }, + "FAIRY_RING": { + "completed": 0, + "divergences": 0, + "failures": 0, + "matches": 0 + }, + "GNOME_GLIDER": { + "completed": 0, + "divergences": 0, + "failures": 0, + "matches": 0 + }, + "HOT_AIR_BALLOON": { + "completed": 0, + "divergences": 0, + "failures": 0, + "matches": 0 + }, + "ITEM_TELEPORT": { + "completed": 0, + "divergences": 0, + "failures": 0, + "matches": 0 + }, + "MAGIC_CARPET": { + "completed": 0, + "divergences": 0, + "failures": 0, + "matches": 0 + }, + "MAGIC_MUSHTREE": { + "completed": 0, + "divergences": 0, + "failures": 0, + "matches": 0 + }, + "MINIGAME_TELEPORT": { + "completed": 0, + "divergences": 0, + "failures": 0, + "matches": 0 + }, + "OBJECT": { + "completed": 42, + "divergences": 0, + "failures": 0, + "matches": 42 + }, + "POH": { + "completed": 0, + "divergences": 0, + "failures": 0, + "matches": 0 + }, + "QUETZAL": { + "completed": 0, + "divergences": 0, + "failures": 0, + "matches": 0 + }, + "SEASONAL": { + "completed": 0, + "divergences": 0, + "failures": 0, + "matches": 0 + }, + "SPELL_TELEPORT": { + "completed": 5, + "divergences": 0, + "failures": 0, + "matches": 5 + }, + "SPIRIT_TREE": { + "completed": 0, + "divergences": 0, + "failures": 0, + "matches": 0 + }, + "TERMINAL_TRAVEL": { + "completed": 4, + "divergences": 0, + "failures": 0, + "matches": 4 + }, + "UNSUPPORTED": { + "completed": 0, + "divergences": 0, + "failures": 0, + "matches": 0 + }, + "WILDERNESS_OBELISK": { + "completed": 0, + "divergences": 0, + "failures": 0, + "matches": 0 + } + }, + "verdict": "ACCEPTED", + "warnings": [ + "2 completed result(s) belonged to superseded route generations", + "76 completed comparison(s) used a different exact route shape" + ] +} diff --git a/docs/evidence/walker/2026-08-05/shadow-input-01-f2p-underground.json b/docs/evidence/walker/2026-08-05/shadow-input-01-f2p-underground.json new file mode 100644 index 00000000000..f160b363cf7 --- /dev/null +++ b/docs/evidence/walker/2026-08-05/shadow-input-01-f2p-underground.json @@ -0,0 +1,417 @@ +{ + "schemaVersion": 2, + "enabled": true, + "candidateEngineId": "shortest-path-upstream@ff8e961b32120175709df9630ece9468cc11347f", + "startedAtEpochMillis": 1785934189237, + "totals": { + "submitted": 10, + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0, + "staleResults": 0, + "discarded": 0, + "pending": 0, + "routeShapeDifferences": 0 + }, + "coverage": { + "SYNCHRONOUS_QUERY": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "ACTIVE_ROUTE": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + }, + "ACTIVE_REPLAN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "RECOVERY_REPLAN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SURFACE_COORDINATES_ONLY": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNDERGROUND_COORDINATES": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + }, + "WALKING_ONLY_SELECTED": { + "completed": 5, + "matches": 5, + "divergences": 0, + "failures": 0 + }, + "USES_TRANSPORT": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + }, + "SELECTS_ITEM_GATED_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ITEMS_ENABLED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_DIRECT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_TO_BANK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_FROM_BANK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_FROM_BANK_SELECTS_ITEM_GATED_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "LIVE_COLLISION_ENABLED": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + }, + "LIVE_COLLISION_CONSULTED": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + } + }, + "transportExecutors": { + "BARROWS_DIG": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CANOE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CHARTER_SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "FAIRY_RING": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GNOME_GLIDER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "HOT_AIR_BALLOON": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "ITEM_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_CARPET": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_MUSHTREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MINIGAME_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "OBJECT": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + }, + "POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SEASONAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPELL_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPIRIT_TREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TERMINAL_TRAVEL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WILDERNESS_OBELISK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNSUPPORTED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + } + }, + "transportTypes": { + "TRANSPORT": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + }, + "AGILITY_SHORTCUT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GRAPPLE_SHORTCUT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BOAT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CANOE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CHARTER_SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "FAIRY_RING": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL_WHISTLE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GNOME_GLIDER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MINECART": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPIRIT_TREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_BOX": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_LEVER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_PORTAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_PORTAL_POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_MINIGAME": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_ITEM": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_SPELL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_SPELL_HOME": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WILDERNESS_OBELISK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_CARPET": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "HOT_AIR_BALLOON": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_MUSHTREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SEASONAL_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "NPC": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNKNOWN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + } + }, + "execution": { + "terminal": 10, + "arrived": 10, + "unreachable": 0, + "exited": 0, + "recoveryTerminal": 0, + "recoveryArrived": 0, + "recoveryUnreachable": 0, + "recoveryExited": 0 + } +} diff --git a/docs/evidence/walker/2026-08-05/shadow-input-02-surface-teleport.json b/docs/evidence/walker/2026-08-05/shadow-input-02-surface-teleport.json new file mode 100644 index 00000000000..504b6bac278 --- /dev/null +++ b/docs/evidence/walker/2026-08-05/shadow-input-02-surface-teleport.json @@ -0,0 +1,417 @@ +{ + "schemaVersion": 2, + "enabled": true, + "candidateEngineId": "shortest-path-upstream@ff8e961b32120175709df9630ece9468cc11347f", + "startedAtEpochMillis": 1785934994269, + "totals": { + "submitted": 3, + "completed": 3, + "matches": 3, + "divergences": 0, + "failures": 0, + "staleResults": 0, + "discarded": 0, + "pending": 0, + "routeShapeDifferences": 1 + }, + "coverage": { + "SYNCHRONOUS_QUERY": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "ACTIVE_ROUTE": { + "completed": 3, + "matches": 3, + "divergences": 0, + "failures": 0 + }, + "ACTIVE_REPLAN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "RECOVERY_REPLAN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SURFACE_COORDINATES_ONLY": { + "completed": 2, + "matches": 2, + "divergences": 0, + "failures": 0 + }, + "UNDERGROUND_COORDINATES": { + "completed": 1, + "matches": 1, + "divergences": 0, + "failures": 0 + }, + "WALKING_ONLY_SELECTED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "USES_TRANSPORT": { + "completed": 3, + "matches": 3, + "divergences": 0, + "failures": 0 + }, + "SELECTS_ITEM_GATED_TRANSPORT": { + "completed": 3, + "matches": 3, + "divergences": 0, + "failures": 0 + }, + "BANK_ITEMS_ENABLED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_DIRECT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_TO_BANK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_FROM_BANK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_FROM_BANK_SELECTS_ITEM_GATED_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "LIVE_COLLISION_ENABLED": { + "completed": 3, + "matches": 3, + "divergences": 0, + "failures": 0 + }, + "LIVE_COLLISION_CONSULTED": { + "completed": 3, + "matches": 3, + "divergences": 0, + "failures": 0 + } + }, + "transportExecutors": { + "BARROWS_DIG": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CANOE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CHARTER_SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "FAIRY_RING": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GNOME_GLIDER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "HOT_AIR_BALLOON": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "ITEM_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_CARPET": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_MUSHTREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MINIGAME_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "OBJECT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SEASONAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPELL_TELEPORT": { + "completed": 3, + "matches": 3, + "divergences": 0, + "failures": 0 + }, + "SPIRIT_TREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TERMINAL_TRAVEL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WILDERNESS_OBELISK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNSUPPORTED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + } + }, + "transportTypes": { + "TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "AGILITY_SHORTCUT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GRAPPLE_SHORTCUT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BOAT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CANOE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CHARTER_SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "FAIRY_RING": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL_WHISTLE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GNOME_GLIDER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MINECART": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPIRIT_TREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_BOX": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_LEVER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_PORTAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_PORTAL_POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_MINIGAME": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_ITEM": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_SPELL": { + "completed": 3, + "matches": 3, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_SPELL_HOME": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WILDERNESS_OBELISK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_CARPET": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "HOT_AIR_BALLOON": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_MUSHTREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SEASONAL_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "NPC": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNKNOWN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + } + }, + "execution": { + "terminal": 3, + "arrived": 3, + "unreachable": 0, + "exited": 0, + "recoveryTerminal": 0, + "recoveryArrived": 0, + "recoveryUnreachable": 0, + "recoveryExited": 0 + } +} diff --git a/docs/evidence/walker/2026-08-05/shadow-input-03-surface-teleport.json b/docs/evidence/walker/2026-08-05/shadow-input-03-surface-teleport.json new file mode 100644 index 00000000000..c39010b1105 --- /dev/null +++ b/docs/evidence/walker/2026-08-05/shadow-input-03-surface-teleport.json @@ -0,0 +1,444 @@ +{ + "schemaVersion": 2, + "enabled": true, + "candidateEngineId": "shortest-path-upstream@ff8e961b32120175709df9630ece9468cc11347f", + "startedAtEpochMillis": 1785935618111, + "totals": { + "submitted": 2, + "completed": 2, + "matches": 2, + "divergences": 0, + "failures": 0, + "staleResults": 0, + "discarded": 0, + "pending": 0, + "routeShapeDifferences": 1 + }, + "coverage": { + "SYNCHRONOUS_QUERY": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "ACTIVE_ROUTE": { + "completed": 2, + "matches": 2, + "divergences": 0, + "failures": 0 + }, + "ACTIVE_REPLAN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "RECOVERY_REPLAN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SURFACE_COORDINATES_ONLY": { + "completed": 2, + "matches": 2, + "divergences": 0, + "failures": 0 + }, + "UNDERGROUND_COORDINATES": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WALKING_ONLY_SELECTED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "USES_TRANSPORT": { + "completed": 2, + "matches": 2, + "divergences": 0, + "failures": 0 + }, + "SELECTS_ITEM_GATED_TRANSPORT": { + "completed": 2, + "matches": 2, + "divergences": 0, + "failures": 0 + }, + "BANK_ITEMS_ENABLED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_DIRECT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_TO_BANK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_FROM_BANK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_FROM_BANK_SELECTS_ITEM_GATED_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "LIVE_COLLISION_ENABLED": { + "completed": 2, + "matches": 2, + "divergences": 0, + "failures": 0 + }, + "LIVE_COLLISION_CONSULTED": { + "completed": 2, + "matches": 2, + "divergences": 0, + "failures": 0 + } + }, + "transportExecutors": { + "BARROWS_DIG": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CANOE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CHARTER_SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "FAIRY_RING": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GNOME_GLIDER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "HOT_AIR_BALLOON": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "ITEM_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_CARPET": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_MUSHTREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MINIGAME_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "OBJECT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SEASONAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPELL_TELEPORT": { + "completed": 2, + "matches": 2, + "divergences": 0, + "failures": 0 + }, + "SPIRIT_TREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TERMINAL_TRAVEL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WILDERNESS_OBELISK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNSUPPORTED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + } + }, + "transportTypes": { + "TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "AGILITY_SHORTCUT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GRAPPLE_SHORTCUT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BOAT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CANOE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CHARTER_SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "FAIRY_RING": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL_WHISTLE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GNOME_GLIDER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MINECART": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPIRIT_TREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_BOX": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_LEVER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_PORTAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_PORTAL_POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_MINIGAME": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_ITEM": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_SPELL": { + "completed": 2, + "matches": 2, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_SPELL_HOME": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WILDERNESS_OBELISK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_CARPET": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "HOT_AIR_BALLOON": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_MUSHTREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SEASONAL_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "NPC": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNKNOWN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + } + }, + "execution": { + "terminal": 2, + "arrived": 2, + "unreachable": 0, + "exited": 0, + "recoveryTerminal": 0, + "recoveryArrived": 0, + "recoveryUnreachable": 0, + "recoveryExited": 0 + }, + "latestRouteShapeDifference": { + "status": "MATCH", + "shadowEngineId": "shortest-path-upstream@ff8e961b32120175709df9630ece9468cc11347f", + "invocation": "ACTIVE_ROUTE", + "coverage": [ + "ACTIVE_ROUTE", + "SURFACE_COORDINATES_ONLY", + "USES_TRANSPORT", + "SELECTS_ITEM_GATED_TRANSPORT", + "LIVE_COLLISION_ENABLED", + "LIVE_COLLISION_CONSULTED" + ], + "terminationMatches": true, + "endpointMatches": true, + "costComparable": true, + "costMatches": true, + "selectedTransportsMatch": true, + "pathMatches": false, + "transportExecutors": [ + "SPELL_TELEPORT" + ], + "transportTypes": [ + "TELEPORTATION_SPELL" + ], + "localSearchNanos": 208408646, + "shadowSearchNanos": 704697430 + } +} diff --git a/docs/evidence/walker/2026-08-05/shadow-input-04-canoe.json b/docs/evidence/walker/2026-08-05/shadow-input-04-canoe.json new file mode 100644 index 00000000000..aa3ab36ba84 --- /dev/null +++ b/docs/evidence/walker/2026-08-05/shadow-input-04-canoe.json @@ -0,0 +1,444 @@ +{ + "schemaVersion": 2, + "enabled": true, + "candidateEngineId": "shortest-path-upstream@ff8e961b32120175709df9630ece9468cc11347f", + "startedAtEpochMillis": 1785937091870, + "totals": { + "submitted": 7, + "completed": 7, + "matches": 7, + "divergences": 0, + "failures": 0, + "staleResults": 0, + "discarded": 0, + "pending": 0, + "routeShapeDifferences": 2 + }, + "coverage": { + "SYNCHRONOUS_QUERY": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "ACTIVE_ROUTE": { + "completed": 7, + "matches": 7, + "divergences": 0, + "failures": 0 + }, + "ACTIVE_REPLAN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "RECOVERY_REPLAN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SURFACE_COORDINATES_ONLY": { + "completed": 7, + "matches": 7, + "divergences": 0, + "failures": 0 + }, + "UNDERGROUND_COORDINATES": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WALKING_ONLY_SELECTED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "USES_TRANSPORT": { + "completed": 5, + "matches": 5, + "divergences": 0, + "failures": 0 + }, + "SELECTS_ITEM_GATED_TRANSPORT": { + "completed": 5, + "matches": 5, + "divergences": 0, + "failures": 0 + }, + "BANK_ITEMS_ENABLED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_DIRECT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_TO_BANK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_FROM_BANK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_FROM_BANK_SELECTS_ITEM_GATED_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "LIVE_COLLISION_ENABLED": { + "completed": 7, + "matches": 7, + "divergences": 0, + "failures": 0 + }, + "LIVE_COLLISION_CONSULTED": { + "completed": 6, + "matches": 6, + "divergences": 0, + "failures": 0 + } + }, + "transportExecutors": { + "BARROWS_DIG": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CANOE": { + "completed": 5, + "matches": 5, + "divergences": 0, + "failures": 0 + }, + "CHARTER_SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "FAIRY_RING": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GNOME_GLIDER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "HOT_AIR_BALLOON": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "ITEM_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_CARPET": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_MUSHTREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MINIGAME_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "OBJECT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SEASONAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPELL_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPIRIT_TREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TERMINAL_TRAVEL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WILDERNESS_OBELISK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNSUPPORTED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + } + }, + "transportTypes": { + "TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "AGILITY_SHORTCUT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GRAPPLE_SHORTCUT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BOAT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CANOE": { + "completed": 5, + "matches": 5, + "divergences": 0, + "failures": 0 + }, + "CHARTER_SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "FAIRY_RING": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL_WHISTLE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GNOME_GLIDER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MINECART": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPIRIT_TREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_BOX": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_LEVER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_PORTAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_PORTAL_POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_MINIGAME": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_ITEM": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_SPELL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_SPELL_HOME": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WILDERNESS_OBELISK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_CARPET": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "HOT_AIR_BALLOON": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_MUSHTREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SEASONAL_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "NPC": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNKNOWN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + } + }, + "execution": { + "terminal": 6, + "arrived": 6, + "unreachable": 0, + "exited": 0, + "recoveryTerminal": 0, + "recoveryArrived": 0, + "recoveryUnreachable": 0, + "recoveryExited": 0 + }, + "latestRouteShapeDifference": { + "status": "MATCH", + "shadowEngineId": "shortest-path-upstream@ff8e961b32120175709df9630ece9468cc11347f", + "invocation": "ACTIVE_ROUTE", + "coverage": [ + "ACTIVE_ROUTE", + "SURFACE_COORDINATES_ONLY", + "USES_TRANSPORT", + "SELECTS_ITEM_GATED_TRANSPORT", + "LIVE_COLLISION_ENABLED", + "LIVE_COLLISION_CONSULTED" + ], + "terminationMatches": true, + "endpointMatches": true, + "costComparable": true, + "costMatches": true, + "selectedTransportsMatch": true, + "pathMatches": false, + "transportExecutors": [ + "CANOE" + ], + "transportTypes": [ + "CANOE" + ], + "localSearchNanos": 37101556, + "shadowSearchNanos": 12611961 + } +} diff --git a/docs/evidence/walker/2026-08-05/shadow-input-05-terminal-travel.json b/docs/evidence/walker/2026-08-05/shadow-input-05-terminal-travel.json new file mode 100644 index 00000000000..fbea8881c4b --- /dev/null +++ b/docs/evidence/walker/2026-08-05/shadow-input-05-terminal-travel.json @@ -0,0 +1,438 @@ +{ + "schemaVersion": 2, + "enabled": true, + "candidateEngineId": "shortest-path-upstream@ff8e961b32120175709df9630ece9468cc11347f", + "startedAtEpochMillis": 1785937771607, + "totals": { + "submitted": 4, + "completed": 4, + "matches": 4, + "divergences": 0, + "failures": 0, + "staleResults": 0, + "discarded": 0, + "pending": 0, + "routeShapeDifferences": 1 + }, + "coverage": { + "SYNCHRONOUS_QUERY": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "ACTIVE_ROUTE": { + "completed": 4, + "matches": 4, + "divergences": 0, + "failures": 0 + }, + "ACTIVE_REPLAN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "RECOVERY_REPLAN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SURFACE_COORDINATES_ONLY": { + "completed": 4, + "matches": 4, + "divergences": 0, + "failures": 0 + }, + "UNDERGROUND_COORDINATES": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WALKING_ONLY_SELECTED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "USES_TRANSPORT": { + "completed": 3, + "matches": 3, + "divergences": 0, + "failures": 0 + }, + "SELECTS_ITEM_GATED_TRANSPORT": { + "completed": 3, + "matches": 3, + "divergences": 0, + "failures": 0 + }, + "BANK_ITEMS_ENABLED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_DIRECT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_TO_BANK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_FROM_BANK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_FROM_BANK_SELECTS_ITEM_GATED_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "LIVE_COLLISION_ENABLED": { + "completed": 4, + "matches": 4, + "divergences": 0, + "failures": 0 + }, + "LIVE_COLLISION_CONSULTED": { + "completed": 4, + "matches": 4, + "divergences": 0, + "failures": 0 + } + }, + "transportExecutors": { + "BARROWS_DIG": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CANOE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CHARTER_SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "FAIRY_RING": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GNOME_GLIDER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "HOT_AIR_BALLOON": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "ITEM_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_CARPET": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_MUSHTREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MINIGAME_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "OBJECT": { + "completed": 3, + "matches": 3, + "divergences": 0, + "failures": 0 + }, + "POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SEASONAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPELL_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPIRIT_TREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TERMINAL_TRAVEL": { + "completed": 3, + "matches": 3, + "divergences": 0, + "failures": 0 + }, + "WILDERNESS_OBELISK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNSUPPORTED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + } + }, + "transportTypes": { + "TRANSPORT": { + "completed": 3, + "matches": 3, + "divergences": 0, + "failures": 0 + }, + "AGILITY_SHORTCUT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GRAPPLE_SHORTCUT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BOAT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CANOE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CHARTER_SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SHIP": { + "completed": 3, + "matches": 3, + "divergences": 0, + "failures": 0 + }, + "FAIRY_RING": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL_WHISTLE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GNOME_GLIDER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MINECART": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPIRIT_TREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_BOX": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_LEVER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_PORTAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_PORTAL_POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_MINIGAME": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_ITEM": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_SPELL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_SPELL_HOME": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WILDERNESS_OBELISK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_CARPET": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "HOT_AIR_BALLOON": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_MUSHTREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SEASONAL_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "NPC": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNKNOWN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + } + }, + "execution": { + "terminal": 4, + "arrived": 4, + "unreachable": 0, + "exited": 0, + "recoveryTerminal": 0, + "recoveryArrived": 0, + "recoveryUnreachable": 0, + "recoveryExited": 0 + }, + "latestRouteShapeDifference": { + "status": "MATCH", + "shadowEngineId": "shortest-path-upstream@ff8e961b32120175709df9630ece9468cc11347f", + "invocation": "ACTIVE_ROUTE", + "coverage": [ + "ACTIVE_ROUTE", + "SURFACE_COORDINATES_ONLY", + "LIVE_COLLISION_ENABLED", + "LIVE_COLLISION_CONSULTED" + ], + "terminationMatches": true, + "endpointMatches": true, + "costComparable": true, + "costMatches": true, + "selectedTransportsMatch": true, + "pathMatches": false, + "transportExecutors": [], + "transportTypes": [], + "localSearchNanos": 511605835, + "shadowSearchNanos": 1202476776 + } +} diff --git a/docs/evidence/walker/2026-08-05/shadow-input-06-active-replan.json b/docs/evidence/walker/2026-08-05/shadow-input-06-active-replan.json new file mode 100644 index 00000000000..f6d78824725 --- /dev/null +++ b/docs/evidence/walker/2026-08-05/shadow-input-06-active-replan.json @@ -0,0 +1,438 @@ +{ + "schemaVersion": 2, + "enabled": true, + "candidateEngineId": "shortest-path-upstream@ff8e961b32120175709df9630ece9468cc11347f", + "startedAtEpochMillis": 1785938522908, + "totals": { + "submitted": 5, + "completed": 5, + "matches": 5, + "divergences": 0, + "failures": 0, + "staleResults": 1, + "discarded": 0, + "pending": 0, + "routeShapeDifferences": 5 + }, + "coverage": { + "SYNCHRONOUS_QUERY": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "ACTIVE_ROUTE": { + "completed": 2, + "matches": 2, + "divergences": 0, + "failures": 0 + }, + "ACTIVE_REPLAN": { + "completed": 3, + "matches": 3, + "divergences": 0, + "failures": 0 + }, + "RECOVERY_REPLAN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SURFACE_COORDINATES_ONLY": { + "completed": 5, + "matches": 5, + "divergences": 0, + "failures": 0 + }, + "UNDERGROUND_COORDINATES": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WALKING_ONLY_SELECTED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "USES_TRANSPORT": { + "completed": 1, + "matches": 1, + "divergences": 0, + "failures": 0 + }, + "SELECTS_ITEM_GATED_TRANSPORT": { + "completed": 1, + "matches": 1, + "divergences": 0, + "failures": 0 + }, + "BANK_ITEMS_ENABLED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_DIRECT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_TO_BANK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_FROM_BANK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_FROM_BANK_SELECTS_ITEM_GATED_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "LIVE_COLLISION_ENABLED": { + "completed": 5, + "matches": 5, + "divergences": 0, + "failures": 0 + }, + "LIVE_COLLISION_CONSULTED": { + "completed": 5, + "matches": 5, + "divergences": 0, + "failures": 0 + } + }, + "transportExecutors": { + "BARROWS_DIG": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CANOE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CHARTER_SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "FAIRY_RING": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GNOME_GLIDER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "HOT_AIR_BALLOON": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "ITEM_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_CARPET": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_MUSHTREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MINIGAME_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "OBJECT": { + "completed": 1, + "matches": 1, + "divergences": 0, + "failures": 0 + }, + "POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SEASONAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPELL_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPIRIT_TREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TERMINAL_TRAVEL": { + "completed": 1, + "matches": 1, + "divergences": 0, + "failures": 0 + }, + "WILDERNESS_OBELISK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNSUPPORTED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + } + }, + "transportTypes": { + "TRANSPORT": { + "completed": 1, + "matches": 1, + "divergences": 0, + "failures": 0 + }, + "AGILITY_SHORTCUT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GRAPPLE_SHORTCUT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BOAT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CANOE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CHARTER_SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SHIP": { + "completed": 1, + "matches": 1, + "divergences": 0, + "failures": 0 + }, + "FAIRY_RING": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL_WHISTLE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GNOME_GLIDER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MINECART": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPIRIT_TREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_BOX": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_LEVER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_PORTAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_PORTAL_POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_MINIGAME": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_ITEM": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_SPELL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_SPELL_HOME": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WILDERNESS_OBELISK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_CARPET": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "HOT_AIR_BALLOON": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_MUSHTREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SEASONAL_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "NPC": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNKNOWN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + } + }, + "execution": { + "terminal": 2, + "arrived": 2, + "unreachable": 0, + "exited": 0, + "recoveryTerminal": 0, + "recoveryArrived": 0, + "recoveryUnreachable": 0, + "recoveryExited": 0 + }, + "latestRouteShapeDifference": { + "status": "MATCH", + "shadowEngineId": "shortest-path-upstream@ff8e961b32120175709df9630ece9468cc11347f", + "invocation": "ACTIVE_REPLAN", + "coverage": [ + "ACTIVE_REPLAN", + "SURFACE_COORDINATES_ONLY", + "LIVE_COLLISION_ENABLED", + "LIVE_COLLISION_CONSULTED" + ], + "terminationMatches": true, + "endpointMatches": true, + "costComparable": true, + "costMatches": true, + "selectedTransportsMatch": true, + "pathMatches": false, + "transportExecutors": [], + "transportTypes": [], + "localSearchNanos": 106490102, + "shadowSearchNanos": 128205961 + } +} diff --git a/docs/evidence/walker/2026-08-05/shadow-input-07-recovery.json b/docs/evidence/walker/2026-08-05/shadow-input-07-recovery.json new file mode 100644 index 00000000000..bfe55172243 --- /dev/null +++ b/docs/evidence/walker/2026-08-05/shadow-input-07-recovery.json @@ -0,0 +1,438 @@ +{ + "schemaVersion": 2, + "enabled": true, + "candidateEngineId": "shortest-path-upstream@ff8e961b32120175709df9630ece9468cc11347f", + "startedAtEpochMillis": 1785941896113, + "totals": { + "submitted": 17, + "completed": 17, + "matches": 17, + "divergences": 0, + "failures": 0, + "staleResults": 0, + "discarded": 0, + "pending": 0, + "routeShapeDifferences": 17 + }, + "coverage": { + "SYNCHRONOUS_QUERY": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "ACTIVE_ROUTE": { + "completed": 6, + "matches": 6, + "divergences": 0, + "failures": 0 + }, + "ACTIVE_REPLAN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "RECOVERY_REPLAN": { + "completed": 11, + "matches": 11, + "divergences": 0, + "failures": 0 + }, + "SURFACE_COORDINATES_ONLY": { + "completed": 17, + "matches": 17, + "divergences": 0, + "failures": 0 + }, + "UNDERGROUND_COORDINATES": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WALKING_ONLY_SELECTED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "USES_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SELECTS_ITEM_GATED_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ITEMS_ENABLED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_DIRECT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_TO_BANK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_FROM_BANK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_FROM_BANK_SELECTS_ITEM_GATED_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "LIVE_COLLISION_ENABLED": { + "completed": 17, + "matches": 17, + "divergences": 0, + "failures": 0 + }, + "LIVE_COLLISION_CONSULTED": { + "completed": 17, + "matches": 17, + "divergences": 0, + "failures": 0 + } + }, + "transportExecutors": { + "BARROWS_DIG": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CANOE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CHARTER_SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "FAIRY_RING": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GNOME_GLIDER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "HOT_AIR_BALLOON": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "ITEM_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_CARPET": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_MUSHTREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MINIGAME_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "OBJECT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SEASONAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPELL_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPIRIT_TREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TERMINAL_TRAVEL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WILDERNESS_OBELISK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNSUPPORTED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + } + }, + "transportTypes": { + "TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "AGILITY_SHORTCUT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GRAPPLE_SHORTCUT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BOAT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CANOE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CHARTER_SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "FAIRY_RING": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL_WHISTLE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GNOME_GLIDER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MINECART": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPIRIT_TREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_BOX": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_LEVER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_PORTAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_PORTAL_POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_MINIGAME": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_ITEM": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_SPELL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_SPELL_HOME": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WILDERNESS_OBELISK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_CARPET": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "HOT_AIR_BALLOON": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_MUSHTREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SEASONAL_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "NPC": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNKNOWN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + } + }, + "execution": { + "terminal": 6, + "arrived": 6, + "unreachable": 0, + "exited": 0, + "recoveryTerminal": 6, + "recoveryArrived": 6, + "recoveryUnreachable": 0, + "recoveryExited": 0 + }, + "latestRouteShapeDifference": { + "status": "MATCH", + "shadowEngineId": "shortest-path-upstream@ff8e961b32120175709df9630ece9468cc11347f", + "invocation": "RECOVERY_REPLAN", + "coverage": [ + "RECOVERY_REPLAN", + "SURFACE_COORDINATES_ONLY", + "LIVE_COLLISION_ENABLED", + "LIVE_COLLISION_CONSULTED" + ], + "terminationMatches": true, + "endpointMatches": true, + "costComparable": true, + "costMatches": true, + "selectedTransportsMatch": true, + "pathMatches": false, + "transportExecutors": [], + "transportTypes": [], + "localSearchNanos": 18778970, + "shadowSearchNanos": 4817316 + } +} diff --git a/docs/evidence/walker/2026-08-05/shadow-input-08-bank.json b/docs/evidence/walker/2026-08-05/shadow-input-08-bank.json new file mode 100644 index 00000000000..614b0aa7a1a --- /dev/null +++ b/docs/evidence/walker/2026-08-05/shadow-input-08-bank.json @@ -0,0 +1,444 @@ +{ + "schemaVersion": 2, + "enabled": true, + "candidateEngineId": "shortest-path-upstream@ff8e961b32120175709df9630ece9468cc11347f", + "startedAtEpochMillis": 1785943397622, + "totals": { + "submitted": 49, + "completed": 49, + "matches": 49, + "divergences": 0, + "failures": 0, + "staleResults": 0, + "discarded": 0, + "pending": 0, + "routeShapeDifferences": 33 + }, + "coverage": { + "SYNCHRONOUS_QUERY": { + "completed": 40, + "matches": 40, + "divergences": 0, + "failures": 0 + }, + "ACTIVE_ROUTE": { + "completed": 9, + "matches": 9, + "divergences": 0, + "failures": 0 + }, + "ACTIVE_REPLAN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "RECOVERY_REPLAN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SURFACE_COORDINATES_ONLY": { + "completed": 49, + "matches": 49, + "divergences": 0, + "failures": 0 + }, + "UNDERGROUND_COORDINATES": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WALKING_ONLY_SELECTED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "USES_TRANSPORT": { + "completed": 45, + "matches": 45, + "divergences": 0, + "failures": 0 + }, + "SELECTS_ITEM_GATED_TRANSPORT": { + "completed": 45, + "matches": 45, + "divergences": 0, + "failures": 0 + }, + "BANK_ITEMS_ENABLED": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_DIRECT": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_TO_BANK": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_FROM_BANK": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_FROM_BANK_SELECTS_ITEM_GATED_TRANSPORT": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + }, + "LIVE_COLLISION_ENABLED": { + "completed": 49, + "matches": 49, + "divergences": 0, + "failures": 0 + }, + "LIVE_COLLISION_CONSULTED": { + "completed": 46, + "matches": 46, + "divergences": 0, + "failures": 0 + } + }, + "transportExecutors": { + "BARROWS_DIG": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CANOE": { + "completed": 45, + "matches": 45, + "divergences": 0, + "failures": 0 + }, + "CHARTER_SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "FAIRY_RING": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GNOME_GLIDER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "HOT_AIR_BALLOON": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "ITEM_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_CARPET": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_MUSHTREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MINIGAME_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "OBJECT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SEASONAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPELL_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPIRIT_TREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TERMINAL_TRAVEL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WILDERNESS_OBELISK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNSUPPORTED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + } + }, + "transportTypes": { + "TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "AGILITY_SHORTCUT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GRAPPLE_SHORTCUT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BOAT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CANOE": { + "completed": 45, + "matches": 45, + "divergences": 0, + "failures": 0 + }, + "CHARTER_SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "FAIRY_RING": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL_WHISTLE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GNOME_GLIDER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MINECART": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPIRIT_TREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_BOX": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_LEVER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_PORTAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_PORTAL_POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_MINIGAME": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_ITEM": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_SPELL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_SPELL_HOME": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WILDERNESS_OBELISK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_CARPET": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "HOT_AIR_BALLOON": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_MUSHTREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SEASONAL_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "NPC": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNKNOWN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + } + }, + "execution": { + "terminal": 6, + "arrived": 6, + "unreachable": 0, + "exited": 0, + "recoveryTerminal": 0, + "recoveryArrived": 0, + "recoveryUnreachable": 0, + "recoveryExited": 0 + }, + "latestRouteShapeDifference": { + "status": "MATCH", + "shadowEngineId": "shortest-path-upstream@ff8e961b32120175709df9630ece9468cc11347f", + "invocation": "ACTIVE_ROUTE", + "coverage": [ + "ACTIVE_ROUTE", + "SURFACE_COORDINATES_ONLY", + "USES_TRANSPORT", + "SELECTS_ITEM_GATED_TRANSPORT", + "LIVE_COLLISION_ENABLED", + "LIVE_COLLISION_CONSULTED" + ], + "terminationMatches": true, + "endpointMatches": true, + "costComparable": true, + "costMatches": true, + "selectedTransportsMatch": true, + "pathMatches": false, + "transportExecutors": [ + "CANOE" + ], + "transportTypes": [ + "CANOE" + ], + "localSearchNanos": 23871918, + "shadowSearchNanos": 6042372 + } +} diff --git a/docs/evidence/walker/2026-08-05/shadow-input-09-active-replan.json b/docs/evidence/walker/2026-08-05/shadow-input-09-active-replan.json new file mode 100644 index 00000000000..21a572e119a --- /dev/null +++ b/docs/evidence/walker/2026-08-05/shadow-input-09-active-replan.json @@ -0,0 +1,438 @@ +{ + "schemaVersion": 2, + "enabled": true, + "candidateEngineId": "shortest-path-upstream@ff8e961b32120175709df9630ece9468cc11347f", + "startedAtEpochMillis": 1785944549355, + "totals": { + "submitted": 14, + "completed": 14, + "matches": 14, + "divergences": 0, + "failures": 0, + "staleResults": 1, + "discarded": 0, + "pending": 0, + "routeShapeDifferences": 14 + }, + "coverage": { + "SYNCHRONOUS_QUERY": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "ACTIVE_ROUTE": { + "completed": 2, + "matches": 2, + "divergences": 0, + "failures": 0 + }, + "ACTIVE_REPLAN": { + "completed": 12, + "matches": 12, + "divergences": 0, + "failures": 0 + }, + "RECOVERY_REPLAN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SURFACE_COORDINATES_ONLY": { + "completed": 14, + "matches": 14, + "divergences": 0, + "failures": 0 + }, + "UNDERGROUND_COORDINATES": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WALKING_ONLY_SELECTED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "USES_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SELECTS_ITEM_GATED_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ITEMS_ENABLED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_DIRECT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_TO_BANK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_FROM_BANK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_FROM_BANK_SELECTS_ITEM_GATED_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "LIVE_COLLISION_ENABLED": { + "completed": 14, + "matches": 14, + "divergences": 0, + "failures": 0 + }, + "LIVE_COLLISION_CONSULTED": { + "completed": 14, + "matches": 14, + "divergences": 0, + "failures": 0 + } + }, + "transportExecutors": { + "BARROWS_DIG": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CANOE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CHARTER_SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "FAIRY_RING": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GNOME_GLIDER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "HOT_AIR_BALLOON": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "ITEM_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_CARPET": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_MUSHTREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MINIGAME_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "OBJECT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SEASONAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPELL_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPIRIT_TREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TERMINAL_TRAVEL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WILDERNESS_OBELISK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNSUPPORTED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + } + }, + "transportTypes": { + "TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "AGILITY_SHORTCUT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GRAPPLE_SHORTCUT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BOAT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CANOE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CHARTER_SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "FAIRY_RING": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL_WHISTLE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GNOME_GLIDER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MINECART": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPIRIT_TREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_BOX": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_LEVER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_PORTAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_PORTAL_POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_MINIGAME": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_ITEM": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_SPELL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_SPELL_HOME": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WILDERNESS_OBELISK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_CARPET": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "HOT_AIR_BALLOON": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_MUSHTREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SEASONAL_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "NPC": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNKNOWN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + } + }, + "execution": { + "terminal": 2, + "arrived": 2, + "unreachable": 0, + "exited": 0, + "recoveryTerminal": 0, + "recoveryArrived": 0, + "recoveryUnreachable": 0, + "recoveryExited": 0 + }, + "latestRouteShapeDifference": { + "status": "MATCH", + "shadowEngineId": "shortest-path-upstream@ff8e961b32120175709df9630ece9468cc11347f", + "invocation": "ACTIVE_REPLAN", + "coverage": [ + "ACTIVE_REPLAN", + "SURFACE_COORDINATES_ONLY", + "LIVE_COLLISION_ENABLED", + "LIVE_COLLISION_CONSULTED" + ], + "terminationMatches": true, + "endpointMatches": true, + "costComparable": true, + "costMatches": true, + "selectedTransportsMatch": true, + "pathMatches": false, + "transportExecutors": [], + "transportTypes": [], + "localSearchNanos": 162599395, + "shadowSearchNanos": 96122140 + } +} diff --git a/docs/evidence/walker/2026-08-05/shadow-input-10-underground.json b/docs/evidence/walker/2026-08-05/shadow-input-10-underground.json new file mode 100644 index 00000000000..b4f884ec7da --- /dev/null +++ b/docs/evidence/walker/2026-08-05/shadow-input-10-underground.json @@ -0,0 +1,438 @@ +{ + "schemaVersion": 2, + "enabled": true, + "candidateEngineId": "shortest-path-upstream@ff8e961b32120175709df9630ece9468cc11347f", + "startedAtEpochMillis": 1785945250717, + "totals": { + "submitted": 10, + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0, + "staleResults": 0, + "discarded": 0, + "pending": 0, + "routeShapeDifferences": 1 + }, + "coverage": { + "SYNCHRONOUS_QUERY": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "ACTIVE_ROUTE": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + }, + "ACTIVE_REPLAN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "RECOVERY_REPLAN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SURFACE_COORDINATES_ONLY": { + "completed": 1, + "matches": 1, + "divergences": 0, + "failures": 0 + }, + "UNDERGROUND_COORDINATES": { + "completed": 9, + "matches": 9, + "divergences": 0, + "failures": 0 + }, + "WALKING_ONLY_SELECTED": { + "completed": 4, + "matches": 4, + "divergences": 0, + "failures": 0 + }, + "USES_TRANSPORT": { + "completed": 9, + "matches": 9, + "divergences": 0, + "failures": 0 + }, + "SELECTS_ITEM_GATED_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ITEMS_ENABLED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_DIRECT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_TO_BANK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_FROM_BANK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_FROM_BANK_SELECTS_ITEM_GATED_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "LIVE_COLLISION_ENABLED": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + }, + "LIVE_COLLISION_CONSULTED": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + } + }, + "transportExecutors": { + "BARROWS_DIG": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CANOE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CHARTER_SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "FAIRY_RING": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GNOME_GLIDER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "HOT_AIR_BALLOON": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "ITEM_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_CARPET": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_MUSHTREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MINIGAME_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "OBJECT": { + "completed": 9, + "matches": 9, + "divergences": 0, + "failures": 0 + }, + "POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SEASONAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPELL_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPIRIT_TREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TERMINAL_TRAVEL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WILDERNESS_OBELISK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNSUPPORTED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + } + }, + "transportTypes": { + "TRANSPORT": { + "completed": 9, + "matches": 9, + "divergences": 0, + "failures": 0 + }, + "AGILITY_SHORTCUT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GRAPPLE_SHORTCUT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BOAT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CANOE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CHARTER_SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "FAIRY_RING": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL_WHISTLE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GNOME_GLIDER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MINECART": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPIRIT_TREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_BOX": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_LEVER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_PORTAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_PORTAL_POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_MINIGAME": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_ITEM": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_SPELL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_SPELL_HOME": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WILDERNESS_OBELISK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_CARPET": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "HOT_AIR_BALLOON": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_MUSHTREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SEASONAL_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "NPC": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNKNOWN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + } + }, + "execution": { + "terminal": 10, + "arrived": 10, + "unreachable": 0, + "exited": 0, + "recoveryTerminal": 0, + "recoveryArrived": 0, + "recoveryUnreachable": 0, + "recoveryExited": 0 + }, + "latestRouteShapeDifference": { + "status": "MATCH", + "shadowEngineId": "shortest-path-upstream@ff8e961b32120175709df9630ece9468cc11347f", + "invocation": "ACTIVE_ROUTE", + "coverage": [ + "ACTIVE_ROUTE", + "SURFACE_COORDINATES_ONLY", + "LIVE_COLLISION_ENABLED", + "LIVE_COLLISION_CONSULTED" + ], + "terminationMatches": true, + "endpointMatches": true, + "costComparable": true, + "costMatches": true, + "selectedTransportsMatch": true, + "pathMatches": false, + "transportExecutors": [], + "transportTypes": [], + "localSearchNanos": 1041602766, + "shadowSearchNanos": 600676511 + } +} diff --git a/docs/evidence/walker/2026-08-05/shadow-input-11-underground.json b/docs/evidence/walker/2026-08-05/shadow-input-11-underground.json new file mode 100644 index 00000000000..dd3de9b44fb --- /dev/null +++ b/docs/evidence/walker/2026-08-05/shadow-input-11-underground.json @@ -0,0 +1,438 @@ +{ + "schemaVersion": 2, + "enabled": true, + "candidateEngineId": "shortest-path-upstream@ff8e961b32120175709df9630ece9468cc11347f", + "startedAtEpochMillis": 1785947235268, + "totals": { + "submitted": 10, + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0, + "staleResults": 0, + "discarded": 0, + "pending": 0, + "routeShapeDifferences": 1 + }, + "coverage": { + "SYNCHRONOUS_QUERY": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "ACTIVE_ROUTE": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + }, + "ACTIVE_REPLAN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "RECOVERY_REPLAN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SURFACE_COORDINATES_ONLY": { + "completed": 1, + "matches": 1, + "divergences": 0, + "failures": 0 + }, + "UNDERGROUND_COORDINATES": { + "completed": 9, + "matches": 9, + "divergences": 0, + "failures": 0 + }, + "WALKING_ONLY_SELECTED": { + "completed": 4, + "matches": 4, + "divergences": 0, + "failures": 0 + }, + "USES_TRANSPORT": { + "completed": 9, + "matches": 9, + "divergences": 0, + "failures": 0 + }, + "SELECTS_ITEM_GATED_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ITEMS_ENABLED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_DIRECT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_TO_BANK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_FROM_BANK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_FROM_BANK_SELECTS_ITEM_GATED_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "LIVE_COLLISION_ENABLED": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + }, + "LIVE_COLLISION_CONSULTED": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + } + }, + "transportExecutors": { + "BARROWS_DIG": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CANOE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CHARTER_SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "FAIRY_RING": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GNOME_GLIDER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "HOT_AIR_BALLOON": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "ITEM_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_CARPET": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_MUSHTREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MINIGAME_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "OBJECT": { + "completed": 9, + "matches": 9, + "divergences": 0, + "failures": 0 + }, + "POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SEASONAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPELL_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPIRIT_TREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TERMINAL_TRAVEL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WILDERNESS_OBELISK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNSUPPORTED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + } + }, + "transportTypes": { + "TRANSPORT": { + "completed": 9, + "matches": 9, + "divergences": 0, + "failures": 0 + }, + "AGILITY_SHORTCUT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GRAPPLE_SHORTCUT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BOAT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CANOE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CHARTER_SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "FAIRY_RING": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL_WHISTLE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GNOME_GLIDER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MINECART": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPIRIT_TREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_BOX": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_LEVER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_PORTAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_PORTAL_POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_MINIGAME": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_ITEM": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_SPELL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_SPELL_HOME": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WILDERNESS_OBELISK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_CARPET": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "HOT_AIR_BALLOON": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_MUSHTREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SEASONAL_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "NPC": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNKNOWN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + } + }, + "execution": { + "terminal": 10, + "arrived": 10, + "unreachable": 0, + "exited": 0, + "recoveryTerminal": 0, + "recoveryArrived": 0, + "recoveryUnreachable": 0, + "recoveryExited": 0 + }, + "latestRouteShapeDifference": { + "status": "MATCH", + "shadowEngineId": "shortest-path-upstream@ff8e961b32120175709df9630ece9468cc11347f", + "invocation": "ACTIVE_ROUTE", + "coverage": [ + "ACTIVE_ROUTE", + "SURFACE_COORDINATES_ONLY", + "LIVE_COLLISION_ENABLED", + "LIVE_COLLISION_CONSULTED" + ], + "terminationMatches": true, + "endpointMatches": true, + "costComparable": true, + "costMatches": true, + "selectedTransportsMatch": true, + "pathMatches": false, + "transportExecutors": [], + "transportTypes": [], + "localSearchNanos": 457838171, + "shadowSearchNanos": 289589123 + } +} diff --git a/docs/evidence/walker/2026-08-05/shadow-input-12-underground.json b/docs/evidence/walker/2026-08-05/shadow-input-12-underground.json new file mode 100644 index 00000000000..f486e7596f1 --- /dev/null +++ b/docs/evidence/walker/2026-08-05/shadow-input-12-underground.json @@ -0,0 +1,417 @@ +{ + "schemaVersion": 2, + "enabled": true, + "candidateEngineId": "shortest-path-upstream@ff8e961b32120175709df9630ece9468cc11347f", + "startedAtEpochMillis": 1785947483058, + "totals": { + "submitted": 10, + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0, + "staleResults": 0, + "discarded": 0, + "pending": 0, + "routeShapeDifferences": 0 + }, + "coverage": { + "SYNCHRONOUS_QUERY": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "ACTIVE_ROUTE": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + }, + "ACTIVE_REPLAN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "RECOVERY_REPLAN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SURFACE_COORDINATES_ONLY": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNDERGROUND_COORDINATES": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + }, + "WALKING_ONLY_SELECTED": { + "completed": 5, + "matches": 5, + "divergences": 0, + "failures": 0 + }, + "USES_TRANSPORT": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + }, + "SELECTS_ITEM_GATED_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ITEMS_ENABLED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_DIRECT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_TO_BANK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_FROM_BANK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BANK_ROUTE_FROM_BANK_SELECTS_ITEM_GATED_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "LIVE_COLLISION_ENABLED": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + }, + "LIVE_COLLISION_CONSULTED": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + } + }, + "transportExecutors": { + "BARROWS_DIG": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CANOE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CHARTER_SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "FAIRY_RING": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GNOME_GLIDER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "HOT_AIR_BALLOON": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "ITEM_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_CARPET": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_MUSHTREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MINIGAME_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "OBJECT": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + }, + "POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SEASONAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPELL_TELEPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPIRIT_TREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TERMINAL_TRAVEL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WILDERNESS_OBELISK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNSUPPORTED": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + } + }, + "transportTypes": { + "TRANSPORT": { + "completed": 10, + "matches": 10, + "divergences": 0, + "failures": 0 + }, + "AGILITY_SHORTCUT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GRAPPLE_SHORTCUT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "BOAT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CANOE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "CHARTER_SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SHIP": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "FAIRY_RING": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "QUETZAL_WHISTLE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "GNOME_GLIDER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MINECART": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SPIRIT_TREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_BOX": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_LEVER": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_PORTAL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_PORTAL_POH": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_MINIGAME": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_ITEM": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_SPELL": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "TELEPORTATION_SPELL_HOME": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "WILDERNESS_OBELISK": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_CARPET": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "HOT_AIR_BALLOON": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "MAGIC_MUSHTREE": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "SEASONAL_TRANSPORT": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "NPC": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + }, + "UNKNOWN": { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0 + } + }, + "execution": { + "terminal": 10, + "arrived": 10, + "unreachable": 0, + "exited": 0, + "recoveryTerminal": 0, + "recoveryArrived": 0, + "recoveryUnreachable": 0, + "recoveryExited": 0 + } +} diff --git a/docs/walker-audit.md b/docs/walker-audit.md index 0cc804833f6..41b1a0318cd 100644 --- a/docs/walker-audit.md +++ b/docs/walker-audit.md @@ -2,6 +2,10 @@ _Audit date: 2026-07-25. Scope: `shortestpath/` (pathfinding) and `util/walker/` (runtime execution)._ +> Historical audit. The maintained direction and current status are in +> [`walker-roadmap.md`](walker-roadmap.md). In particular, live collision now defaults on and the +> upstream collision baseline has moved since this snapshot. + ## Verdict **Path *generation* is healthy. The runtime *executor* is the problem, and collision-map diff --git a/docs/walker-p2-unification.md b/docs/walker-p2-unification.md index a1366712797..97ee83f5765 100644 --- a/docs/walker-p2-unification.md +++ b/docs/walker-p2-unification.md @@ -1,5 +1,9 @@ # Walker P2 — Unify the Obstacle Model +> Historical design record. See [`walker-roadmap.md`](walker-roadmap.md) for the maintained plan and +> the decision to evolve stateful door/transport behavior from pinned incidents instead of performing a +> wholesale rewrite. + _Plan date: 2026-07-26. Depends on P1 (docs/walker-audit.md): `state/WalkerRouteState`, `recovery/RouteRecovery` + harness, `geometry/WalkerPathGeometry`, and the pre-existing `door/` + `obstacle/` packages are in place._ diff --git a/docs/walker-planner-selection-gate.md b/docs/walker-planner-selection-gate.md new file mode 100644 index 00000000000..4e6eaab4ab7 --- /dev/null +++ b/docs/walker-planner-selection-gate.md @@ -0,0 +1,293 @@ +# Walker planner selection gate + +## F2P production selection evidence complete + +The upstream-compatible boundary is correct, and the reviewed upstream core now satisfies the F2P-scoped +correctness, runtime, rollback and performance evidence gates. `Skretzo/shortest-path` remains the +maintenance-preferred candidate algorithm and reviewed reference data; members-policy selection is not +justified until its separate evidence slice passes. +Microbot's resolved planning snapshot remains authoritative for executable-edge admission, live collision, +account policy and intentional data overlays. Microbot should continue to own banking orchestration, +interaction, recovery and automation policy. + +This avoids an indefinitely diverging local search fork while keeping Microbot runtime behavior independent +of a RuneLite display plugin. The `Rs2RoutePlanner` boundary keeps those responsibilities separate and makes +the production choice reversible. + +## Required evidence + +A production-core decision requires all of these gates: + +1. **Pinned implementation:** the packaged core matches the reviewed upstream commit except for the declared, + digest-pinned adapter patch surface. +2. **Headless correctness:** every parity corpus case agrees on termination, endpoint, route cost and exact + selected edge; every intentional policy divergence remains documented and observable. +3. **Headless performance:** at least five clean, same-revision comparison runs satisfy the performance + thresholds below. +4. **Live shadow behavior:** active surface, underground, transport, bank/replan and recovery routes produce + enough categorized evidence to show no unexplained semantic divergence or planner failure. Stale and + discarded shadow work is capacity telemetry, not a route mismatch. +5. **Explicit rollout decision and rollback:** record the evidence and choose the production core + deliberately. The first production rollout must retain a tested, release-independent way to select the + local planner and preserve comparable telemetry for both engines. During staged dual-running, any confirmed + upstream planner failure or unexplained semantic divergence on a request selected for execution triggers + rollback to the local planner. The normal-canary and forced-failure artifacts must jointly pass + `scripts/evaluate-walker-rollout-evidence.py`; do not infer selection from packaging the adapter, enabling + shadow mode or reviewing two self-reported harness checks independently. The paired artifact must also + contain complete canary-readiness timing: route submission through both searches, comparison, + selection/fallback and upstream materialization. +6. **Evidence-scoped rollout:** accepted F2P evidence permits only a match-gated F2P upstream-selecting + rollout. Selecting upstream for members policy requires a separate representative members-world slice + covering members-only executors, requirements and transport networks. + +The current implementation satisfies all six gates at opt-in F2P canary scope. Shadowing exists for +synchronous, ordinary active and +cave routes. Its coordinate-free coverage counters, read-only Agent Server endpoint and opt-in F2P harness +capture produced an accepted 12-session aggregate covering recovery, transport, item-gated, bank-workflow, +surface, underground, walking-only, live-collision, active-route/replan, arrival and executor-diversity +minimums without a semantic divergence or planner failure. Five clean samples from the exact evaluated +revision pass gate 3 with a `0.407` upstream/local comparable-suite median ratio and no correctness, absolute +or per-case threshold failure. +The explicit `UPSTREAM_F2P_CANARY` selector now keeps members policy local, waits for both F2P candidates +before atomically publishing an active route, selects upstream only on a semantic match and records distinct +divergence/failure local fallbacks. A live underground object-transition run selected upstream 10/10 times +and arrived 10/10 times. A separate test-only forced-failure run selected upstream 0/10 times, recorded ten +failure fallbacks and still arrived 10/10 times. A fresh pair with aggregate canary-readiness telemetry passes: +normal readiness averaged `304.3 ms`, peaked at `645.7 ms` and averaged `172.0 ms` of non-search overhead; +forced rollback averaged `260.8 ms`, peaked at `675.1 ms` and averaged `123.4 ms` of non-search overhead. +Production default/release selection remains an explicit release decision; passing these gates does not change +`LOCAL` automatically. + +The sanitized source snapshots and generated reports are tracked under +`docs/evidence/walker/2026-08-05/`. The tracked inputs reproduce the accepted live-shadow and paired rollback +reports byte-for-byte with the commands in that directory's README. Raw client logs, coordinates and account +or profile data are intentionally excluded. + +## Performance protocol + +Generate independent comparison reports from the exact code under review: + +```bash +for sample in 1 2 3 4 5; do + scripts/compare-shortest-path-planners.py \ + --require-all \ + --output-dir "build/shortest-path-performance/sample-${sample}" +done + +scripts/report-shortest-path-planner-performance.py \ + build/shortest-path-performance/sample-*/report.json \ + --json-output build/shortest-path-performance/evidence.json \ + --markdown-output build/shortest-path-performance/evidence.md +``` + +The checked-in evaluator requires: + +- five or more reports with identical local, upstream, RuneLite, corpus and adapter-patch identities; +- a clean local worktree for every sample; +- no correctness, packaged-adapter or unsupported-capability failure; +- an upstream/local median comparable-suite elapsed ratio no greater than `1.5`; +- for each comparable case, an upstream maximum no greater than both `2,000 ms` and the larger of three + times the local maximum or a `100 ms` noise allowance. + +The relative case threshold prevents a material regression from hiding in a favorable suite total. The +noise allowance avoids rejecting a sub-tick route because one engine takes a few additional milliseconds. +Thresholds are command-line options, but changing them for a decision requires an accompanying rationale. + +The independent engine measurements do not capture the time an active dual-planner canary keeps the route in +the calculating state. The live paired evaluator therefore additionally requires: + +- one `canaryPerformance` planning sample per completed comparison; +- successful upstream-search timing for every normal-canary comparison and none for the injected pre-search + failure case; +- a maximum submission-to-ready duration no greater than `2,000 ms`; and +- average non-search overhead no greater than `250 ms` per decision after subtracting both measured searches. + +The duration includes pathfinding-executor queue time, both searches, semantic comparison, selection or +fallback and upstream-route materialization. The readiness/local-search ratio remains diagnostic, while the +explicit overhead allowance avoids a fixed-cost short route failing solely because its local search is fast. +Change either threshold only through the evaluator arguments and record the release rationale with the +evidence. + +Bank-aware cases are excluded from core timing because Microbot currently composes direct, to-bank and +from-bank searches while upstream models bank state in one graph search. Their route semantics remain part +of the correctness gate. The documented Twinflame provider-policy divergence is also excluded. Node counts +and peak-heap deltas remain diagnostic: the engines use different graph structures and independent JVM heap +baselines, so those values are not interchangeable resource measurements. + +## Live shadow protocol + +For every evidence slice, start a fresh client so its process-lifetime counters describe one review session, +enable the developer-only shadow mode and verify collection before running routes: + +```bash +./microbot-cli plugin-config set shortestpath plannerSelectionMode SHADOW +./microbot-cli walker shadow +``` + +The schema-versioned endpoint reports only invocation and route-property tags; it does not expose route +coordinates, target names, paths or exception messages. It distinguishes deliberate recovery from ordinary +replanning, reports selected transport executor/type families and counts a live-collision route only when the +pinned overlay actually answers at least one collision-edge read. Exercise ordinary surface routes, +underground/cave routes, selected transports, bank-item planning, live collision and deliberate +recovery/replanning. Let the one-worker shadow queue settle, then capture it. The evaluator accepts multiple +fresh-session snapshots so resource- or account-specific harnesses remain isolated: + +```bash +./microbot-cli walker shadow > build/walker-shadow-underground.json +scripts/evaluate-walker-shadow-evidence.py \ + build/walker-shadow-underground.json \ + build/walker-shadow-teleport.json \ + build/walker-shadow-network.json \ + --json-output build/walker-shadow-evidence.json \ + --markdown-output build/walker-shadow-evidence.md +``` + +Every input is validated independently before aggregation. All inputs must use schema v2 and the same pinned +candidate engine, every queue must be settled and each `startedAtEpochMillis` must be unique so the same +client session cannot be counted twice. A divergence, planner failure or invalid accounting in any member +session rejects the aggregate; splitting scenarios across clients does not relax any total or category +threshold. + +The default gate requires at least 100 completed comparisons and zero divergences or planner failures. Its +overlapping coverage minimums are 75 ordinary active routes, 15 ordinary active replans, 10 recovery-triggered +replans, 60 surface routes, 20 routes using underground coordinates, 10 walking-only cave selections and 20 +routes selecting transports. Five routes must select an item- or fare-gated transport. Ten comparisons must +be the explicit bank-to-target leg of `compareRoutes`, and five of those must select an item- or fare-gated +transport. This proves the bank workflow was compared without mislabeling every request with the bank setting +enabled as bank-dependent. Twenty-five routes must actually consult captured live-collision edges; merely +turning the setting on does not count. + +Transport evidence must cover at least four distinct executor families. The default grouped minimums are five +local-transition routes (`OBJECT` or `BARROWS_DIG`), five teleport routes, five network routes and three +terminal-travel routes. Per-executor and per-type counters remain available for reviewing the concrete mix. +The accepted fresh-client inputs must contain at least 50 terminal blocking walks that arrived and at least +five arrivals after recovery was actually triggered. A recovery-triggered `UNREACHABLE` or `EXIT` rejects +the evidence; other terminal non-arrivals remain visible warnings for manual classification. +Terminal outcomes are route-scoped: the blocking walk captures whether its active logical route actually +entered planner comparison before arrival clears that route. A members-policy walk in +`UPSTREAM_F2P_CANARY`, or any walk in `LOCAL`, must not increment these counters merely because the +process-wide mode supports comparison. `SHADOW` routes and eligible F2P canary routes do increment them. +The candidate engine ID must equal the pinned reviewed commit, shadow mode must still be enabled and no +comparison may be pending at capture. Stale and discarded work is reported as sampling telemetry; it does not +become a semantic mismatch, and it does not count toward completed coverage when discarded before execution. + +Exact walking-path equality is diagnostic rather than a semantic rejection because equal-cost tie paths are +valid. The endpoint reports a coordinate-free `routeShapeDifferences` total and preserves the most recently +completed differing comparison as `latestRouteShapeDifference`, even after the active route is torn down. +Every counted shape difference has already matched termination, endpoint, route cost and exact selected +transport identity; it is therefore an equal-cost route-shape alternative, not a semantic mismatch. Before a +rollout decision, classify the observed volume by invocation and by walking-only, transport, surface and +underground coverage. Inspect at least one representative route from every non-zero class and explain any +class responsible for more than one quarter of completed comparisons. A class associated with a non-arrival, +recovery regression or repeated visible detour remains rejecting until explained or fixed. + +## Staged rollout and fallback sunset + +Planner selection uses one explicit mode: `LOCAL`, `SHADOW` or `UPSTREAM_F2P_CANARY`. `LOCAL` remains the +default. `SHADOW` always executes local and compares upstream asynchronously. `UPSTREAM_F2P_CANARY` is +eligible only when the resolved immutable policy is non-members; it calculates both candidates and keeps the +active route in the calculating phase until selection completes. A semantic match may select the exact +upstream route. A planner failure or semantic divergence retains local and increments a dedicated fallback +counter; members requests remain local and are not canary comparisons. A route-shape-only difference remains +a semantic match when termination, endpoint, cost and exact selected transports agree. + +The first upstream-selecting release is the F2P match-gated canary. Comparison remains enabled for eligible +production requests, with `LOCAL` selectable independently of the upstream source set. This canary is not yet +an upstream-authoritative mode: a different route cost or selected transport remains a divergence and retains +local, even when the upstream result might be a genuine improvement. Local fallback is +conservative containment, not evidence that local is the correct oracle; every divergence still requires +review and remains rejecting release evidence. The upstream result is temporarily materialized into the +legacy completed-pathfinder view for existing runtime consumers. Remove that compatibility shell with the +local core when the fallback sunset is reached. + +### Post-canary authority contract + +Do not implement an authority mode as "select upstream unless it throws." Before an `UPSTREAM_F2P` mode can +be enabled, all of the following must be implemented and tested: + +1. An engine-independent validator must prove that the candidate belongs to the resolved request and immutable + planning snapshot, has a coherent termination and endpoint, has contiguous route steps, and uses only exact + admitted transport identities with an executable Microbot owner. +2. A digest-pinned reviewed-divergence policy must distinguish an approved improvement from an unexplained + semantic difference. Approval requires a route-property regression and representative live execution for + every newly admitted divergence class; a broad "shorter is safe" rule is insufficient. +3. Failure, invalid materialization, an invalid candidate or an unclassified divergence must execute the local + result for the current request and emit a release-visible rollback signal. Selection and fallback counters + must distinguish match-gated canary decisions from authoritative reviewed-divergence decisions. +4. Dual-planner telemetry and the release-independent `LOCAL` control remain enabled throughout the staged + authority period. Adding the enum/config value does not authorize changing the default. + +Only releases in which the authority mode can select a reviewed valid divergence count as +"upstream-authoritative" for the two-release/1,000-comparison fallback sunset. Match-gated canary releases do +not start that clock. + +The vendored delta is also bounded: six upstream files may be patched and one adapter-only source may be +added. `scripts/check-shortest-path-vendored-core.py` rejects a larger surface. Raising the budget requires a +dated ADR amendment and a contribution-or-external-adapter assessment, so upstream convergence cannot quietly +turn into another growing local planner fork. + +The local core becomes eligible for deletion only after every supported rollout scope has completed at least +two upstream-authoritative releases and at least 1,000 settled production dual-planner comparisons without an +unresolved semantic divergence or planner failure. The release test must prove the independent rollback path, +and no open walker incident may require the local core. Once these conditions pass, remove the local planner in +the next planned release. Any extension requires a dated ADR amendment identifying the incident, owner and new +expiry condition. + +Semantic divergences and planner failures are always rejecting outcomes. The endpoint preserves their latest +coordinate-free summaries as `latestDivergence` and `latestFailure` for the process lifetime, including after +active-route teardown. The evaluator rejects a non-zero terminal counter that lacks its corresponding +diagnostic; a failure exposes only the exception class, never its message or route data. + +## Current signal + +Five clean samples from the exact evaluated revision pass the performance evaluator with no correctness or +threshold failure. The comparable-suite median is `1,365.3 ms` locally and `555.2 ms` upstream, an +upstream/local ratio of `0.407` against the `1.5` limit. Every comparable case also passes its absolute and +relative maximum. The durable aggregate is +`docs/evidence/walker/2026-08-05/performance-evidence.json`. + +Twelve independently validated fresh sessions aggregate to 141/141 semantic matches, 71/71 exact walker +arrivals and zero divergence, planner failure, pending, discarded, unreachable or exit outcomes. All live +minimums pass: 75 ordinary active routes, 15 ordinary active replans, 11 recovery replans, 102 surface +comparisons, 39 underground comparisons, 18 walking-only cave selections, 97 transport selections, 59 +item- or fare-gated selections, 10 explicit item-gated bank-to-target legs and 137 live-collision-consulted +searches. The executor evidence covers object transitions, spell teleports, canoes and terminal travel, so all +four grouped minimums and the four-executor diversity minimum pass. Two completed comparisons were stale +after deliberate route replacement; they remain valid completed semantic results but do not represent the +latest route generation. + +Seventy-six comparisons used a different exact path while still matching termination, endpoint, route cost +and exact selected transports. The retained coordinate-free diagnostics classify these as equal-cost +route-shape alternatives. Thirty-one occurred in two transport-free surface replan/recovery sessions where +every comparison differed; both sessions still completed all blocking walks, including six recovered +arrivals. Thirty-three occurred in the bank/canoe session, whose retained representative selected the same +exact canoe and cost; that session completed 49/49 comparisons and all six walks. The remaining twelve were +spread across surface active-route/replan slices; retained representatives cover spell, canoe and +transport-free alternatives. None of the 39 underground comparisons differed—the two sewer-session shape +warnings came from their surface setup legs. No shape-only class coincided with a non-arrival, recovery +regression or visible route failure. +A prior canoe session recorded one non-canoe semantic divergence but did not retain the superseded comparison, +so it remains rejected historical evidence and is not part of the aggregate. The rebuilt three-repetition +canoe session completed 7/7 comparisons and 6/6 walks without reproducing it; any recurrence will now retain +its mismatch category. + +The first dedicated bank session then exposed a reproducible local-core defect: all ten bank-to-target +comparisons selected the farther Barbarian Village canoe at cost 130 locally while upstream selected the +nearer Edgeville canoe at cost 78. The local frontier's geometric heuristic was not admissible in a graph with +long-distance transports. Cost-ordering the local frontier restored exact parity, and a real catalog/collision +regression pins that route. The rebuilt session completed 49/49 matching comparisons, including 10/10 explicit +item-gated bank-to-target legs, and all six terminal walks arrived. The rejected pre-fix session is not part of +the aggregate. + +The headless correctness and live-shadow gates are closed, and the exact current tree passes the clean timing +thresholds. The opt-in F2P selector and independent rollback path are implemented and live-validated on the +underground F2P-17 route: the normal canary recorded 10/10 upstream selections and arrivals with zero +divergence/failure; the forced-failure run recorded 10/10 local failure fallbacks and arrivals with zero +upstream selections. Both were repeated after terminal evidence became route-generation scoped and retained +all 10 eligible arrivals; focused members-canary and local-mode regressions prove ineligible walks leave the +execution totals unchanged. The fresh paired release evaluator accepts the normal and forced-failure artifacts +with complete submission-to-ready telemetry and no failure, shortfall or warning. The normal canary averaged +`304.3 ms`, peaked at `645.7 ms` and averaged `172.0 ms` of non-search overhead; rollback averaged `260.8 ms`, +peaked at `675.1 ms` and averaged `123.4 ms` of non-search overhead. `LOCAL` remains the default. The evidence +permits an explicitly approved F2P-scoped release; it does not permit members-policy selection or silently +change the default. diff --git a/docs/walker-roadmap.md b/docs/walker-roadmap.md new file mode 100644 index 00000000000..9c6affc367b --- /dev/null +++ b/docs/walker-roadmap.md @@ -0,0 +1,560 @@ +# Walker Roadmap + +_Canonical plan. Last reviewed: 2026-08-05._ + +## Direction + +Microbot is not a source fork of `Skretzo/shortest-path`, but it must not drift from it silently. +The ownership boundary is: + +- **Shortest Path upstream:** primary reviewed external reference for static collision and + transport/teleport semantics, and the maintenance-preferred candidate planner rather than merely a + source of occasional fixes. This preference does not predetermine the production engine, and upstream + data is not automatically authoritative for Microbot execution. +- **Microbot:** route execution, live collision, obstacle interaction, recovery, banking and automation + policy. +- **Import rule:** review upstream changes by behavior and data semantics. Never overwrite Microbot + executor behavior or local restrictions merely to make the trees look alike. +- **Planner rule:** choose the production engine from correctness, runtime reliability, performance and + maintenance evidence gathered through the same engine-neutral boundary. New local search algorithms + require a pinned incident or benchmark rather than divergence for its own sake. + +The reviewed upstream commit and resource blobs live in +`scripts/shortest-path-upstream-baseline.json`. Run: + +```bash +scripts/check-shortest-path-upstream.py +``` + +The command exits `2` when upstream moved and prints changes in tracked planner/data scopes. Use +`--json` for other automation or `--allow-drift` for an informational local check. The scheduled +`shortest-path-upstream-drift.yml` workflow runs it weekly and fails visibly when review is due. + +The production-packaged planner core has a separate offline integrity gate: + +```bash +scripts/check-shortest-path-vendored-core.py +``` + +This pins all 50 vendored Java files and adapter metadata. Passing `--upstream-checkout` additionally proves +every undeclared source is byte-identical to the exact reviewed checkout, the declared patch/addition lists +are complete, and the bundled license is unchanged. + +Transport convergence has a second, stricter contract: + +```bash +scripts/compare-shortest-path-transports.py --check-baseline +``` + +This compares routes by semantic identity, including named network endpoints when Microbot models a +different boarding or landing tile. It compares interaction object, requirements, fare/items, +membership, wilderness limit, consumability and duration separately. The exact reviewed debt is pinned +in `scripts/shortest-path-transport-baseline.json`; counts and content digests make route swaps visible, +not merely changes to the total. Updating that baseline requires reviewing the changed identities and +field dimensions. Intentional differences may carry a rationale bound to their exact field-drift digest; +the check fails when the underlying routes or fields change. Unexplained debt remains unresolved rather +than implicitly accepted, so the baseline is not an allowlist or a claim that remaining differences are correct. +For agility, RuneLite's explicit course-obstacle catalog classifies course traversal separately while +retaining those identities in total upstream-only debt; both the classified set and full set are digest-pinned. + +## Current state + +### Upstream convergence + +- Reviewed upstream: `Skretzo/shortest-path@ff8e961b32120175709df9630ece9468cc11347f`. +- Collision map imported from that commit after the planner core, route corpus and benchmark all + passed against it. It expands the map from 2,724 to 2,726 regions. +- All four spellbook home teleports are represented in the catalog. Microbot deliberately keeps one row + per spellbook and does not copy upstream's animation-setting variants; animation display settings must + not change route availability. They share an exact-name, zero-rune widget executor because RuneLite's + `MagicAction` catalog only represents Lumbridge. Spellbook, quest, cooldown and Wilderness requirements + remain catalog/planner gates rather than being weakened to accommodate that UI difference. Lumbridge + has live round-trip evidence; the alternative-spellbook variants still require direct live evidence. +- Microbot-only `blocked_edges.tsv`, `dangerous_tiles.tsv`, `npcs.tsv` and `restrictions.tsv` remain + local policy/data overlays. +- At the reviewed commit the semantic inventory has 6,601 shared route identities, 1,016 + upstream-only identities, 952 Microbot-only identities and 1,379 shared identities with at least one + comparable field difference. These totals include unresolved representation differences and are a + debt inventory, not a walker-quality score. +- `hot_air_balloons.tsv`, `magic_mushtrees.tsv`, `minecarts.tsv` and `teleportation_levers.tsv` have + exact compared parity. The Lovakengj minecart import also fixes the old unconditional 20-coin fare: + paid routes require varbit `7796<11`, while free routes require `7796=11`. +- Minigame teleport identities now fully match upstream: Guardians of the Rift lands inside the Temple + of the Eye, the Keldagrim Rat Pits destination is restored, and Varrock uses the current landing. The + transport model now enforces upstream's special Total level, Combat level and Quest-points + requirements; this closes the previously ignored 40 Combat gate for Pest Control. +- The first teleport-item schema slice is behavior-compatible: explicit requirements preserve AND + groups, OR alternatives and quantities, including upstream's maximum-quantity rule within an OR + group, while legacy semicolon rows retain their historical any-item behavior. Pathfinder + availability, refresh snapshots, bank withdrawal planning and Slayer transport preparation use the + structured requirements rather than flattening them. +- The symbolic requirement adapter now resolves pinned walking collections for axes, pickaxes, + machetes, grapple gear, keys, passes, currencies and the upstream cape/apron families. Unsupported + symbols fail closed. Rune collections delegate to the canonical `Runes`, `Rs2Staff` and `Rs2Tome` + catalogs and retain raw/combo-rune alternatives separately from equipped staff and offhand providers. + The owned route edge preserves those source semantics, and bank preparation produces one immutable + withdrawal-and-equipment loadout so an inventory staff is never mistaken for an active provider. + All 43 shipped non-home spell rows now carry the reviewed upstream item requirements without changing + Microbot's existing landing coordinates; coordinate drift remains a separate evidence-gated decision. +- All 45 River Lum and River Dougne canoe routes have exact compared field parity, including the full + twelve-axe collection and upstream route costs. The executor selects `CanoeMapLum` or + `CanoeMapDougne` from the reviewed station object family, unknown stations fail closed and the route + corpus proves selection of the western chain. Live River Dougne execution remains required. +- Barrows now has twelve deliberately Microbot-owned static edges because the reviewed Shortest Path + artifact has no mound, individual-crypt or tunnel transitions. Six exact mound-to-crypt pairs require + a spade and use a dedicated inventory-item executor; six staircase edges return each individual crypt + to a representative anchor on its own surface mound. The exit spawn may vary within the mound, so + runtime completion uses the existing bounded landing tolerance and then replans from the observed tile. + The route corpus proves every mound/crypt pair and separately proves that no sarcophagus becomes a + static tunnel edge. Quest Helper observes which sarcophagus is empty per run, so randomized tunnel + entry remains fail-closed pending a state-aware executor and live evidence. One mound round trip is + still required to verify the current staircase action, surface landing envelope and handoff logs. +- The direct Max-cape family and Quest-point cape now match the reviewed upstream identities and + requirements. Four Max-cape destinations were restored, a duplicate Black chinchompa row was removed, + and nested display labels resolve to their executable leaf action. POH home variants remain under + Microbot's programmatic POH ownership. +- The Quetzal network has exact compared parity across all 28 station-side route identities. All 14 + whistle destinations, items and unlocks are represented inline; their 14 reviewed field differences + are intentional because Microbot splits upstream's family-level consumable row into charged and + perfected-infinite variants. This keeps item `33120` usable under Inventory (perm). The Gorge landing + uses the current tile, Cam Torum uses the live map label and unlocks use the canonical varplayer bitmask. +- Laguna Aurorae now has all nine reviewed object-`26262` outbound spirit-tree perimeter origins rather + than only an inbound destination. A resource-backed route proves the north-west approach can select the + network and reach the Grand Exchange. Spirit-tree upstream-only debt falls from 11 identities to the two + POH directions that remain intentionally programmatic; the corpus rejects static POH duplicates. The + current `Travel` action and `E: Laguna Aurorae` destination label still require a live round trip. +- The ordinary-transport remainder is now fully classified. Two Elemental Workshop wall directions use + the current object `26115`, a curated collision-edge override and the concrete battered-key requirement; + the upstream steel-key-ring alternative remains fail-closed because possession of an arbitrary ring does + not prove that this key is stored on it. The remaining 15 upstream-only identities are digest-pinned as + four superseded Piscatoris gate anchors, eight id-less Marim staircases, one interaction-less Daero jump + and two intentionally disabled Varrock Palace trellis routes. Static route coverage proves that the + battered key selects the wall edge while a key ring alone cannot. Live wall interaction remains pending. +- Four missing Pandemonium ship routes now connect Port Sarim and Musa Point through the exact reviewed + Captain Tobias, Customs officer and Seaman Morris interactions. Each direction is gated by the + `Pandemonium` quest and a 30-coin fare, uses the direct terminal-travel executor and is selected only + when both prerequisites are available. The six remaining upstream-only ship identities are fully + classified as Microbot's current Corsair Cove, Ardougne and Void Outpost deck/landing representations. + Static route coverage proves exact edge selection and fail-closed prerequisite behavior; a live + Pandemonium round trip remains pending. +- The semantic comparator no longer treats a column missing from one schema as an empty requirement. + This removes false membership drift from item/minigame/portal families without hiding fields that are + present in both artifacts. +- The first agility-shortcut correctness slice fixes all 13 shared item-field differences: twelve grapple + edges require both a supported crossbow and a mith grapple, while the Trollheim rope edge retains both + its rope item and attached-rope varbit. The Lumbridge-farm fence and northern Varlamore rocks use their + current upstream landings and durations, with route-corpus tests proving the planner selects each edge. + RuneLite's `Obstacles.OBSTACLE_IDS` authoritatively classifies 114 of the 231 remaining upstream-only + identities as known course traversal; they stay visible in total debt but are not bulk-import candidates + for the general walker graph. The other 117 remain ordinary-world or unresolved representation debt. + Three Trollheim climbing-rock ascents were moved from generic transports to boots-gated agility edges, + while their unrestricted descents remain generic; route coverage proves the asymmetric policy. The full + 88-edge Isafdar forest family now uses current upstream landings, Agility gates and traversal durations + instead of unconditional generic transports; this adds 22 missing edges and removes four stale landing + variants. A real three-obstacle dense-forest route pins the imported chain. The remaining 28 agility + identities that Microbot had represented as unrestricted generic transports now retain upstream's + requirements across Brimhaven Dungeon, the Lumbridge cellar, Karamja rocks, Slayer Tower and Darkmeyer. + Catalog assertions pin all levels, durations and unlock varbits, while one real route per family proves + planner selection. The comparator separately classifies this cross-file bypass pattern and pins it at + zero; upstream's two intentional Darkmeyer diagonal generic approaches remain exactly once alongside + their gated shortcut variants. Live interaction evidence for the corrected fence, rocks, grapple, rope, + Isafdar and these five converted families remains pending. + +### Executor reliability + +- Live collision is enabled by default, revision/version keyed and prunes stale captures. +- Door and raw-scene observations are cached to bound repeated scanning. +- `Rs2PathApi` is the compatibility seam around mutable shortest-path plugin state. No in-tree consumer + outside the facade or shortest-path implementation now constructs or reads `Pathfinder` or imports and + mutates `PathfinderConfig`; legacy concrete accessors remain public only for binary compatibility. + Presence-only recovery, obstacle and door-catalog queries now use named facade operations or immutable + `Rs2TransportEdge` views; the hot-air-balloon handler consumes its owned selected edge directly. Concrete + `Transport` dependencies still exist in behavior-bearing execution handlers, planner overlays and legacy + compatibility APIs, so the final stable boundary remains intentionally narrower than the current + compatibility surface. +- Synchronous planning now dispatches through the engine-neutral `Rs2RoutePlanner` contract. Before an + engine receives a request, `Rs2PathApi` resolves an immutable `Rs2RoutePolicy` containing bank visibility, + Wilderness and dangerous-NPC policy, teleport policy, membership, live-collision enablement, cutoff, + enabled transport families and restricted points. Engines reject unresolved requests instead of reading + mutable plugin globals. The local dual-engine runner uses this same contract rather than constructing its + planner directly. Its selected immutable edge retains the exact local source object only as a package-private + opaque identity, so comparison and execution never rematch ambiguous endpoints. +- Microbot executor capability is injected into `PathfinderConfig` through `TransportPlanningPolicy` at plugin + composition. The pathfinder core no longer imports `TransportExecutionRegistry`; a boundary regression + rejects that coupling if it returns. Headless planner tests can admit an explicit synthetic catalog, while + production continues to fail closed through `Rs2TransportPlanningPolicy`. +- Planner termination is now explicit. The local pathfinder distinguishes target reached, exhausted + graph, cutoff, cancellation and caught failure; `Rs2RouteResult` exposes the same states through a + Microbot-owned enum instead of treating every stopped worker as a successful search. +- Completed-path materialization is now keyed to the exact final `Node` identity rather than a shared + dirty boolean. A live forced-recovery run exposed a race where an older reader could clear the dirty + flag after a newer best node was published, leaving the point path older than its edge sequence; the + strict contiguous-route invariant and a focused regression now pin the fix. Blocking walks also treat + up to two client-thread read timeouts as transient, retain their active route and use a bounded + collision-free route-index fallback for the failed reachability read. Repeated stalls still fail visibly. +- Local frontier ordering now uses travelled cost rather than the old geometric A* score. A live bank-leg + shadow run proved that the heuristic was not admissible once long-distance transports were present: the + local core chose the farther Barbarian Village canoe at cost 130 while the pinned upstream core chose the + nearer Edgeville canoe at cost 78. A real collision-map/canoe-catalog regression now pins exact edge and + cost parity. The accepted rerun matched all 49 comparisons, including all ten explicit bank-to-target legs, + and all six terminal canoe/setup walks arrived. +- Chosen routes now retain typed walking and transport edges. Forward and bidirectional reconstruction + preserve the exact local `Transport` selected by the search, while `Rs2RouteResult` exposes only + immutable Microbot-owned edge, transport and item-requirement values. Destination bank-item planning + consumes those exact edges for fare, rune, fairy-ring, purchasable-item and AND/OR item-requirement + selection. The transitional `LegacyRoutePlan` handoff has been removed and the boundary checker rejects + its reintroduction. The old public `Transport`-returning helper remains deprecated for Hub binary + compatibility; it is not used by the active banking flow or accepted as an executor contract. +- Transport executability is now explicit. `TransportExecutionRegistry` rejects catalog rows that have + no live Microbot handler before pathfinding, and each immutable `Rs2TransportEdge` records its + planner-independent `Rs2TransportExecutor`. The exact local `Transport` remains only as an opaque + package-private handler payload because POH transports carry executable subtype behavior. This boundary + is recorded in `docs/decisions/adr-0005-walker-transport-execution-boundary.md`. +- The catalog capability audit makes terminal-travel debt explicit instead of treating a whole resource + family as executable. Every spell teleport and all 225 directed hot-air-balloon edges have an explicit + executor. Forty-one multi-step terminal rows remain deliberately fail-closed: 30 multi-destination + Boat/Boaty `Board` rows, six destination-selecting Rowboat rows and five unimplemented `Talk-to` rows. + The exact interaction groups are test-pinned. Balloon dispatch recognizes the base baskets and + their six unlocked-station transforms, selects one of the six exact RuneLite map components and requires + an observed landing before completing the edge. The dual-engine corpus pins Castle Wars-to-Varrock + selection and cost. Live prerequisite evidence also proves the planner fails closed: on an account with + no unlocked balloon stations, enabling the feature evaluated all 225 edges and admitted zero. A successful + map interaction and landing still require direct evidence on an account with an unlocked station. +- Route results now expose planner-independent cost, explored-node, checked-transport and elapsed-time + metrics. Missing engine measurements remain explicitly unavailable rather than being reported as zero. +- The pinned dual-engine adapter runs the local planner and the reviewed upstream commit from one + immutable policy corpus. Overland, underground, surface-to-underground-to-surface, unreachable, + Wilderness-interior, ambiguous network, hot-air-balloon, start-at-bank and separate-bank-detour cases + plus carried/banked raw-rune, separate staff/tome and missing-ordinary-item spell cases agree on reachability, + termination, exact selected transport IDs and path cost. One reviewed divergence is pinned explicitly: + upstream consumes its one allowed staff substitution on the first elemental clause and therefore rejects + a Twinflame staff for a spell requiring both fire and water; Microbot deliberately reuses the same selected + combination staff across every clause it provides, matching the executable loadout. The 16-case corpus + therefore requires 15 exact parity results and one documented expected divergence; an unexpected new + difference or disappearance of the reviewed difference fails the harness. The production-packaged upstream + adapter and the independently compiled evaluation checkout both carry the selected `Transport` reference + through `NodeGraph` into `PathStep`; each maps that exact object to a corpus or execution identity and never + rematches by endpoints. The harness now fails when the packaged adapter differs semantically from the + independent pinned build, except for the same explicitly documented input-policy divergence. It records + node, elapsed and peak-heap diagnostics without treating noisy performance measurements as correctness + gates. For bank detours the local workflow composes direct and bank-leg searches while upstream tracks + bank state inside one search; their final route behavior is comparable, but their node/time measurements + are not planner-core parity. +- `SHIP`, `NPC` and `BOAT` describe the journey, not whether the live target is an NPC or scene object. + Immutable selected edges therefore carry both `TERMINAL_TRAVEL` and an explicit interaction mode. + Direct travel resolves the configured name/action against both target kinds near the selected origin; + Mountain Guide rows use the registered dialogue-destination mode, and unknown flows fail before + planning. Travel attempts are terminal for the current path scan, and one exact selected + edge can be clicked at most once per top-level walk invocation. Legacy `SHIP` destination labels retain + their configured action first and may fall back only to the live `Travel` action; explicit dialogue, + quick-travel and boat actions are never replaced. Arrival accepts the catalogued landing or its immediate + planned continuation, covering current ships that auto-complete an obsolete deck/gangplank pair without + scanning arbitrary later route points. Live Port Sarim-to-Musa Point and reverse walks each selected the + exact ship edge, issued one `Travel` interaction, observed the current ground landing and completed the + transport handoff without a timeout. +- The Al Kharid/Tempoross Ferry is the representative direct object-backed `BOAT` row. Static route-corpus + coverage selects it, transformed-object matching is semantic (`Ferry` + `Board`) and completion requires + the exact catalog landing. A rebuilt live attempt correctly admitted zero boat rows on a free world; + the available profile was rejected by the game server when moved to a members world, so no ferry + interaction occurred and successful outbound/reverse evidence remains pending. +- The Al Kharid toll-gate incident now has one execution owner and a fail-closed completion contract. The + raw door scanner defers this catalog edge to the selected transport executor. That executor resolves the + current transformed scene object by `Gate` name, configured action and exact edge geometry instead of + trusting historical object ids, which collided with an unrelated live object in the current client. A + ranged click may server-walk before presenting its confirmation, so completion waits for movement/dialogue + onset and requires the exact selected tile on the opposite side. Unit tests reject adjacent-origin, + wrong-name, wrong-action and wrong-location false positives. Rebuilt live walks in both directions selected + the Gate, issued the configured toll action once, reached the exact opposite-side destination and emitted + the expected handoff without an unresolved timeout or generic-door interception. +- The Varrock Sewers manhole incident removed a Microbot-only `Open;Manhole;881` row that incorrectly + promoted object-state preparation into a surface-to-underground graph edge. The catalog now retains only + upstream's traversing `Climb-down;Manhole;882` edge; the existing closed-object mapping may open object + `881`, refind `882` and then execute the selected transition. A catalog regression rejects reintroducing + the preparation edge, and the F2P live harness completed five of five exact arrivals at `3237,9858,0` + without an unresolved trapdoor wait or route stall. +- The route corpus proves selection of each terminal travel family and retains Microbot's explicit + Port Sarim-to-Musa Point ship-deck/gangplank edge. It also proves both directions through the east + Draynor sewer transition and the underground corridor to the west sewer. + +## Workstreams + +### Current priority: finish the F2P selection evidence before expanding breadth + +The ship slice is the last opportunistic broad transport-family import before the engine decision. Do not +resume copying families merely to reduce tree drift or add local search algorithms until the following +production-adapter slice is complete. Adapter- or gate-required parity work, incident fixes and reviewed +collision updates continue through the same evidence boundary: + +1. **Complete:** both local and reviewed-upstream engines consume the same resolved `Rs2RouteRequest` and + `Rs2RoutePolicy` contract. +2. **Complete:** the already-filtered executable catalog, live-collision view and Microbot restrictions are + projected into an engine-neutral immutable planning snapshot; executor admission stays outside both cores. +3. **Complete for the F2P evidence scope:** default-off shadow mode covers synchronous queries, ordinary active + walker requests and cave-route selection. It compares termination, endpoint, cost and exact selected edge, + uses a bounded one-worker/one-queued-request executor, invalidates stale route generations and exposes both + the latest structured comparison and process-lifetime match/divergence/failure/discard counters. The latest + semantic divergence, planner failure and route-shape-only difference survive route teardown as coordinate- + free diagnostics. Completed + outcomes are tagged by active/query/ordinary-replan/recovery origin, surface/underground coordinates, cave + walking-only selection, selected transport executor/type, explicit bank-workflow leg and live collision + actually consulted by the local search. Equal-cost alternate walking shapes are counted separately as a + diagnostic. `microbot-cli walker shadow` exposes this evidence without coordinates, and + `scripts/evaluate-walker-shadow-evidence.py` enforces stratified recovery, bank, collision and transport + coverage rather than treating enabled settings as behavioral evidence. Blocking-walk terminal outcomes + also prove that recovery-triggered routes arrived instead of merely producing a matching replan. Their + accounting is bound to the logical route's actual comparison eligibility: members-policy walks in the F2P + canary and all local-only walks cannot inflate execution totals, while shadow and eligible F2P canary + routes retain their outcome after arrival clears the active route. The F2P + harness can explicitly enable shadowing, waits for the worker to settle and embeds the same schema-v2 + coordinate-free snapshot in its result. Twelve accepted fresh sessions now aggregate to 141/141 matches + and 71/71 exact arrivals with no divergence, failure, pending, discarded, unreachable or exit outcome. + Every live minimum passes: 75 active routes, 15 active replans, 11 recovery replans, 102 surface and 39 + underground comparisons, 18 walking-only cave selections, 97 transport selections, 59 item- or fare-gated + selections and ten explicit item-gated bank-to-target comparisons. Recovery, bank-workflow, collision, + arrival, executor-group and four-executor diversity gates all pass. Two completed results were stale after + deliberate route replacement. Seventy-six equal-cost route-shape differences remain diagnostic: 31 came + from transport-free surface replan/recovery sessions, 33 from the bank/canoe session and 12 from mixed + surface active slices; none of the 39 underground comparisons differed and no class coincided with a + non-arrival or recovery regression. A prior rejected canoe session's one non- + canoe divergence did not reproduce in the rebuilt 7/7 comparison, 6/6 arrival slice; future terminal + outcomes retain their mismatch category across teardown. + The evaluator can combine independently validated fresh-session slices only when their schema + and pinned engine match, their queues are settled and their process start identities are unique. This + avoids coupling underground, teleport, network and bank prerequisites into one brittle mega-harness while + preserving every aggregate threshold. +4. **Complete for F2P selection:** the corpus and focused adapter tests include real underground routes, + restrictions, an immutable collision override and representative executable-network slices. Repeated + Varrock Sewers sessions close the underground and walking-only live minimums, and the full 12-session + aggregate closes every F2P live-shadow threshold. Members-policy selection still requires its own + representative members-only executor, requirement and network evidence. +5. **Complete for an explicit F2P match-gated release decision:** planner selection is one explicit + `LOCAL`, `SHADOW` or `UPSTREAM_F2P_CANARY` mode. `LOCAL` remains the default. The canary is eligible only + for resolved non-members policy, waits for both candidates before atomically publishing the route, and + selects upstream only on a semantic match. Divergence or planner failure retains local while recording a + dedicated fallback; local is conservative containment rather than a correctness oracle. Exact upstream + routes are temporarily materialized through the legacy completed-pathfinder view, which is removed with + the local core after the fallback sunset. A live F2P-17 underground run made 10/10 upstream selections and + arrivals with no divergence/failure. A separate test-only forced-failure run made 0 upstream selections, + 10 local failure fallbacks and 10 arrivals. Both runs were repeated after terminal evidence was bound to + the generation-matched ready route; each retained all 10 eligible arrivals, while members-canary and local + regression routes leave execution totals unchanged. `evaluate-walker-rollout-evidence.py` now treats the + normal and forced-failure artifacts as one release gate and accepts a fresh pair only when engine/session, + route, accounting, selection, fallback, arrival, coverage, failure-opacity and submission-to-ready timing + invariants all hold. A fresh readiness-aware pair is accepted without failures, shortfalls or warnings: + normal readiness averaged `304.3 ms` and peaked at `645.7 ms`; forced rollback averaged `260.8 ms` and + peaked at `675.1 ms`, with average non-search overhead below the `250 ms` limit in both phases. Five clean + samples from the exact evaluated revision pass every correctness and performance threshold: the local + comparable-suite median is `1,365.3 ms`, upstream is `555.2 ms`, and the ratio is `0.407`. `LOCAL` remains + the default until a release explicitly selects the F2P canary. Members-policy selection still requires + representative members-only evidence. +6. **Next authority milestone:** define and implement the engine-independent candidate validator and + digest-pinned reviewed-divergence policy in `docs/walker-planner-selection-gate.md`. The current canary is + intentionally match-gated and therefore does not yet permit a genuinely different upstream route or count + toward the two-release upstream-authority sunset. Keep `LOCAL` as the default while this contract and its + rollback telemetry are incomplete. + +### 1. Keep planner data current + +1. Run the drift checker during walker work and before releases. +2. When upstream moves, classify changed files as collision, transport data or planner logic. +3. For collision changes, run `ShortestPathCoreTest`, `WalkerRouteCorpusTest` and + `PathfinderBenchmarkTest` against the candidate map before import. +4. For transport changes, convert by semantic identity (origin, destination, requirements), then add a + regression assertion for every intentionally imported or rejected behavior. +5. Reduce pinned semantic debt only in an adapter-, gate- or incident-driven slice. Within that slice, first + remove comparison noise, then classify every remaining upstream-only identity as import, programmatic + equivalent, intentional exclusion or unresolved; do not resume family copying solely to lower the total. +6. Extend the pinned upstream-schema adapter one symbolic family at a time. Rune/staff/tome semantics and + the shipped spell requirements are complete; remaining unsupported slot/category collections must + continue to fail closed until their runtime semantics are explicit. Keep Microbot-only policy overlays + separate so upstream refreshes cannot erase them. +7. Continue scheduled semantic review of upstream transport and teleport changes while the engine decision + is pending. Import adapter- or incident-relevant behavior with executable coverage, and classify other + user-visible changes without broad copying merely to reduce drift counts. A baseline update may not + increase unexplained upstream-only or field-drift debt without a release-note rationale. +8. Treat agility courses separately from ordinary shortcuts. Do not infer general-walker safety from an + executable object action alone: import course traversal only after a route-level use case proves that + entering and leaving the sequence cannot strand or loop an ordinary walk. + +### 2. Grow the route safety net + +The corpus should cover route properties, not exact tile sequences. Existing coverage includes +underground routing and all three terminal-travel families. Continue adding cases for: + +- underground entrances and exits, including a surface-to-underground-to-surface journey; +- each terminal-travel family (`SHIP`, `NPC`, `BOAT`); +- newly added map regions and high-change regions from each upstream collision update; +- known incident routes before changing their recovery or collision behavior. + +Every corpus case must prove arrival or an intentional partial path and pin the transport/landmark that +makes the route valid. + +### 3. Improve executor behavior from incidents + +Prioritize reproducible symptoms over a wholesale `Rs2Walker` rewrite: + +1. Complete one unlocked hot-air-balloon flight and require logs to show the exact selected edge, one + basket interaction, the exact destination component and observed arrival. Locked-account fail-closed + evidence is complete; do not infer successful UI execution from it. +2. Extend terminal-travel live evidence beyond the completed Port Sarim/Musa Point `SHIP` incident to + one representative `NPC` and `BOAT` edge. The direct Al Kharid/Tempoross Ferry has static selection, + semantic object matching and free-world fail-closed evidence, but still needs a members-world round trip. + The dedicated shadow harness now also proves two outbound and one reverse ship selection: 3/3 exact + `TERMINAL_TRAVEL` comparisons and 4/4 blocking-walk arrivals. Both directions use one interaction per + invocation and accept the current landing without a false timeout; static planner and scan-classification + coverage remains green for all three families. +3. Add a route and live reproduction for Misthalin Mystery door transitions before changing the door + cascade. +4. Complete the remaining Barrows runtime slice. Static mound digs and individual-crypt exits are + represented and corpus-pinned; run one live mound round trip, then add randomized tunnel entry only + through observed empty-sarcophagus state. Do not encode a fixed sarcophagus-to-tunnel edge. +5. Extract a component only when a pinned incident requires changing that behavior; keep the existing + stateful ordering until its replacement has equivalent harness and live evidence. + +### 4. Observability + +Retain a compact decision trail for path recalculation, transport attempts, obstacle resolution and +learned collision. A failed live harness run must make the last planned edge, selected transport and +recovery decision discoverable without reproducing under a debugger. + +Per-edge transport rejection reasons are TRACE-only because a refresh evaluates every expanded catalog +edge. DEBUG retains aggregate per-type counts and timing, while slow refreshes remain visible at INFO; +verbose diagnostics must not make an otherwise healthy refresh exceed the walker timeout. + +### 5. Tighten the planner boundary + +Direct `ShortestPathPlugin` state access outside `Rs2PathApi` has been removed and +`scripts/check-shortest-path-boundary.py` enforces that invariant; the plugin class literal used for +plugin selection is the only non-state exception. Continue replacing the compatibility seam's concrete +internals with Microbot-owned operations and immutable values: + +1. introduce route request/result values that capture targets and planning policy without exposing + `PathfinderConfig`; the first slice is complete: immutable `Rs2RouteRequest`/`Rs2RouteResult` values + and `Rs2PathApi.plan(...)` now own synchronous refresh, search timing and honest-partial results. A fully + resolved immutable `Rs2RoutePolicy` and `Rs2RoutePlanner` engine interface now sit at dispatch; the local + engine and local side of the dual-engine harness both use them, and unresolved requests fail before search; +2. move pathfinder creation, refresh, cancellation and executor ownership behind the seam; + bank discovery, deposit-box discovery and destination transport planning no longer construct + `Pathfinder` directly, and temporary bank-item policy is serialized on the walker mutex and restored + inside the API. The synchronous `Rs2Walker` query helpers (`getTotalTiles`, `canReach`, `getWalkPath`, + multi-target reachability/distance and nearest-accessible-target selection) now use the same immutable + request/result operation; the boundary checker rejects new local-core construction in that class. + Bank-aware queries restore both the previous bank policy and the caller's refresh target. Active route + restart, cancellation, configuration refresh, cave walking-only selection and executor ownership now + also live behind `Rs2PathApi`; the lifecycle package has no concrete planner/config dependency and the + boundary checker enforces that state. Cave selection evaluates every requested target rather than the + iteration order's first target. NPC location selection and direct-vs-bank route comparison no longer + mutate the shared bank-item flag: each route leg declares its policy in its request, restoration avoids + a redundant refresh when the policy was already active, and banking diagnostics consume the exact typed + edges selected by that leg. Active-route consumers outside the shortest-path implementation now read a + generation-tagged immutable `Rs2ActiveRouteStatus` with explicit absent/calculating/ready phases, copied + raw and smoothed paths, owned termination and metrics. `Rs2Walker`, Quest Helper and obstacle handling no + longer import or inspect `Pathfinder`; generation-aware waits cannot accidentally accept a superseded + calculation. Slayer item preparation no longer flips shared bank policy and has an exact immutable-edge + replacement for its deprecated concrete-transport API. The boundary checker now rejects direct active + planner reads everywhere outside the facade, and rejects mutable planner configuration in bank, + deposit-box, NPC, Slayer, banking, lifecycle, walker and Leagues scopes. The walker's collision + preflight, dangerous-NPC recovery policy, restriction refresh, learned-edge recording, spirit-tree + policy, Wilderness classification, teleport-item classification and post-bank inventory-only switch + are named facade operations rather than configuration reads. Leagues invalidation uses the same seam, + while its catalog injection receives only a transport-usability predicate from the local engine instead + of the mutable config. No production code outside the facade or shortest-path implementation imports + `PathfinderConfig`. Remaining work is to decide which concrete-transport overlays are implementation + internals and migrate only the execution-facing contracts that need planner independence; +3. expose immutable transport-edge views needed by execution and banking rather than the mutable catalog; + the banking slice is complete: local path reconstruction retains exact transport identity in both search + directions, `Rs2RouteResult` exposes a contiguous immutable step list, and destination banking consumes + `Rs2TransportEdge` directly. `TransportRouteAnalysis` now snapshots the exact direct, start-to-bank and + bank-to-target step sequences; withdrawal planning consumes the compared bank-to-target sequence instead + of performing a second search from the pre-bank player location. `LegacyRoutePlan` is removed and CI + rejects both its return and the compare-then-replan banking pattern. Active completed + routes now publish an identity-checked immutable snapshot, and both raw-segment dispatch and nearby + current-tile recovery take only the exact transport selected for that route. They no longer rescan the + mutable origin catalog or infer a choice from destination membership. Each edge now carries an explicit + Microbot executor capability, and unregistered catalog rows fail closed before planning. Existing + interaction handlers receive the exact local `Transport` only as an opaque package-private execution + payload where behavior requires it; a blanket conversion of handler signatures to `Rs2TransportEdge` + is explicitly not the architecture target. Replace a payload only when its executor has an equivalent + Microbot-owned command/context interface; +4. prohibit new direct `Pathfinder`, `PathfinderConfig` and mutable transport dependencies only after each + corresponding consumer slice has migrated and equivalent route/runtime evidence exists. + +#### Concrete transport dependency inventory + +The remaining production imports are classified rather than treated as one undifferentiated migration: + +| Ownership | Remaining consumers | Decision | +|---|---|---| +| Planner implementation | `shortestpath/pathfinder/**`, `Rs2PathApi` | Allowed behind the facade. The facade maps selected/catalog entries to immutable Microbot values. | +| Behavior-bearing execution payload | `Rs2Walker`, `PohTransport`, seasonal handlers | Keep only while behavior cannot be represented losslessly by an owned command. POH subtype execution is the explicit current exception. | +| Planner overlay and evidence ingestion | Leagues injection, attempts and observations | Treat as local-engine internals for now; redesign the overlay input/output contract before attempting an engine replacement. Mutable config access is already removed. | +| Deprecated compatibility surface | `Rs2Walker.getTransportsForPath`, concrete-returning `TransportRouteAnalysis` methods, Slayer and banking concrete overloads | Preserve for Hub binary compatibility and keep out of active planning/execution flows. `TransportRouteAnalysis` itself is now an owned immutable analysis value with exact-step getters. | +| Migrated execution/catalog consumers | hot-air balloon, door probing, unified obstacle scene and route recovery | Concrete imports are prohibited by `check-shortest-path-boundary.py`. Presence checks use `hasCatalogTransportOrigin`; classification uses immutable catalog edges. | + +The bank-route distance heuristic and withdrawal selector both consume the exact ordered `Rs2RouteStep` +selected by the compared bank search; neither rematches the mutable catalog nor replans from a different +origin to guess the transport. The next execution-boundary +candidate is a seasonal or POH command contract, selected only after its full runtime inputs and success +conditions are captured in tests and live evidence. + +### 6. Evaluate planner-core convergence + +The ownership split does not justify maintaining a second planner forever. Once the request/result seam +can represent typed route edges and explicit termination without exposing either engine's classes: + +1. **Complete:** package an adapter for the reviewed upstream core and run it beside the local planner from the + same immutable requests and policy snapshots. Static collision, exact explicit-catalog network selection, + start-at-bank availability and separate-bank workflow slices are covered by + `scripts/compare-shortest-path-planners.py`. The gate also compiles an independent pinned checkout and + proves its semantic output matches the production-packaged adapter; +2. compare exact reachability, termination, selected transport identities, path cost, nodes explored, + elapsed time and peak search memory across overland, surface-to-underground-to-surface, unreachable, + wilderness, network-transport and banked/unbanked routes; +3. require preservation of Microbot-only restrictions and prove that upstream path steps contain enough + edge identity for the existing executor, recovery and observability contracts; +4. replace the local core only after the headless corpus is equivalent and the live harness passes the + interaction routes affected by the candidate. Route execution remains Microbot-owned whichever core + wins; +5. record the benchmark and acceptance decision against an exact upstream commit. A failed candidate is + evidence for a specific local requirement, not permission for untracked architectural drift. + +The reviewed upstream `PathStep` still carries only packed position and bank-visited state in its source +tree. Upstream's own display fallback documents that reconstructing a transport from adjacent steps is +ambiguous when more than one valid transport shares an edge. The evaluation checkout therefore applies +`upstream-exact-transport-identity.patch` at the exact reviewed commit: it retains the selected object in +the primitive node graph without changing search ordering or cost. A same-origin/destination corpus case +proves that the faster of two alternatives is reported exactly. This is sufficient for evaluation, but a +production planner replacement still requires equivalent metadata to land upstream or be maintained as +an explicitly reviewed adapter patch. Origin/destination rematching is not an acceptable executor contract. + +Run the current comparison against an existing exact upstream checkout with: + +```bash +scripts/compare-shortest-path-planners.py \ + --upstream-checkout /path/to/Skretzo-shortest-path \ + --require-all +``` + +Without `--upstream-checkout`, the harness clones the reviewed commit into a temporary worktree. The +`--require-all` option exits `2` as soon as a future corpus case requests a capability either engine +explicitly cannot represent; it must be used before any planner replacement decision. + +## Validation gates + +A walker change is ready to merge only when all applicable gates pass: + +1. focused unit/regression tests for the changed behavior; +2. planner core + route corpus for collision or transport-data changes; +3. `./ci/build.sh`; +4. a live harness walk when behavior touches runtime interaction; +5. upstream baseline updated only after the reviewed changes and import/rejection decisions are recorded. +6. semantic transport baseline unchanged, or deliberately updated with the reviewed route identities and + differing fields documented. + +## Historical plans + +- `runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/UPSTREAM_COMPARISON.md` + contains the detailed upstream archaeology. +- `WEBWALKER_IMPROVEMENT_PLAN.md`, `docs/walker-audit.md` and `docs/walker-p2-unification.md` preserve + earlier audits and completed/abandoned design stages. Where they conflict with this file, this roadmap + is authoritative. diff --git a/microbot-cli b/microbot-cli index 1831dbfe23c..8219e3fa396 100755 --- a/microbot-cli +++ b/microbot-cli @@ -73,6 +73,7 @@ Movement: Walk to coordinates (non-blocking by default; --wait blocks until arrival or timeout, default 30s; --distance defaults to 0 for exact coordinates) + walker shadow Planner shadow totals and route-class coverage Banking: bank Bank status (open/closed, items) @@ -368,6 +369,11 @@ case "$1" in esac ;; + walker) + [[ $# -eq 2 && "$2" == "shadow" ]] || { echo '{"error":"Usage: microbot-cli walker shadow"}'; exit 1; } + do_get "${BASE}/walker/shadow" + ;; + walk) [[ $# -lt 3 ]] && { echo '{"error":"Usage: microbot-cli walk [plane] [--wait] [--timeout SECONDS] [--distance TILES]"}'; exit 1; } walk_x="$2" diff --git a/runelite-client/build.gradle.kts b/runelite-client/build.gradle.kts index 10ba41663d9..939a3075990 100644 --- a/runelite-client/build.gradle.kts +++ b/runelite-client/build.gradle.kts @@ -54,6 +54,26 @@ plugins { } +sourceSets.named("main") { + java.srcDir("src/upstreamPlanner/src/main/java") +} + +// The pinned upstream core loads the same reviewed collision archive from its original root path. +// Keep one source artifact and copy it into both runtime resource namespaces during assembly. +tasks.named("processResources") { + from("src/main/resources/net/runelite/client/plugins/microbot/shortestpath/collision-map.zip") { + into("") + } +} + +tasks.withType().configureEach { + exclude("shortestpath/**") +} + +tasks.withType().configureEach { + exclude("shortestpath/**") +} + // Module-system flags required to extend com.apple.eawt.FullScreenAdapter on macOS // (OSXFullScreenAdapter). Without these, the JVM throws IllegalAccessError at class load. val macEawtJvmArgs = listOf( @@ -211,6 +231,51 @@ tasks.register("runUnitTests") { } } +tasks.register("exportLocalPlannerComparison") { + group = "verification" + description = "Export deterministic local planner results for the opt-in upstream comparison harness" + + dependsOn(":client:compileJava", ":client:compileTestJava") + classpath = sourceSets.test.get().runtimeClasspath + mainClass.set("net.runelite.client.plugins.microbot.util.walker.LocalPlannerComparisonMain") + + val corpus = providers.gradleProperty("plannerCorpus") + val output = providers.gradleProperty("plannerOutput") + doFirst { + require(corpus.isPresent && output.isPresent) { + "exportLocalPlannerComparison requires -PplannerCorpus= and -PplannerOutput=" + } + args(rootProject.file(corpus.get()).absolutePath, rootProject.file(output.get()).absolutePath) + systemProperty("microbot.planner.revision", + providers.gradleProperty("plannerRevision").getOrElse("unknown")) + } + + outputs.upToDateWhen { false } +} + +tasks.register("exportEmbeddedUpstreamPlannerComparison") { + group = "verification" + description = "Export results from the production-packaged pinned upstream planner adapter" + + dependsOn(":client:compileJava", ":client:compileTestJava") + classpath = sourceSets.test.get().runtimeClasspath + mainClass.set("net.runelite.client.plugins.microbot.util.walker.LocalPlannerComparisonMain") + + val corpus = providers.gradleProperty("plannerCorpus") + val output = providers.gradleProperty("plannerOutput") + doFirst { + require(corpus.isPresent && output.isPresent) { + "exportEmbeddedUpstreamPlannerComparison requires -PplannerCorpus= and -PplannerOutput=" + } + args(rootProject.file(corpus.get()).absolutePath, rootProject.file(output.get()).absolutePath) + systemProperty("microbot.planner.revision", + providers.gradleProperty("plannerRevision").getOrElse("unknown")) + systemProperty("microbot.planner.embedded-upstream", "true") + } + + outputs.upToDateWhen { false } +} + tasks.register("regenerateClientThreadGuardrailBaseline") { group = "verification" description = "Regenerate src/test/resources/threadsafety/client-thread-guardrail-baseline.txt from current sources" diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Script.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Script.java index 9994f5eb40f..d9a14828768 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Script.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Script.java @@ -3,12 +3,12 @@ import lombok.Getter; import lombok.extern.slf4j.Slf4j; import net.runelite.api.coords.WorldPoint; -import net.runelite.client.plugins.microbot.shortestpath.ShortestPathPlugin; import net.runelite.client.plugins.microbot.util.Global; import net.runelite.client.plugins.microbot.agentserver.handler.ScriptHeartbeatRegistry; import net.runelite.client.plugins.microbot.util.antiban.SessionFatigue; import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import net.runelite.client.plugins.microbot.util.walker.Rs2PathApi; import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; import org.jetbrains.annotations.NotNull; @@ -56,7 +56,7 @@ public void shutdown() { ScriptHeartbeatRegistry.remove(this.getClass().getName()); if (mainScheduledFuture != null && !mainScheduledFuture.isDone()) { mainScheduledFuture.cancel(true); - ShortestPathPlugin.exit(); + Rs2PathApi.exit(); if (Microbot.getClientThread().scheduledFuture != null) Microbot.getClientThread().scheduledFuture.cancel(true); initialPlayerLocation = null; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/AgentServerPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/AgentServerPlugin.java index 607bc2c9904..9399aa130e2 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/AgentServerPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/AgentServerPlugin.java @@ -136,6 +136,7 @@ private List buildHandlers(int maxResults) { new NpcHandler(gson, maxResults), new ObjectHandler(gson, maxResults), new WalkHandler(gson), + new WalkerShadowHandler(gson), new BankHandler(gson), new DialogueHandler(gson), new GroundItemHandler(gson, maxResults), diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/WalkerShadowHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/WalkerShadowHandler.java new file mode 100644 index 00000000000..2e7e4c523dc --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/WalkerShadowHandler.java @@ -0,0 +1,179 @@ +package net.runelite.client.plugins.microbot.agentserver.handler; + +import com.google.gson.Gson; +import com.sun.net.httpserver.HttpExchange; +import net.runelite.client.plugins.microbot.util.walker.Rs2PathApi; +import net.runelite.client.plugins.microbot.util.walker.Rs2PlannerShadowComparison; +import net.runelite.client.plugins.microbot.util.walker.Rs2PlannerCanaryPerformanceStats; +import net.runelite.client.plugins.microbot.util.walker.Rs2PlannerShadowContext; +import net.runelite.client.plugins.microbot.util.walker.Rs2PlannerShadowCoverageStats; +import net.runelite.client.plugins.microbot.util.walker.Rs2PlannerShadowStats; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportExecutor; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportType; +import net.runelite.client.plugins.microbot.util.walker.Rs2WalkerShadowExecutionStats; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.Map; + +/** Read-only, coordinate-free production planner shadow evidence. */ +public final class WalkerShadowHandler extends AgentHandler +{ + public WalkerShadowHandler(Gson gson) + { + super(gson); + } + + @Override + public String getPath() + { + return "/walker/shadow"; + } + + @Override + protected void handleRequest(HttpExchange exchange) throws IOException + { + try + { + requireGet(exchange); + } + catch (HttpMethodException error) + { + sendJson(exchange, 405, errorResponse(error.getMessage())); + return; + } + sendJson(exchange, 200, snapshot()); + } + + /** Coordinate-free schema-v2 snapshot shared by the read-only endpoint and live test harnesses. */ + public static Map snapshot() + { + Rs2PlannerShadowStats stats = Rs2PathApi.getShadowStats(); + Map response = new LinkedHashMap<>(); + response.put("schemaVersion", 2); + response.put("enabled", Rs2PathApi.isUpstreamPlannerShadowEnabled()); + response.put("plannerMode", Rs2PathApi.getPlannerSelectionMode().name()); + response.put("candidateEngineId", Rs2PathApi.getUpstreamPlannerEngineId()); + response.put("startedAtEpochMillis", stats.getStartedAtEpochMillis()); + Map totals = new LinkedHashMap<>(); + totals.put("submitted", stats.getSubmitted()); + totals.put("completed", stats.getCompleted()); + totals.put("matches", stats.getMatches()); + totals.put("divergences", stats.getDivergences()); + totals.put("failures", stats.getFailures()); + totals.put("staleResults", stats.getStaleResults()); + totals.put("discarded", stats.getDiscarded()); + totals.put("pending", stats.getPending()); + totals.put("routeShapeDifferences", stats.getRouteShapeDifferences()); + totals.put("upstreamCanarySelections", stats.getUpstreamCanarySelections()); + totals.put("localFallbackDivergences", stats.getLocalFallbackDivergences()); + totals.put("localFallbackFailures", stats.getLocalFallbackFailures()); + response.put("totals", totals); + + Map coverage = new LinkedHashMap<>(); + for (Map.Entry + entry : stats.getCoverage().entrySet()) + { + Rs2PlannerShadowCoverageStats value = entry.getValue(); + Map outcomes = new LinkedHashMap<>(); + outcomes.put("completed", value.getCompleted()); + outcomes.put("matches", value.getMatches()); + outcomes.put("divergences", value.getDivergences()); + outcomes.put("failures", value.getFailures()); + coverage.put(entry.getKey().name(), outcomes); + } + response.put("coverage", coverage); + Map transportExecutors = new LinkedHashMap<>(); + for (Map.Entry + entry : stats.getTransportExecutors().entrySet()) + { + transportExecutors.put(entry.getKey().name(), outcomes(entry.getValue())); + } + response.put("transportExecutors", transportExecutors); + Map transportTypes = new LinkedHashMap<>(); + for (Map.Entry + entry : stats.getTransportTypes().entrySet()) + { + transportTypes.put(entry.getKey().name(), outcomes(entry.getValue())); + } + response.put("transportTypes", transportTypes); + Rs2WalkerShadowExecutionStats executionStats = stats.getExecution(); + Map execution = new LinkedHashMap<>(); + execution.put("terminal", executionStats.getTerminal()); + execution.put("arrived", executionStats.getArrived()); + execution.put("unreachable", executionStats.getUnreachable()); + execution.put("exited", executionStats.getExited()); + execution.put("recoveryTerminal", executionStats.getRecoveryTerminal()); + execution.put("recoveryArrived", executionStats.getRecoveryArrived()); + execution.put("recoveryUnreachable", executionStats.getRecoveryUnreachable()); + execution.put("recoveryExited", executionStats.getRecoveryExited()); + response.put("execution", execution); + Rs2PlannerCanaryPerformanceStats performanceStats = stats.getCanaryPerformance(); + Map canaryPerformance = new LinkedHashMap<>(); + canaryPerformance.put("planningSamples", performanceStats.getPlanningSamples()); + canaryPerformance.put("planningNanosTotal", performanceStats.getPlanningNanosTotal()); + canaryPerformance.put("planningNanosMax", performanceStats.getPlanningNanosMax()); + canaryPerformance.put("localSearchNanosTotal", performanceStats.getLocalSearchNanosTotal()); + canaryPerformance.put("localSearchNanosMax", performanceStats.getLocalSearchNanosMax()); + canaryPerformance.put("upstreamSearchSamples", performanceStats.getUpstreamSearchSamples()); + canaryPerformance.put("upstreamSearchNanosTotal", performanceStats.getUpstreamSearchNanosTotal()); + canaryPerformance.put("upstreamSearchNanosMax", performanceStats.getUpstreamSearchNanosMax()); + response.put("canaryPerformance", canaryPerformance); + response.put("latest", Rs2PathApi.getLastShadowComparison() + .map(WalkerShadowHandler::comparison).orElse(null)); + response.put("latestRouteShapeDifference", Rs2PathApi.getLastRouteShapeDifference() + .map(WalkerShadowHandler::comparison).orElse(null)); + response.put("latestDivergence", Rs2PathApi.getLastDivergence() + .map(WalkerShadowHandler::comparison).orElse(null)); + response.put("latestFailure", Rs2PathApi.getLastPlannerFailure() + .map(WalkerShadowHandler::comparison).orElse(null)); + return response; + } + + private static Map outcomes(Rs2PlannerShadowCoverageStats value) + { + Map outcomes = new LinkedHashMap<>(); + outcomes.put("completed", value.getCompleted()); + outcomes.put("matches", value.getMatches()); + outcomes.put("divergences", value.getDivergences()); + outcomes.put("failures", value.getFailures()); + return outcomes; + } + + private static Map comparison(Rs2PlannerShadowComparison comparison) + { + Map result = new LinkedHashMap<>(); + result.put("status", comparison.getStatus().name()); + result.put("shadowEngineId", comparison.getShadowEngineId()); + result.put("invocation", comparison.getContext().getInvocation().name()); + ArrayList coverage = new ArrayList<>(); + for (Rs2PlannerShadowContext.Coverage value : comparison.getContext().getCoverage()) + { + coverage.add(value.name()); + } + result.put("coverage", coverage); + result.put("terminationMatches", comparison.isTerminationMatches()); + result.put("endpointMatches", comparison.isEndpointMatches()); + result.put("costComparable", comparison.isCostComparable()); + result.put("costMatches", comparison.isCostMatches()); + result.put("selectedTransportsMatch", comparison.isSelectedTransportsMatch()); + result.put("pathMatches", comparison.isPathMatches()); + ArrayList transportExecutors = new ArrayList<>(); + for (Rs2TransportExecutor value : comparison.getContext().getTransportExecutors()) + { + transportExecutors.add(value.name()); + } + result.put("transportExecutors", transportExecutors); + ArrayList transportTypes = new ArrayList<>(); + for (Rs2TransportType value : comparison.getContext().getTransportTypes()) + { + transportTypes.add(value.name()); + } + result.put("transportTypes", transportTypes); + result.put("localSearchNanos", comparison.getLocalSearchNanos()); + result.put("shadowSearchNanos", comparison.getShadowSearchNanos()); + result.put("failureType", comparison.getFailureType()); + return result; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestScript.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestScript.java index 1ccfffa4041..2768b5d8ce3 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestScript.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestScript.java @@ -18,7 +18,6 @@ import net.runelite.client.plugins.microbot.questhelper.requirements.item.ItemRequirement; import net.runelite.client.plugins.microbot.questhelper.steps.*; import net.runelite.client.plugins.microbot.questhelper.steps.widget.WidgetHighlight; -import net.runelite.client.plugins.microbot.shortestpath.ShortestPathPlugin; import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; import net.runelite.client.plugins.microbot.util.camera.Rs2Camera; import net.runelite.client.plugins.microbot.util.combat.Rs2Combat; @@ -33,6 +32,7 @@ import net.runelite.client.plugins.microbot.util.player.Rs2Player; import net.runelite.client.plugins.microbot.util.shop.Rs2Shop; import net.runelite.client.plugins.microbot.util.tile.Rs2Tile; +import net.runelite.client.plugins.microbot.util.walker.Rs2PathApi; import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; import net.runelite.client.plugins.microbot.api.npc.models.Rs2NpcModel; @@ -234,8 +234,8 @@ public boolean run(QuestHelperConfig config, QuestHelperPlugin mQuestPlugin) { boolean isInCutscene = Microbot.getVarbitValue(4606) > 0; if (isInCutscene) { - if (ShortestPathPlugin.getMarker() != null) - ShortestPathPlugin.exit(); + if (Rs2PathApi.getMarker() != null) + Rs2PathApi.exit(); return; } @@ -1385,11 +1385,11 @@ public boolean applyObjectStep(ObjectStep step) { for (var tile : Rs2Tile.getWalkableTilesAroundTile(object.getWorldLocation(), unreachableTargetCheckDist)) { if (tileObjects.stream().noneMatch(x -> x.getWorldLocation().equals(tile))) { - if (!Rs2Walker.walkTo(tile) && ShortestPathPlugin.getPathfinder() == null) + if (!Rs2Walker.walkTo(tile) && !Rs2PathApi.getActiveRouteStatus().isPresent()) return false; - sleepUntil(() -> ShortestPathPlugin.getPathfinder() == null || ShortestPathPlugin.getPathfinder().isDone()); - if (ShortestPathPlugin.getPathfinder() == null || ShortestPathPlugin.getPathfinder().isDone()) { + sleepUntil(() -> !Rs2PathApi.getActiveRouteStatus().isCalculating()); + if (!Rs2PathApi.getActiveRouteStatus().isCalculating()) { unreachableTarget = false; unreachableTargetCheckDist = 1; } @@ -1424,9 +1424,11 @@ public boolean applyObjectStep(ObjectStep step) { Rs2Walker.walkTo(targetTile, 3); - if (ShortestPathPlugin.getPathfinder() != null) { - var path = ShortestPathPlugin.getPathfinder().getPath(); - if (path.get(path.size() - 1).distanceTo(step.getDefinedPoint().getWorldPoint()) <= 1) + var activeRoute = Rs2PathApi.getActiveRouteStatus(); + if (activeRoute.isPresent()) { + var endpoint = activeRoute.getEndpoint(); + if (endpoint.isPresent() + && endpoint.get().distanceTo(step.getDefinedPoint().getWorldPoint()) <= 1) return false; } else return false; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/PlannerSelectionMode.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/PlannerSelectionMode.java new file mode 100644 index 00000000000..d089857657a --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/PlannerSelectionMode.java @@ -0,0 +1,48 @@ +package net.runelite.client.plugins.microbot.shortestpath; + +/** + * Explicit production planner rollout state. + * + *

A single mode prevents contradictory combinations such as selecting the upstream planner while + * comparison telemetry is disabled. The canary is deliberately limited to resolved F2P policy; members + * routes remain local until their own evidence gate is accepted.

+ */ +public enum PlannerSelectionMode +{ + /** Run only the local Microbot planner. */ + LOCAL, + /** Keep the local planner authoritative and compare the pinned upstream planner asynchronously. */ + SHADOW, + /** Select a semantically matching upstream result for F2P routes, with an automatic local fallback. */ + UPSTREAM_F2P_CANARY; + + public boolean comparisonEnabled() + { + return this != LOCAL; + } + + public boolean f2pCanaryEnabled() + { + return this == UPSTREAM_F2P_CANARY; + } + + public static PlannerSelectionMode fromConfigValue(Object value, PlannerSelectionMode defaultValue) + { + if (value instanceof PlannerSelectionMode) + { + return (PlannerSelectionMode) value; + } + if (value instanceof String) + { + try + { + return PlannerSelectionMode.valueOf(((String) value).trim().toUpperCase()); + } + catch (IllegalArgumentException ignored) + { + // Invalid test/plugin-message overrides fail closed to the persisted/default mode. + } + } + return defaultValue; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathConfig.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathConfig.java index f718e343095..d6612a06d4e 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathConfig.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathConfig.java @@ -904,4 +904,17 @@ default boolean useLiveCollision() { default boolean resetLearnedCollision() { return false; } + + @ConfigItem( + keyName = "plannerSelectionMode", + name = "Planner rollout mode", + description = "Local is the production default. Shadow compares the pinned upstream planner. " + + "The F2P canary selects only semantically matching upstream routes and automatically " + + "falls back to local; members routes remain local.", + position = 3, + section = sectionDeveloper + ) + default PlannerSelectionMode plannerSelectionMode() { + return PlannerSelectionMode.LOCAL; + } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java index 073c4825dc0..a86cc2b66e8 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java @@ -62,6 +62,7 @@ import net.runelite.client.plugins.microbot.util.player.Rs2Player; import net.runelite.client.plugins.microbot.util.tile.Rs2Tile; import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportPlanningPolicy; import net.runelite.client.ui.ClientToolbar; import net.runelite.client.ui.JagexColors; import net.runelite.client.ui.NavigationButton; @@ -227,7 +228,9 @@ protected void startUp() { Map> transports = Transport.loadAllFromResources(); List restrictions = Restriction.loadAllFromResources(); - pathfinderConfig = new PathfinderConfig(map, transports, restrictions, client, config); + pathfinderConfig = new PathfinderConfig( + map, transports, restrictions, client, config, + Rs2TransportPlanningPolicy.INSTANCE); panel = injector.getInstance(ShortestPathPanel.class); pohPanel = new PohPanel(config); @@ -992,6 +995,15 @@ public static TeleportationItem override(String configOverrideKey, Teleportation return defaultValue; } + public static PlannerSelectionMode override( + String configOverrideKey, PlannerSelectionMode defaultValue) { + if (!configOverride.isEmpty()) { + return PlannerSelectionMode.fromConfigValue( + configOverride.get(configOverrideKey), defaultValue); + } + return defaultValue; + } + private TileCounter override(String configOverrideKey, TileCounter defaultValue) { if (!configOverride.isEmpty()) { Object value = configOverride.get(configOverrideKey); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/Transport.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/Transport.java index db3d2900553..2b33e9069fa 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/Transport.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/Transport.java @@ -16,6 +16,11 @@ */ @Slf4j public class Transport { + public static final int TOTAL_LEVEL_INDEX = Skill.values().length; + public static final int COMBAT_LEVEL_INDEX = TOTAL_LEVEL_INDEX + 1; + public static final int QUEST_POINTS_INDEX = COMBAT_LEVEL_INDEX + 1; + public static final int REQUIREMENT_LEVEL_COUNT = QUEST_POINTS_INDEX + 1; + //START microbot variables @Getter @Setter @@ -46,7 +51,7 @@ public class Transport { * The skill levels required to use this transport */ @Getter - private final int[] skillLevels = new int[Skill.values().length]; + private final int[] skillLevels = new int[REQUIREMENT_LEVEL_COUNT]; /** * The quests required to use this transport @@ -55,14 +60,19 @@ public class Transport { private Map quests = new HashMap<>(); /** - * The ids of items required to use this transport. - * If the player has **any** of the matching list of items, - * this transport is valid + * Compatibility view of the item IDs required to use this transport. New code should use + * {@link #getItemRequirements()} so AND groups and quantities are not discarded. */ @Getter - @Setter private Set> itemIdRequirements = new HashSet<>(); + /** + * Lossless item requirements. Entries are AND-ed; alternatives within an entry are OR-ed. + * {@link #itemIdRequirements} remains as the compatibility view used by older callers. + */ + @Getter + private List itemRequirements = new ArrayList<>(); + /** * The type of transport */ @@ -138,6 +148,8 @@ public Transport(Transport origin, Transport destination) { this.itemIdRequirements.addAll(origin.itemIdRequirements); this.itemIdRequirements.addAll(destination.itemIdRequirements); + this.itemRequirements.addAll(origin.itemRequirements); + this.itemRequirements.addAll(destination.itemRequirements); this.type = origin.type; @@ -186,7 +198,13 @@ public Transport(WorldPoint origin, WorldPoint destination, String displayInfo, * Object interaction Transport constructor */ public Transport(WorldPoint origin, WorldPoint destination, String displayInfo, TransportType transportType, boolean isMember, String action, String target, int objectId) { - this(origin, destination, displayInfo, transportType, isMember, 1); + this(origin, destination, displayInfo, transportType, isMember, action, target, objectId, 1); + } + + /** Object interaction transport with an explicit planner cost in ticks. */ + public Transport(WorldPoint origin, WorldPoint destination, String displayInfo, TransportType transportType, + boolean isMember, String action, String target, int objectId, int duration) { + this(origin, destination, displayInfo, transportType, isMember, duration); this.action = action; this.name = target; this.objectId = objectId; @@ -198,7 +216,7 @@ public Transport(WorldPoint origin, WorldPoint destination, String displayInfo, public Transport(WorldPoint destination, String displayInfo, TransportType transportType, boolean isMember, int maxWildernessLevel, Set> itemIdRequirements) { this(null, destination, displayInfo, transportType, isMember, 1); this.maxWildernessLevel = maxWildernessLevel; - this.itemIdRequirements = itemIdRequirements != null ? new HashSet<>(itemIdRequirements) : new HashSet<>(); + setItemIdRequirements(itemIdRequirements); } /** @@ -244,10 +262,17 @@ public Transport(WorldPoint destination, String displayInfo, TransportType trans if ((value = fieldMap.get("menuOption menuTarget objectID")) != null && !value.trim().isEmpty()) { value = value.trim(); // Remove leading/trailing spaces - // Regex pattern for semicolon-separated values - String regex = "^([^;]+);([^;]+);(\\d+)$"; - java.util.regex.Pattern pattern = java.util.regex.Pattern.compile(regex); - java.util.regex.Matcher matcher = pattern.matcher(value); + // Microbot historically used semicolons while upstream uses whitespace. In the + // whitespace form the option is one token, the object id is the final numeric token, + // and the target may contain spaces. + java.util.regex.Matcher matcher = java.util.regex.Pattern + .compile("^([^;]+);([^;]+);(\\d+)$") + .matcher(value); + if (!matcher.matches()) { + matcher = java.util.regex.Pattern + .compile("^(\\S+)\\s+(.+?)\\s+(\\d+)$") + .matcher(value); + } if (matcher.matches()) { // Extract matched groups @@ -276,14 +301,14 @@ public Transport(WorldPoint destination, String displayInfo, TransportType trans String[] skillRequirements = value.split(DELIM_MULTI); for (String requirement : skillRequirements) { - String[] levelAndSkill = requirement.split(DELIM); + String[] levelAndSkill = requirement.trim().split("\\s+", 2); if (levelAndSkill.length < 2) { continue; } int level = Integer.parseInt(levelAndSkill[0]); - String skillName = levelAndSkill[1]; + String skillName = levelAndSkill[1].trim(); Skill[] skills = Skill.values(); for (int i = 0; i < skills.length; i++) { @@ -292,19 +317,36 @@ public Transport(WorldPoint destination, String displayInfo, TransportType trans break; } } + String normalizedSkillName = skillName.toLowerCase(Locale.ROOT); + if (normalizedSkillName.startsWith("total")) { + skillLevels[TOTAL_LEVEL_INDEX] = level; + } else if (normalizedSkillName.startsWith("combat")) { + skillLevels[COMBAT_LEVEL_INDEX] = level; + } else if (normalizedSkillName.startsWith("quest")) { + skillLevels[QUEST_POINTS_INDEX] = level; + } } } - if ((value = fieldMap.get("Item IDs")) != null && !value.trim().isEmpty()) { - String[] itemIdsList = value.split(DELIM_MULTI); - for (String listIds : itemIdsList) { - Set multiitemList = new HashSet<>(); - String[] itemIds = listIds.split(DELIM); - for (String item : itemIds) { - int itemId = Integer.parseInt(item); - multiitemList.add(itemId); + if ((value = fieldMap.get("Items")) != null && !value.trim().isEmpty()) { + setItemRequirements(TransportItemRequirement.parseRequirements(value)); + } else if ((value = fieldMap.get("Item IDs")) != null && !value.trim().isEmpty()) { + if (value.contains("=") || value.contains("&") || value.contains("|")) { + setItemRequirements(TransportItemRequirement.parseRequirements(value)); + } else { + Set> legacyGroups = new LinkedHashSet<>(); + for (String listIds : value.split(DELIM_MULTI)) { + Set group = new LinkedHashSet<>(); + for (String item : listIds.trim().split("\\s+")) { + if (!item.isEmpty()) { + group.add(Integer.parseInt(item)); + } + } + if (!group.isEmpty()) { + legacyGroups.add(group); + } } - itemIdRequirements.add(multiitemList); + setItemIdRequirements(legacyGroups); } } @@ -376,7 +418,11 @@ public Transport(WorldPoint destination, String displayInfo, TransportType trans } } - if ((value = fieldMap.get("Varplayers")) != null && !value.trim().isEmpty()) { + value = fieldMap.get("Varplayers"); + if ((value == null || value.trim().isEmpty())) { + value = fieldMap.get("VarPlayers"); + } + if (value != null && !value.trim().isEmpty()) { for (String varplayerCheck : value.split(DELIM_MULTI)) { if (varplayerCheck.isBlank()) { continue; @@ -428,6 +474,53 @@ private int getRequiredLevel(Skill skill) { return skillLevels[skill.ordinal()]; } + public int getRequiredTotalLevel() { + return skillLevels[TOTAL_LEVEL_INDEX]; + } + + public int getRequiredCombatLevel() { + return skillLevels[COMBAT_LEVEL_INDEX]; + } + + public int getRequiredQuestPoints() { + return skillLevels[QUEST_POINTS_INDEX]; + } + + /** + * Updates the legacy compatibility view. Historically every ID in this structure was treated as + * an alternative, regardless of its nested set, so preserve that behavior as one OR requirement. + */ + public void setItemIdRequirements(Set> requirements) { + Set> copied = new LinkedHashSet<>(); + Set alternatives = new LinkedHashSet<>(); + if (requirements != null) { + for (Set group : requirements) { + if (group == null || group.isEmpty()) { + continue; + } + Set copiedGroup = new LinkedHashSet<>(group); + copied.add(Collections.unmodifiableSet(copiedGroup)); + alternatives.addAll(copiedGroup); + } + } + this.itemIdRequirements = copied; + this.itemRequirements = alternatives.isEmpty() + ? new ArrayList<>() + : new ArrayList<>(Collections.singletonList( + TransportItemRequirement.legacyAlternatives(alternatives))); + } + + private void setItemRequirements(List requirements) { + this.itemRequirements = requirements == null + ? new ArrayList<>() + : new ArrayList<>(requirements); + Set> compatibility = new LinkedHashSet<>(); + for (TransportItemRequirement requirement : this.itemRequirements) { + compatibility.add(Collections.unmodifiableSet(new LinkedHashSet<>(requirement.getAllItemIds()))); + } + this.itemIdRequirements = compatibility; + } + /** * Whether the transport has one or more quest requirements */ @@ -639,6 +732,7 @@ public String toString() { ", skillLevels=" + Arrays.toString(skillLevels) + ", quests=" + quests + ", itemIdRequirements=" + itemIdRequirements + + ", itemRequirements=" + itemRequirements + ", type=" + type + ", duration=" + duration + ", displayInfo='" + displayInfo + '\'' + diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/TransportExecutionRegistry.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/TransportExecutionRegistry.java new file mode 100644 index 00000000000..5e0a2a8e834 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/TransportExecutionRegistry.java @@ -0,0 +1,341 @@ +package net.runelite.client.plugins.microbot.shortestpath; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.ItemID; +import net.runelite.client.plugins.microbot.util.poh.PohTransport; +import net.runelite.client.plugins.skillcalculator.skills.MagicAction; + +import java.util.Arrays; +import java.util.EnumSet; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Pure planner-side description of the Microbot executor capabilities. + * + *

A transport must not be offered to automated pathfinding unless the walker has a concrete + * execution branch for it. Keeping that decision here prevents catalog convergence from silently + * turning data coverage into routes that the runtime cannot complete.

+ */ +public final class TransportExecutionRegistry +{ + public enum Executor + { + BARROWS_DIG, + CANOE, + CHARTER_SHIP, + FAIRY_RING, + GNOME_GLIDER, + HOT_AIR_BALLOON, + ITEM_TELEPORT, + MAGIC_CARPET, + MAGIC_MUSHTREE, + MINIGAME_TELEPORT, + OBJECT, + POH, + QUETZAL, + SEASONAL, + SPELL_TELEPORT, + SPIRIT_TREE, + TERMINAL_TRAVEL, + WILDERNESS_OBELISK + } + + /** Planner-visible interaction sequence supported by the terminal-travel executor. */ + public enum TerminalTravelMode + { + DIRECT, + DIALOGUE_DESTINATION + } + + /** Exact destination labels presented by the unlocked balloon network map. */ + public enum BalloonDestination + { + CASTLE_WARS("Castle Wars"), + GRAND_TREE("Grand Tree"), + CRAFTING_GUILD("Crafting Guild"), + ENTRANA("Entrana"), + TAVERLEY("Taverley"), + VARROCK("Varrock"); + + private final String displayName; + + BalloonDestination(String displayName) + { + this.displayName = displayName; + } + + public String getDisplayName() + { + return displayName; + } + } + + /** Home teleports are zero-rune spellbook widgets rather than ordinary {@link MagicAction}s. */ + public enum HomeTeleport + { + LUMBRIDGE("Lumbridge Home Teleport"), + EDGEVILLE("Edgeville Home Teleport"), + LUNAR("Lunar Home Teleport"), + ARCEUUS("Arceuus Home Teleport"); + + private final String displayName; + + HomeTeleport(String displayName) + { + this.displayName = displayName; + } + + public String getDisplayName() + { + return displayName; + } + } + + private static final Set GENERIC_OBJECT_EXECUTORS = EnumSet.of( + TransportType.TRANSPORT, + TransportType.AGILITY_SHORTCUT, + TransportType.GRAPPLE_SHORTCUT, + TransportType.MINECART, + TransportType.TELEPORTATION_LEVER, + TransportType.TELEPORTATION_PORTAL, + TransportType.MAGIC_MUSHTREE); + + /** + * Barrows mound digs are inventory-item interactions, not scene-object interactions. Keep the + * six deterministic surface-to-crypt mappings exact so an arbitrary object-less transport row + * cannot acquire the spade executor by naming itself "Dig". + */ + private static final Map + BARROWS_DIG_DESTINATIONS = Map.of( + new WorldPoint(3564, 3291, 0), new WorldPoint(3559, 9703, 3), + new WorldPoint(3575, 3299, 0), new WorldPoint(3558, 9718, 3), + new WorldPoint(3578, 3281, 0), new WorldPoint(3534, 9706, 3), + new WorldPoint(3567, 3274, 0), new WorldPoint(3546, 9686, 3), + new WorldPoint(3553, 3281, 0), new WorldPoint(3566, 9683, 3), + new WorldPoint(3556, 3297, 0), new WorldPoint(3578, 9704, 3)); + + private TransportExecutionRegistry() + { + } + + public static boolean canExecute(Transport transport) + { + return executorFor(transport).isPresent(); + } + + /** Resolve the walker branch without reading live client state. */ + public static Optional executorFor(Transport transport) + { + if (transport == null || transport.getType() == null || transport.getDestination() == null) + { + return Optional.empty(); + } + + TransportType type = transport.getType(); + if (isBarrowsDig(transport)) + { + return Optional.of(Executor.BARROWS_DIG); + } + if (type == TransportType.TELEPORTATION_SPELL) + { + return hasRegisteredSpell(transport.getDisplayInfo()) + ? Optional.of(Executor.SPELL_TELEPORT) + : Optional.empty(); + } + if (type == TransportType.POH) + { + return transport instanceof PohTransport + ? Optional.of(Executor.POH) + : Optional.empty(); + } + if (type == TransportType.HOT_AIR_BALLOON) + { + return hasRegisteredBalloon(transport) + ? Optional.of(Executor.HOT_AIR_BALLOON) + : Optional.empty(); + } + if (isTerminalTravelType(type)) + { + return terminalTravelModeFor(transport).isPresent() + ? Optional.of(Executor.TERMINAL_TRAVEL) + : Optional.empty(); + } + if (GENERIC_OBJECT_EXECUTORS.contains(type)) + { + return hasObjectInteraction(transport) + ? Optional.of(type == TransportType.MAGIC_MUSHTREE + ? Executor.MAGIC_MUSHTREE + : Executor.OBJECT) + : Optional.empty(); + } + + return Optional.ofNullable(specializedExecutor(type)); + } + + private static boolean isBarrowsDig(Transport transport) + { + if (transport.getType() != TransportType.TRANSPORT + || transport.getObjectId() != 0 + || !"Dig".equalsIgnoreCase(transport.getAction()) + || !"Barrow".equalsIgnoreCase(transport.getName()) + || !transport.getDestination().equals(BARROWS_DIG_DESTINATIONS.get(transport.getOrigin())) + || transport.getItemRequirements().size() != 1) + { + return false; + } + TransportItemRequirement spade = transport.getItemRequirements().get(0); + return spade.getAllItemIds().equals(Set.of(ItemID.SPADE)) + && spade.getRequiredQuantity(ItemID.SPADE) == 1; + } + + private static Executor specializedExecutor(TransportType type) + { + switch (type) + { + case CANOE: + return Executor.CANOE; + case CHARTER_SHIP: + return Executor.CHARTER_SHIP; + case FAIRY_RING: + return Executor.FAIRY_RING; + case GNOME_GLIDER: + return Executor.GNOME_GLIDER; + case MAGIC_CARPET: + return Executor.MAGIC_CARPET; + case QUETZAL: + return Executor.QUETZAL; + case SPIRIT_TREE: + return Executor.SPIRIT_TREE; + case TELEPORTATION_ITEM: + return Executor.ITEM_TELEPORT; + case TELEPORTATION_MINIGAME: + return Executor.MINIGAME_TELEPORT; + case WILDERNESS_OBELISK: + return Executor.WILDERNESS_OBELISK; + case SEASONAL_TRANSPORT: + return Executor.SEASONAL; + default: + return null; + } + } + + /** + * Resolve the complete interaction flow, not merely the catalog family. + * + *

The SHIP/NPC/BOAT files describe journeys and contain both NPC and scene-object targets. + * Target kind is therefore resolved live. Interaction sequence is catalog policy, however, and must + * be known before planning. Unknown or currently unimplemented sequences fail closed here.

+ */ + public static Optional terminalTravelModeFor(Transport transport) + { + if (transport == null + || !isTerminalTravelType(transport.getType()) + || transport.getOrigin() == null + || transport.getDestination() == null + || transport.getObjectId() <= 0 + || isBlank(transport.getName()) + || isBlank(transport.getAction())) + { + return Optional.empty(); + } + + if (requiresUnsupportedTerminalDestinationSelection(transport)) + { + return Optional.empty(); + } + if ("Mountain Guide".equalsIgnoreCase(transport.getName())) + { + return isBlank(transport.getDisplayInfo()) + ? Optional.empty() + : Optional.of(TerminalTravelMode.DIALOGUE_DESTINATION); + } + return Optional.of(TerminalTravelMode.DIRECT); + } + + private static boolean isTerminalTravelType(TransportType type) + { + return type == TransportType.SHIP || type == TransportType.NPC || type == TransportType.BOAT; + } + + private static boolean requiresUnsupportedTerminalDestinationSelection(Transport transport) + { + String action = transport.getAction(); + String target = transport.getName(); + if (transport.getType() == TransportType.BOAT && !isBlank(transport.getDisplayInfo())) + { + return ("Board".equalsIgnoreCase(action) + && ("Boaty".equalsIgnoreCase(target) || "Boat".equalsIgnoreCase(target))) + || ("Travel".equalsIgnoreCase(action) && "Rowboat".equalsIgnoreCase(target)); + } + return "Talk-to".equalsIgnoreCase(action) + && ("Captain Shanks".equalsIgnoreCase(target) || "Pirate Pete".equalsIgnoreCase(target)); + } + + private static boolean hasObjectInteraction(Transport transport) + { + return transport.getOrigin() != null + && transport.getObjectId() > 0 + && !isBlank(transport.getAction()); + } + + private static boolean hasRegisteredSpell(String displayInfo) + { + if (isBlank(displayInfo)) + { + return false; + } + if (homeTeleportFor(displayInfo).isPresent()) + { + return true; + } + String spellName = displayInfo.contains(":") + ? displayInfo.substring(0, displayInfo.indexOf(':')).trim() + : displayInfo.trim(); + return Arrays.stream(MagicAction.values()) + .anyMatch(action -> action.getName().toLowerCase(Locale.ROOT) + .contains(spellName.toLowerCase(Locale.ROOT))); + } + + /** Resolve the exact home-teleport widget family shared by planning and execution. */ + public static Optional homeTeleportFor(String displayInfo) + { + if (isBlank(displayInfo)) + { + return Optional.empty(); + } + String normalized = displayInfo.trim().toLowerCase(Locale.ROOT); + return Arrays.stream(HomeTeleport.values()) + .filter(teleport -> teleport.getDisplayName().toLowerCase(Locale.ROOT).equals(normalized)) + .findFirst(); + } + + /** Resolve an exact balloon-map destination shared by capability filtering and runtime dispatch. */ + public static Optional balloonDestinationFor(String displayInfo) + { + if (isBlank(displayInfo)) + { + return Optional.empty(); + } + String normalized = displayInfo.trim().toLowerCase(Locale.ROOT); + return Arrays.stream(BalloonDestination.values()) + .filter(destination -> destination.getDisplayName().toLowerCase(Locale.ROOT).equals(normalized)) + .findFirst(); + } + + private static boolean hasRegisteredBalloon(Transport transport) + { + return hasObjectInteraction(transport) + && (transport.getObjectId() == 19128 || transport.getObjectId() == 19129) + && "Use".equalsIgnoreCase(transport.getAction()) + && "Basket".equalsIgnoreCase(transport.getName()) + && balloonDestinationFor(transport.getDisplayInfo()).isPresent(); + } + + private static boolean isBlank(String value) + { + return value == null || value.trim().isEmpty(); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/TransportItemRequirement.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/TransportItemRequirement.java new file mode 100644 index 00000000000..d25d88163a2 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/TransportItemRequirement.java @@ -0,0 +1,280 @@ +package net.runelite.client.plugins.microbot.shortestpath; + +import lombok.Getter; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.IntPredicate; +import java.util.function.IntUnaryOperator; + +/** + * One item requirement for a transport. + * + *

The alternatives are OR-ed and retain their individual quantities. A transport may contain + * multiple instances of this class; those requirements are AND-ed. The upstream parser normalizes + * every alternative in an OR group to that group's maximum quantity; {@link #parseRequirements} + * deliberately applies the same rule.

+ */ +public final class TransportItemRequirement { + @Getter + private final Map alternatives; + @Getter + private final Set staffAlternatives; + @Getter + private final Set offhandAlternatives; + @Getter + private final boolean runeOnly; + + public TransportItemRequirement(Map alternatives) { + this(alternatives, Collections.emptySet(), Collections.emptySet(), false); + } + + public TransportItemRequirement(Map alternatives, + Set staffAlternatives, Set offhandAlternatives, boolean runeOnly) { + if (alternatives == null || alternatives.isEmpty()) { + throw new IllegalArgumentException("item requirement must contain an alternative"); + } + Map copy = new LinkedHashMap<>(); + for (Map.Entry entry : alternatives.entrySet()) { + Integer itemId = entry.getKey(); + Integer quantity = entry.getValue(); + if (itemId == null || itemId <= 0) { + throw new IllegalArgumentException("item id must be positive: " + itemId); + } + if (quantity == null || quantity < 0) { + throw new IllegalArgumentException("item quantity must be non-negative: " + quantity); + } + if (copy.put(itemId, quantity) != null) { + throw new IllegalArgumentException("duplicate item alternative: " + itemId); + } + } + this.alternatives = Collections.unmodifiableMap(copy); + this.staffAlternatives = immutablePositiveIds(staffAlternatives, "staff"); + this.offhandAlternatives = immutablePositiveIds(offhandAlternatives, "offhand"); + this.runeOnly = runeOnly; + } + + private static Set immutablePositiveIds(Set itemIds, String label) { + if (itemIds == null || itemIds.isEmpty()) { + return Collections.emptySet(); + } + LinkedHashSet copy = new LinkedHashSet<>(); + for (Integer itemId : itemIds) { + if (itemId == null || itemId <= 0) { + throw new IllegalArgumentException(label + " item id must be positive: " + itemId); + } + copy.add(itemId); + } + return Collections.unmodifiableSet(copy); + } + + public static TransportItemRequirement legacyAlternatives(Set itemIds) { + Map alternatives = new LinkedHashMap<>(); + for (Integer itemId : itemIds) { + alternatives.put(itemId, 1); + } + return new TransportItemRequirement(alternatives); + } + + /** + * Parses the numeric subset of the upstream item grammar. Symbolic item collections must be + * resolved by the pinned schema adapter before reaching Microbot resources. + */ + public static List parseNumericRequirements(String value) { + return parseRequirements(value, false); + } + + /** + * Parses numeric item ids plus explicitly supported symbolic collections from the pinned adapter. + * Unsupported collections fail closed rather than being omitted from the transport. + */ + public static List parseRequirements(String value) { + return parseRequirements(value, true); + } + + private static List parseRequirements(String value, boolean allowSymbols) { + if (value == null || value.trim().isEmpty()) { + return Collections.emptyList(); + } + String normalized = value.replace(" ", "") + .replace("&&", "&") + .replace("||", "|"); + List requirements = new ArrayList<>(); + for (String andPart : normalized.split("&", -1)) { + if (andPart.isEmpty()) { + throw new IllegalArgumentException("empty AND item requirement in: " + value); + } + Map parsedAlternatives = new LinkedHashMap<>(); + Set staffAlternatives = new LinkedHashSet<>(); + Set offhandAlternatives = new LinkedHashSet<>(); + boolean runeOnly = true; + int maximumQuantity = -1; + for (String orPart : andPart.split("\\|", -1)) { + String[] itemAndQuantity = orPart.split("=", -1); + if (itemAndQuantity.length != 2) { + throw new IllegalArgumentException("invalid item requirement: " + orPart); + } + final int quantity; + try { + quantity = Integer.parseInt(itemAndQuantity[1]); + } catch (NumberFormatException e) { + throw new IllegalArgumentException( + "unresolved symbolic or invalid item requirement: " + orPart, e); + } + Set itemIds; + try { + itemIds = Collections.singleton(Integer.parseInt(itemAndQuantity[0])); + runeOnly = false; + } catch (NumberFormatException e) { + TransportItemResolver.Resolution resolution = allowSymbols + ? TransportItemResolver.resolve(itemAndQuantity[0]) : null; + if (resolution == null) { + throw new IllegalArgumentException( + "unresolved symbolic or invalid item requirement: " + orPart, e); + } + itemIds = resolution.getItemIds(); + staffAlternatives.addAll(resolution.getStaffIds()); + offhandAlternatives.addAll(resolution.getOffhandIds()); + runeOnly &= resolution.isRune(); + } + for (Integer itemId : itemIds) { + parsedAlternatives.merge(itemId, quantity, Math::max); + } + maximumQuantity = Math.max(maximumQuantity, quantity); + } + Map alternatives = new LinkedHashMap<>(); + for (Integer itemId : parsedAlternatives.keySet()) { + alternatives.put(itemId, maximumQuantity); + } + requirements.add(new TransportItemRequirement( + alternatives, staffAlternatives, offhandAlternatives, runeOnly)); + } + return Collections.unmodifiableList(requirements); + } + + public Set getItemIds() { + return Collections.unmodifiableSet(new LinkedHashSet<>(alternatives.keySet())); + } + + public int getRequiredQuantity(int itemId) { + return alternatives.getOrDefault(itemId, -1); + } + + public boolean isSatisfiedBy(IntUnaryOperator availableQuantity) { + for (Map.Entry alternative : alternatives.entrySet()) { + int required = alternative.getValue(); + int available = Math.max(0, availableQuantity.applyAsInt(alternative.getKey())); + if ((required == 0 && available == 0) || (required > 0 && available >= required)) { + return true; + } + } + return false; + } + + boolean isSatisfiedBy(IntUnaryOperator availableQuantity, int staffItemId, int offhandItemId) { + return isSatisfiedBy(availableQuantity) + || staffAlternatives.contains(staffItemId) + || offhandAlternatives.contains(offhandItemId); + } + + /** + * Select at most one future weapon and one future offhand that make every AND-clause true. + * The same combination staff may satisfy multiple elemental rune clauses, matching the game. + */ + public static Optional selectProviders( + List requirements, + IntUnaryOperator availableQuantity, + IntPredicate staffAvailable, + IntPredicate offhandAvailable) { + if (requirements == null || requirements.isEmpty()) { + return Optional.of(ProviderSelection.NONE); + } + TreeSet staffs = new TreeSet<>(); + TreeSet offhands = new TreeSet<>(); + for (TransportItemRequirement requirement : requirements) { + requirement.staffAlternatives.stream().filter(staffAvailable::test).forEach(staffs::add); + requirement.offhandAlternatives.stream().filter(offhandAvailable::test).forEach(offhands::add); + } + List staffCandidates = new ArrayList<>(); + staffCandidates.add(ProviderSelection.NO_ITEM); + staffCandidates.addAll(staffs); + List offhandCandidates = new ArrayList<>(); + offhandCandidates.add(ProviderSelection.NO_ITEM); + offhandCandidates.addAll(offhands); + for (Integer staff : staffCandidates) { + for (Integer offhand : offhandCandidates) { + boolean satisfied = true; + for (TransportItemRequirement requirement : requirements) { + if (!requirement.isSatisfiedBy(availableQuantity, staff, offhand)) { + satisfied = false; + break; + } + } + if (satisfied) { + return Optional.of(new ProviderSelection(staff, offhand)); + } + } + } + return Optional.empty(); + } + + public Set getAllItemIds() { + LinkedHashSet itemIds = new LinkedHashSet<>(alternatives.keySet()); + itemIds.addAll(staffAlternatives); + itemIds.addAll(offhandAlternatives); + return Collections.unmodifiableSet(itemIds); + } + + public static final class ProviderSelection { + static final int NO_ITEM = -1; + static final ProviderSelection NONE = new ProviderSelection(NO_ITEM, NO_ITEM); + + private final int staffItemId; + private final int offhandItemId; + + private ProviderSelection(int staffItemId, int offhandItemId) { + this.staffItemId = staffItemId; + this.offhandItemId = offhandItemId; + } + + public int getStaffItemId() { return staffItemId; } + public int getOffhandItemId() { return offhandItemId; } + public boolean hasStaff() { return staffItemId > 0; } + public boolean hasOffhand() { return offhandItemId > 0; } + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof TransportItemRequirement)) { + return false; + } + TransportItemRequirement that = (TransportItemRequirement) other; + return alternatives.equals(that.alternatives) + && staffAlternatives.equals(that.staffAlternatives) + && offhandAlternatives.equals(that.offhandAlternatives) + && runeOnly == that.runeOnly; + } + + @Override + public int hashCode() { + int result = alternatives.hashCode(); + result = 31 * result + staffAlternatives.hashCode(); + result = 31 * result + offhandAlternatives.hashCode(); + return 31 * result + Boolean.hashCode(runeOnly); + } + + @Override + public String toString() { + return alternatives + " staff=" + staffAlternatives + " offhand=" + offhandAlternatives; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/TransportItemResolver.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/TransportItemResolver.java new file mode 100644 index 00000000000..25f07bc04b6 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/TransportItemResolver.java @@ -0,0 +1,169 @@ +package net.runelite.client.plugins.microbot.shortestpath; + +import net.runelite.api.gameval.ItemID; +import net.runelite.client.plugins.microbot.util.magic.Rs2Staff; +import net.runelite.client.plugins.microbot.util.magic.Rs2Tome; +import net.runelite.client.plugins.microbot.util.magic.Runes; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Pinned adapter for symbolic item collections used by Shortest Path transport resources. + * + *

Only collections whose semantics can be represented by {@link TransportItemRequirement} belong + * here. Rune symbols delegate to Microbot's canonical rune, staff and tome catalogs so pathfinding and + * actual casting cannot acquire separate provider lists. Unknown or unsupported symbols fail closed.

+ */ +final class TransportItemResolver { + private static final Map SYMBOLS = buildSymbols(); + + private TransportItemResolver() { + } + + static Resolution resolve(String symbol) { + if (symbol == null) { + return null; + } + return SYMBOLS.get(symbol.trim().toUpperCase(Locale.ROOT)); + } + + private static Map buildSymbols() { + Map symbols = new LinkedHashMap<>(); + addRune(symbols, "AIR_RUNE", Runes.AIR); + addRune(symbols, "ASTRAL_RUNE", Runes.ASTRAL); + add(symbols, "AXE", + ItemID.BRONZE_AXE, ItemID.IRON_AXE, ItemID.STEEL_AXE, ItemID.BLACK_AXE, + ItemID.MITHRIL_AXE, ItemID.ADAMANT_AXE, ItemID.RUNE_AXE, ItemID.DRAGON_AXE, + ItemID.CRYSTAL_AXE, ItemID.TRAIL_GILDED_AXE, ItemID.INFERNAL_AXE, ItemID._3A_AXE); + add(symbols, "BANANA", ItemID.BANANA); + addRune(symbols, "BLOOD_RUNE", Runes.BLOOD); + add(symbols, "BROWN_APRON", + ItemID.BROWN_APRON, ItemID.GOLDEN_APRON, ItemID.SKILLCAPE_CRAFTING, + ItemID.SKILLCAPE_CRAFTING_TRIMMED, ItemID.SKILLCAPE_CRAFTING_HOOD); + add(symbols, "CLIMBING_BOOTS", ItemID.DEATH_CLIMBINGBOOTS, ItemID.CLIMBING_BOOTS_G); + add(symbols, "COINS", ItemID.COINS); + add(symbols, "CROSSBOW", + ItemID.CROSSBOW, ItemID.PHOENIX_CROSSBOW, ItemID.DTTD_BONE_CROSSBOW, + ItemID.HUNTING_CROSSBOW, ItemID.XBOWS_CROSSBOW_BRONZE, ItemID.XBOWS_CROSSBOW_IRON, + ItemID.XBOWS_CROSSBOW_STEEL, ItemID.XBOWS_CROSSBOW_MITHRIL, + ItemID.XBOWS_CROSSBOW_ADAMANTITE, ItemID.XBOWS_CROSSBOW_RUNITE, + ItemID.XBOWS_CROSSBOW_DRAGON, ItemID.DRAGONHUNTER_XBOW, + ItemID.BARROWS_KARIL_WEAPON, ItemID.BARROWS_KARIL_WEAPON_BROKEN, + ItemID.BARROWS_KARIL_WEAPON_25, ItemID.BARROWS_KARIL_WEAPON_50, + ItemID.BARROWS_KARIL_WEAPON_75, ItemID.BARROWS_KARIL_WEAPON_100, + ItemID.ACB, ItemID.ZARYTE_XBOW); + add(symbols, "DUSTY_KEY", ItemID.DUSTY_KEY); + addRune(symbols, "DUST_RUNE", Runes.DUST); + addRune(symbols, "EARTH_RUNE", Runes.EARTH); + add(symbols, "ECTO_TOKEN", ItemID.ECTOTOKEN); + add(symbols, "GLOWING_FUNGUS", ItemID.GLOWING_FUNGUS); + addRune(symbols, "FIRE_RUNE", Runes.FIRE); + addRune(symbols, "LAVA_RUNE", Runes.LAVA); + addRune(symbols, "LAW_RUNE", Runes.LAW); + add(symbols, "MACHETE", + ItemID.MACHETTE, ItemID.MACHETTE_OPAL, ItemID.MACHETTE_JADE, ItemID.MACHETTE_REDTOPAZ); + add(symbols, "MAX_CAPE", + ItemID.SKILLCAPE_MAX, ItemID.SKILLCAPE_MAX_WORN, ItemID.SKILLCAPE_MAX_FIRECAPE, + ItemID.SKILLCAPE_MAX_FIRECAPE_DUMMY, ItemID.SKILLCAPE_MAX_FIRECAPE_TROUVER, + ItemID.SKILLCAPE_MAX_SARADOMIN, ItemID.SKILLCAPE_MAX_ZAMORAK, + ItemID.SKILLCAPE_MAX_GUTHIX, ItemID.SKILLCAPE_MAX_ANMA, ItemID.SKILLCAPE_MAX_ARDY, + ItemID.SKILLCAPE_MAX_INFERNALCAPE, ItemID.SKILLCAPE_MAX_INFERNALCAPE_DUMMY, + ItemID.SKILLCAPE_MAX_INFERNALCAPE_TROUVER, ItemID.SKILLCAPE_MAX_SARADOMIN2, + ItemID.SKILLCAPE_MAX_SARADOMIN2_TROUVER, ItemID.SKILLCAPE_MAX_ZAMORAK2, + ItemID.SKILLCAPE_MAX_ZAMORAK2_TROUVER, ItemID.SKILLCAPE_MAX_GUTHIX2, + ItemID.SKILLCAPE_MAX_GUTHIX2_TROUVER, ItemID.SKILLCAPE_MAX_ASSEMBLER, + ItemID.SKILLCAPE_MAX_ASSEMBLER_TROUVER, ItemID.SKILLCAPE_MAX_MYTHICAL, + ItemID.SKILLCAPE_MAX_ASSEMBLER_MASORI, ItemID.SKILLCAPE_MAX_ASSEMBLER_MASORI_TROUVER, + ItemID.SKILLCAPE_MAX_DIZANAS, ItemID.SKILLCAPE_MAX_DIZANAS_TROUVER); + add(symbols, "MAX_HOOD", + ItemID.SKILLCAPE_MAX_HOOD, ItemID.SKILLCAPE_MAX_HOOD_FIRECAPE, + ItemID.SKILLCAPE_MAX_HOOD_SARADOMIN, ItemID.SKILLCAPE_MAX_HOOD_ZAMORAK, + ItemID.SKILLCAPE_MAX_HOOD_GUTHIX, ItemID.SKILLCAPE_MAX_HOOD_ANMA, + ItemID.SKILLCAPE_MAX_HOOD_ARDY, ItemID.SKILLCAPE_MAX_HOOD_INFERNALCAPE, + ItemID.SKILLCAPE_MAX_HOOD_SARADOMIN2, ItemID.SKILLCAPE_MAX_HOOD_ZAMORAK2, + ItemID.SKILLCAPE_MAX_HOOD_GUTHIX2, ItemID.SKILLCAPE_MAX_HOOD_ASSEMBLER, + ItemID.SKILLCAPE_MAX_HOOD_MYTHICAL, ItemID.SKILLCAPE_MAX_HOOD_ASSEMBLER_MASORI, + ItemID.SKILLCAPE_MAX_HOOD_DIZANAS); + add(symbols, "MAZE_KEY", ItemID.MELZARKEY); + addRune(symbols, "MIND_RUNE", Runes.MIND); + addRune(symbols, "MIST_RUNE", Runes.MIST); + add(symbols, "MITH_GRAPPLE", ItemID.XBOWS_GRAPPLE_TIP_BOLT_MITHRIL_ROPE); + addRune(symbols, "MUD_RUNE", Runes.MUD); + addRune(symbols, "NATURE_RUNE", Runes.NATURE); + add(symbols, "PICKAXE", + ItemID.BRONZE_PICKAXE, ItemID.IRON_PICKAXE, ItemID.STEEL_PICKAXE, + ItemID.BLACK_PICKAXE, ItemID.MITHRIL_PICKAXE, ItemID.ADAMANT_PICKAXE, + ItemID.RUNE_PICKAXE, ItemID.DRAGON_PICKAXE, ItemID.CRYSTAL_PICKAXE, + ItemID.TRAIL_GILDED_PICKAXE, ItemID._3A_PICKAXE, ItemID.DRAGON_PICKAXE_PRETTY, + ItemID.ZALCANO_PICKAXE, ItemID.TRAILBLAZER_PICKAXE_NO_INFERNAL, + ItemID.TRAILBLAZER_RELOADED_PICKAXE_NO_INFERNAL, ItemID.INFERNAL_PICKAXE); + add(symbols, "ROPE", ItemID.ROPE); + add(symbols, "SHANTAY_PASS", ItemID.SHANTAY_PASS); + add(symbols, "SKAVID_MAP", ItemID.SKAVIDMAP); + addRune(symbols, "SMOKE_RUNE", Runes.SMOKE); + addRune(symbols, "SOUL_RUNE", Runes.SOUL); + addRune(symbols, "STEAM_RUNE", Runes.STEAM); + addRune(symbols, "WATER_RUNE", Runes.WATER); + return Collections.unmodifiableMap(symbols); + } + + private static void add(Map symbols, String name, Integer... itemIds) { + put(symbols, name, new Resolution(ids(itemIds), Collections.emptySet(), + Collections.emptySet(), false)); + } + + private static void addRune(Map symbols, String name, Runes rune) { + LinkedHashSet itemIds = new LinkedHashSet<>(); + itemIds.add(rune.getItemId()); + Arrays.stream(Runes.getComboRunes(rune)) + .map(Runes::getItemId) + .forEach(itemIds::add); + put(symbols, name, new Resolution( + itemIds, + Rs2Staff.itemIdsProviding(rune), + Rs2Tome.itemIdsProviding(rune), + true)); + } + + private static Set ids(Integer... itemIds) { + return Collections.unmodifiableSet(new LinkedHashSet<>(Arrays.asList(itemIds))); + } + + private static void put(Map symbols, String name, Resolution resolution) { + if (resolution.itemIds.isEmpty() + || resolution.itemIds.stream().anyMatch(id -> id == null || id <= 0) + || resolution.staffIds.stream().anyMatch(id -> id == null || id <= 0) + || resolution.offhandIds.stream().anyMatch(id -> id == null || id <= 0)) { + throw new IllegalArgumentException("invalid item collection: " + name); + } + if (symbols.put(name, resolution) != null) { + throw new IllegalArgumentException("duplicate item collection: " + name); + } + } + + static final class Resolution { + private final Set itemIds; + private final Set staffIds; + private final Set offhandIds; + private final boolean rune; + + private Resolution(Set itemIds, Set staffIds, + Set offhandIds, boolean rune) { + this.itemIds = Set.copyOf(itemIds); + this.staffIds = Set.copyOf(staffIds); + this.offhandIds = Set.copyOf(offhandIds); + this.rune = rune; + } + + Set getItemIds() { return itemIds; } + Set getStaffIds() { return staffIds; } + Set getOffhandIds() { return offhandIds; } + boolean isRune() { return rune; } + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/UPSTREAM_COMPARISON.md b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/UPSTREAM_COMPARISON.md index 6201c47efb1..14f14c95568 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/UPSTREAM_COMPARISON.md +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/UPSTREAM_COMPARISON.md @@ -3,6 +3,242 @@ Comparison of [Skretzo/shortest-path](https://github.com/Skretzo/shortest-path) (upstream) against the Microbot fork. Original baseline: `07fca57` ("Data fixes and minor cleanups (#400)"). **Everything below the "Re-baseline" section refers to that old baseline and is partly superseded — read the re-baseline first.** +The maintained plan and ownership boundary are in `docs/walker-roadmap.md`. The machine-readable +baseline is `scripts/shortest-path-upstream-baseline.json` and can be checked with +`scripts/check-shortest-path-upstream.py`. + +--- + +## Re-baseline 2026-08-05 → upstream `ff8e961b32` + +Upstream moved by two collision-map commits since the 2026-07-20 review. The exact reviewed commit and +resource blobs are now recorded outside this narrative document so drift is machine-detectable. + +- Imported upstream `collision-map.zip` (`sha256:3a99d42fec10e12dbda96bbaae45b354d8e2270c4c1a453d033e95b7da2670d2`). + The map grew from 2,724 to 2,726 regions. Before import, the candidate passed `ShortestPathCoreTest`, + `WalkerRouteCorpusTest` and `PathfinderBenchmarkTest` in an isolated worktree. +- Closed the home-teleport coverage gap with Edgeville, Lunar and Arceuus destinations. Microbot uses one + semantic row per spellbook and intentionally rejects upstream's animation-duration variants because a + display/animation setting must not gate planner availability. +- Added `scripts/compare-shortest-path-transports.py` plus an exact reviewed semantic-debt baseline. The + comparator matches named network endpoints when boarding/landing coordinates differ, compares fares and + requirement dimensions, and makes identity swaps visible through content digests. It is enforced for + affected pull requests and by the weekly upstream workflow. +- Brought `minecarts.tsv` to exact compared parity. The old local rows charged 20 coins after The Forsaken + Tower; paid variants now require `7796<11` and free variants require `7796=11`. +- Closed all minigame-teleport identity gaps (Guardians of the Rift and the Varrock/Keldagrim Rat Pits + landings) and added Total-level, Combat-level and Quest-points gating to the local transport model. The + Pest Control minigame teleport now actually enforces its upstream 40 Combat requirement. +- Added a structured item-requirement compatibility layer. Numeric upstream expressions retain AND, + OR and quantity semantics, including upstream's maximum quantity within an OR group; legacy + `Item IDs` rows remain one OR group. Pathfinder, + transport-refresh caching, bank planning and Slayer transport preparation now consume that model. +- Imported the direct Max-cape and Quest-point-cape family from the pinned teleport-item artifact: + 16 route identities converged, four previously missing Max-cape destinations were restored, and the + duplicate Black chinchompa row was removed. Multi-level labels now resolve their leaf item sub-action; + POH-home variants remain programmatic Microbot behavior. +- Added a pinned symbolic collection adapter for walking tools, grapple gear, keys, passes, currencies + and cape/apron families. Unknown symbols fail closed. Rune symbols now delegate to Microbot's canonical + rune, staff and tome catalogs, preserve equipment-provider semantics through the immutable route edge, + and produce one atomic bank withdrawal/equipment loadout. All 43 shipped non-home spell rows carry the + reviewed upstream item requirements while retaining the existing Microbot landing coordinates; disputed + coordinate changes remain deferred for route/live evidence. All 45 River Lum and River Dougne canoe + routes now have exact compared field parity; the executor chooses the chain-specific map interface and + route-corpus coverage pins the new western network. Live River Dougne execution remains pending. +- Brought all 28 Quetzal network identities to exact compared parity and added a dedicated semantic + mapping from upstream `quetzal_whistle.tsv` to Microbot's inline teleport-item rows. All 14 whistle + destinations retain the current Quetzacalli Gorge landing, Cam Torum map label, canonical unlock + bitmasks and Twilight's Promise gate. Microbot intentionally records 14 item/consumability differences: + charged whistles remain consumable while perfected-infinite item `33120` remains available to the + Inventory (perm) policy, rather than inheriting upstream's family-level `Consumable=T` flag. +- Corrected the executor's wilderness boundary check to the same inclusive maximum used by the planner. + The old executor-only `+1` admitted a teleport one Wilderness level beyond the planned limit. +- The comparator now ignores a comparable field only when its column is absent from one entire schema. + This reduced false field drift while preserving exact identity/content digests for real changes. +- Closed the lossless agility-shortcut requirement slice: all twelve reviewed grapple edges now require + both a crossbow family and a mith grapple, and the Trollheim rope edge carries its rope plus unlock + varbit in executable fields. Corrected current landings and durations for the Lumbridge-farm fence and + northern Varlamore rocks; real pathfinder corpus cases select both edges. The comparator now uses + RuneLite's explicit course-obstacle catalog to classify 114 known course identities without removing + them from total debt. Three Trollheim climbing-rock ascents moved from generic transports to exact + boots-gated agility edges while their descents remain generic; a pathfinder case proves the ascent. + The complete 88-edge Isafdar forest family now matches upstream landings, levels and durations: 66 + unconditional generic rows became Agility shortcuts, 22 missing edges were added and four stale local + landing variants were removed. The route corpus proves the three-edge dense-forest chain. The other 28 + exact cross-file identities that were still unrestricted generic transports now retain upstream's + Agility requirements across Brimhaven Dungeon (6), the Lumbridge cellar (2), Karamja rocks (6), Slayer + Tower (8) and Darkmeyer (6). Catalog tests pin their levels, durations and unlock varbits, and one real + pathfinder route per family proves graph selection. The semantic comparator now exposes any upstream + agility identity represented only as a local generic transport and pins that bypass class at zero. The + two diagonal Darkmeyer approaches that upstream intentionally models both ways remain exactly once as + generic transports alongside their gated shortcut variants. The current semantic inventory is 6,601 + shared, 1,016 upstream-only and 952 Microbot-only route identities, with 1,379 comparable field drifts; + agility debt is 231 identities, split into 114 course obstacles and 117 ordinary-world or unresolved + routes. Live interaction evidence for this slice remains pending. +- Added route-corpus coverage for Draynor's east sewer transition and for planner selection of `SHIP`, + `NPC` and `BOAT` travel families while retaining Microbot's explicit ship-deck/gangplank model. +- Added twelve intentional Microbot-only Barrows edges absent from the reviewed upstream artifact: six + exact spade-gated mound digs into the individual crypts and six object-backed crypt exits to + representative anchors on their matching surface mounds. A dedicated registry capability prevents + arbitrary object-less `Dig` rows from becoming executable. Static route coverage pins all six pairs and + rejects every sarcophagus object as a deterministic tunnel edge; the empty crypt is randomized and must + be observed by a future state-aware executor. Live mound round-trip evidence remains pending. +- Completed the static Laguna Aurorae spirit-tree perimeter from the pinned artifact. Nine object-`26262` + origins now feed the existing `Travel` executor and the already-present Pandemonium-gated destination; + route coverage proves the north-west origin selects the network. Spirit-tree shared identities rise from + 145 to 154 and upstream-only debt falls from 11 to the two POH directions already owned by Microbot's + programmatic POH integration. Tests reject adding those POH routes to the static TSV. A live Laguna + round trip remains required. +- Closed the executable part of ordinary `transports.tsv` debt. The two Elemental Workshop wall directions + now use current object `26115`, require the concrete battered key and sit behind a curated collision-edge + override so the planner cannot walk through the closed wall. The upstream steel-key-ring alternative is + intentionally stricter locally: ring possession does not prove that the battered key is stored. The + remaining 15 upstream-only identities are fully classified and digest-pinned (four superseded Piscatoris + anchors, eight id-less Marim stairs, one interaction-less Daero jump and two unsafe Varrock trellis rows), + leaving zero unexplained ordinary route identities. A route regression proves battered-key selection and + key-ring-only rejection; live wall execution remains pending. +- Imported the four genuinely missing Pandemonium ship directions between Port Sarim, Musa Point and the + island using the reviewed Captain Tobias, Customs officer and Seaman Morris ids/actions. All four retain + the quest gate and 30-coin fare, resolve to direct terminal travel, and have real-pathfinder selection plus + fail-closed prerequisite coverage. The remaining six upstream-only ship identities are exact-classified + representations of Microbot's current Corsair Cove, Ardougne and Void Outpost deck/landing coordinates; + a live Pandemonium round trip remains pending. +- Added a pinned dual-engine evaluation harness. A declared adapter patch retains upstream's exact selected + transport object through `NodeGraph`/`PathStep`, avoiding the ambiguous endpoint rematch documented by + upstream itself. Static, real White Wolf surface-tunnel-surface, same-edge network alternatives and + bank-disabled/start-at-bank policies agree on reachability, termination, exact selected corpus IDs and + route cost. A separate-bank detour and four source-aware spell slices (carried/banked raw runes, + separate staff/tome, and a missing ordinary item) also agree. The corpus explicitly pins one reviewed, game-semantic + divergence: upstream rejects one Twinflame staff as the provider for both fire and water clauses because + its requirement evaluator consumes the staff substitution after the first clause; Microbot reuses the + same selected combination staff across every compatible clause. The 16-case gate requires 15 exact + parity results plus this one documented expected divergence, and fails if either an unexpected difference + appears or the reviewed difference disappears. The local adapter performs several workflow searches while + upstream carries bank state through one graph, so node/time metrics for that case are diagnostic rather + than core-performance parity. The gate runs the local core, the production-packaged pinned upstream adapter + and an independently compiled temporary checkout. It requires the two upstream executions to agree on all + semantic result fields unless the corpus already declares an input-policy divergence. +- Converted the local side of that harness and synchronous production planning to one `Rs2RoutePlanner` + boundary. `Rs2PathApi` now resolves an immutable policy snapshot before engine dispatch, including bank, + Wilderness, dangerous-NPC, teleport, membership, live-collision, cutoff, enabled-family and restriction + state; unresolved requests fail instead of consulting mutable globals. Exact local transport identity is + retained only as an opaque package-private payload on the immutable edge. Microbot executor admission and + zero-rune home-teleport capability are injected at plugin composition through `TransportPlanningPolicy`, + and the pathfinder core is CI-guarded against importing the executor registry. ADR 0006 established the + production-capable upstream shadow adapter as the next milestone before further broad family imports. +- Packaged the reviewed non-UI upstream core in an isolated source set and added a default-off production + adapter. Both engines consume the same resolved request and immutable planning snapshot; upstream maps a + selected transport back to the exact already-admitted Microbot edge by object identity. Shadow execution is + bounded to one worker and one queued request, never publishes an execution route, rejects stale active-route + generations and covers synchronous queries, ordinary active walker routes and cave-route selection. The + facade exposes the latest structured comparison plus aggregate match, divergence, failure, stale, discard + and exact-route-shape-difference counters. Completed outcomes distinguish ordinary replans from recovery, + classify explicit bank-workflow legs, retain selected transport executor/type families and count live + collision only when the overlay answers a search edge. The schema-versioned Agent Server endpoint exposes + this coordinate-free evidence through `microbot-cli walker shadow`, and + `evaluate-walker-shadow-evidence.py` enforces recovery, bank-workflow, collision and transport-family + diversity plus terminal blocking-walk/recovered-arrival outcomes rather than treating enabled settings or + a matching replan as behavioral evidence. + Twelve accepted live-shadow sessions now provide 141/141 semantic matches and 71/71 walker arrivals. The + aggregate closes every F2P live minimum, including 75 active routes, 15 active replans, 11 recovery replans, + 39 underground comparisons, 18 walking-only cave selections and ten explicit item-gated bank-to-target + comparisons. There is no semantic divergence, planner failure, pending/discarded work, unreachable result or + exit. Seventy-six exact-shape differences are equal-cost alternatives with matching selected transports; + retained diagnostics classify them across transport-free replan/recovery, bank/canoe and mixed surface + slices, with none in the underground comparisons and none associated with a non-arrival. Five clean samples + from the exact evaluated revision pass the timing gate at a `0.407` upstream/local comparable-suite median + ratio. Sanitized source snapshots and accepted reports are tracked under + `docs/evidence/walker/2026-08-05/`. The explicit F2P selector and rollback test are also complete; + members-policy selection requires separate representative members-world evidence. + `check-shortest-path-vendored-core.py` pins all source/metadata digests and can prove every undeclared file + byte-identical to the reviewed checkout. +- Added an explicit `LOCAL`, `SHADOW` and `UPSTREAM_F2P_CANARY` selection state. `LOCAL` remains the default; + `SHADOW` cannot select a route; and the canary is eligible only for resolved non-members policy. The canary + keeps active publication in the calculating phase until both candidates finish, selects upstream only for + a semantic match, and otherwise retains local with separate divergence/failure fallback counters. This is + conservative containment rather than treating local as a correctness oracle. Exact upstream selections + are temporarily materialized into the legacy completed-pathfinder view for existing runtime consumers. + Remove that shell with the local planner after the two-release/1,000-comparison fallback sunset. A live + F2P-17 underground run made ten upstream selections and ten arrivals without divergence or failure. A + separate test-only forced-failure run made zero upstream selections, ten local failure fallbacks and ten + arrivals. This validates the opt-in selector; the default remains local until an F2P release is explicitly + approved. +- Migrated active destination bank-item discovery to exact immutable `Rs2TransportEdge` values. Fare, + rune, fairy-ring, purchasable-item and structured AND/OR requirement selection no longer require a + concrete selected `Transport`; the transitional `LegacyRoutePlan` handoff is removed and CI prevents it + returning. The deprecated concrete helper remains only as a Hub compatibility API, outside the active + banking and executor contracts. +- Bound active runtime transport discovery to the exact selected route edge. Completed pathfinders publish + an immutable, source-identity-checked route snapshot; raw-segment dispatch, ranged classification and + nearby current-tile recovery no longer rescan all catalog rows at an origin. Immutable edges carry an + explicit Microbot executor capability, while the exact local concrete object is retained only as an opaque + package-private payload for behavior-bearing handlers such as POH. Catalog rows without a registered + executor fail closed before planning. All four home teleports use one exact-name, zero-rune widget + executor shared by planner capability and runtime dispatch. The 225 directed hot-air-balloon edges now + use a dedicated exact-destination map executor and observed-landing contract. Static and dual-engine + selection coverage is green; live evidence proves all edges remain unavailable when station unlocks are + absent, while a successful flight still needs an unlocked-account run. +- Closed the live Port Sarim/Musa Point terminal-ship incident without changing upstream planning data. + Current NPC menus expose `Travel` while the reviewed catalog retains destination labels, and current + travel lands directly on the ground after auto-completing the catalogued deck/gangplank pair. The executor + now preserves the configured action first, applies a conservative `SHIP`-only `Travel` fallback, limits an + exact selected edge to one interaction per top-level walk, and accepts only the immediate planned landing + continuation. Live walks in both directions produced one click, an observed handoff and no timeout. +- Tightened the Microbot-owned Al Kharid toll executor after live investigation exposed both a false landing + and stale object-id collision. The raw door scanner now defers the catalog edge to one selected-transport + owner; that owner resolves the transformed live Gate by configured action and exact edge geometry, with no + historical-id fallback. Completion requires the exact opposite-side destination and an unresolved + interaction bubbles back without a handoff. Rebuilt live walks in both directions selected the Gate, + issued one toll interaction, reached the exact selected landing and emitted the expected handoff without + raw-obstacle interception or timeout. +- Removed the local `Open;Manhole;881` Varrock Sewers row after a live walk proved that it modeled cover + preparation as though it were a surface-to-underground transition. The reviewed upstream catalog contains + only `Climb-down;Manhole;882`; Microbot's closed-object handling can still open `881`, refind `882` and + execute that exact edge. A static catalog regression pins the distinction, and five consecutive F2P live + walks arrived on the exact sewer tile without a trapdoor timeout or route stall. +- Replaced the terminal-travel type assumption with a row-level execution contract. `SHIP`, `NPC` and + `BOAT` describe journeys whose configured target may be an NPC or scene object; immutable route edges now + carry a direct or dialogue-destination mode in addition to `TERMINAL_TRAVEL`. Semantic live matching + admits the direct Al Kharid/Tempoross `Board;Ferry` edge without trusting its historical object id. + Forty-one multi-step terminal rows remain deliberately fail-closed and are pinned by interaction group + until their destination-selection flows are implemented. The Ferry is statically selected by the route + corpus, while successful members-world outbound/reverse execution remains pending; the rebuilt free-world + run correctly admitted zero boat edges and therefore did not produce false runtime evidence. +- The July architectural conclusion still holds: use upstream as a tracked planner/data reference and + retain Microbot ownership of runtime execution and automation policy. +- Advanced the production planner boundary without selecting a replacement engine: synchronous walker + queries and active route restart/cancellation now enter through immutable `Rs2RouteRequest` policy and + `Rs2PathApi`. Configuration refresh, cave walking-only selection, executor ownership and local + `Pathfinder` construction are confined to that seam, with CI rejecting reintroduction in the walker and + lifecycle packages. NPC target selection and bank-route comparison also use request-scoped policy; bank + diagnostics retain the exact typed edges chosen by search rather than rematching the mutable catalog. + The next migration slice is also complete: a generation-tagged immutable active-route status now serves + walker progress/recovery, Quest Helper and obstacle consumers, while CI rejects concrete active planner + reads outside the facade. Slayer bank-item preparation is request-scoped and exposes an exact immutable + edge replacement for its deprecated concrete-transport helper. Explicit walker policy/config operations + are now named facade calls, and CI rejects mutable configuration in the walker. Leagues cache invalidation + also enters through the facade, while its catalog injection receives a narrow transport-usability + predicate instead of `PathfinderConfig`; no production consumer outside the facade or shortest-path + implementation imports the mutable config. Concrete transport payloads and overlay ownership are the next + boundary decision, not another blanket type migration. The first classified payload slice is now complete: + hot-air-balloon execution consumes the immutable selected edge, recovery and obstacle code ask only for + transport-origin presence, door catalog classification uses immutable edge views, and bank-route distance + scoring follows the exact ordered route steps rather than rematching a same-endpoint catalog entry. + `TransportRouteAnalysis` now retains every compared leg's exact steps, and withdrawal planning consumes + the selected bank-to-target edges instead of running a second search from the pre-bank location. CI + prohibits concrete transport imports from migrated packages and rejects that compare-then-replan pattern. + +Next work is to approve the F2P-scoped release decision and collect representative members-only evidence before +any members-policy selection. Broad family-by-family transport convergence remains paused except for +incident-driven fixes. +Runtime interaction changes still require live harness evidence. Do not use the old priority list at the +bottom of this historical document as the active queue. + +The opt-in F2P harness exports the endpoint's coordinate-free schema-v2 snapshot and rejects empty, unsettled, +divergent or failed ordinary shadow runs. The full accepted aggregate now covers the required surface, +recovery, bank, teleport, network and terminal-travel mix. The fixed Varrock manhole route also serves as the +selection/rollback release case described above. + --- ## Re-baseline 2026-07-20 → upstream `7e7e5bf94b` @@ -44,14 +280,13 @@ Microbot loads 22 TSVs (see `Transport.java`). File-level diff vs `skretzo/maste - Of the 778: only **14 named** transports; **764 anonymous route objects** (372 Climb, 137 Ladder, 123 Stairs, 43 Staircase, gates/doors/caves…). - **Not drift:** 737 distinct origins in the 778, of which only 24 overlap a Microbot origin — **713 are genuinely new origin tiles**. Concentrated central 2500–2999 (399), Varlamore/Kebos 1500–1999 (133), Misthalin 3000–3499 (131). - **Verdict:** real coverage gap (new route objects + new areas Microbot's baseline predates). -- **✅ DONE (2026-07-20):** imported **768** of the 778 (`transports.tsv` 4949→5717), scripted + validated. Conversion: action `space`→`;` form; inserted `Currency`/`isMembers` columns; upstream named item variations + `|` OR-sets → Microbot numeric id-sets (`AXE`/`MACHETE`/`PICKAXE`/`ROPE` via `ItemVariations`→`ItemID`; `COINS=N`→Currency); fixed upstream typo `Shadows`→`Shadow of the Storm` (else the `Quest` gate silently drops). **Excluded:** 15 id-less rows (bare `Climb-up Staircase`, name-only walls/gates) unmatchable in Microbot's id-based format, and **2 Garden of Tranquillity trellis rows (obj 2149)** Microbot intentionally omits — caught by `testVarrockSewerPathAvoidsDisabledPalaceTrellisShortcut`. Paired with the **updated collision map** (`collision-map.zip` 2663→2724 regions) so new-area routes have collision coverage. Validated: shortestpath suite green (73 tests, incl. real cross-region pathfinding). +- **✅ DONE (2026-07-20):** imported **768** of the 778 (`transports.tsv` 4949→5717), scripted + validated. Conversion: action `space`→`;` form; inserted `Currency`/`isMembers` columns; upstream named item variations + `|` OR-sets → Microbot numeric id-sets (`AXE`/`MACHETE`/`PICKAXE`/`ROPE` via `ItemVariations`→`ItemID`; `COINS=N`→Currency); fixed upstream typo `Shadows`→`Shadow of the Storm` (else the `Quest` gate silently drops). The original import excluded 15 id-less rows and **2 Garden of Tranquillity trellis rows (obj 2149)**. On 2026-08-05 the two Elemental Workshop wall directions were recovered with current object `26115`; the remaining 15 identities are now explicitly classified rather than unexplained. The trellis remains intentionally omitted and is caught by `testVarrockSewerPathAvoidsDisabledPalaceTrellisShortcut`. Paired with the **updated collision map** (`collision-map.zip` 2663→2724 regions) so new-area routes have collision coverage. Validated: shortestpath suite green (73 tests, incl. real cross-region pathfinding). - **Caveat:** imported members-area routes carry an empty `isMembers` (upstream lacked that column) — same as upstream's own behaviour; harmless since F2P can't reach those origins anyway. ### Recommended next Stage-4 target -**Home-teleport coverage** is the next confirmed upstream data gap: compare upstream -`teleportation_spells_home.tsv` (16 variants) with Microbot's two home-teleport rows and backfill only -the missing, valid variants. #2 and #3 are complete, #11 is a stale premise, and #10/#12/#30 have no -confirmed upstream implementation to backport. +✅ Completed 2026-08-04. Edgeville, Lunar and Arceuus were added with spellbook, quest, membership and +cooldown requirements. Animation-setting duplicates were intentionally not imported. See the newer +re-baseline above for the next work. --- diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/WEBWALKER_IMPROVEMENT_PLAN.md b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/WEBWALKER_IMPROVEMENT_PLAN.md index 5806f82f2d7..32a939cffb4 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/WEBWALKER_IMPROVEMENT_PLAN.md +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/WEBWALKER_IMPROVEMENT_PLAN.md @@ -60,7 +60,7 @@ The "completeness of navigating the world" half. | 19 | DONE | After a door interact, if the player didn't move and `isQuestLockedDoorDialogue()` matches ("quest" / "you need to" / "you must" / "cannot enter" / "requires you" / …), log `warn` with door details + dialogue text, add the tile to `sessionBlacklistedDoors`, close the dialogue, refresh `PathfinderConfig` (re-read quest/varbit state) and `recalculatePath()`. Entry of `handleDoors` short-circuits on blacklisted tiles to break the retry loop. | `Rs2Walker.java:1256–1275, 1376–1396` | M | | 20 | DONE | POH `convertInstancedWorldPoint()` null-path diagnostics added: `handleDoors` null log now includes rawFrom/rawTo/fromWp/toWp and `idx/pathSize`; `setTarget` POH instance start now null-checks `WorldPoint.fromLocalInstance` (falls back to raw world location with a `warn` when it returns null) | `Rs2Walker.java:1206–1213, 1473–1486` | M | | 21 | DONE | Minimap click now scans forward from first past-threshold tile to the furthest same-plane, non-transport-origin tile within ~14-tile Chebyshev reach, then advances the loop index past the intermediate tiles. Cuts tick count on long diagonal runs by ~30-40% since Chebyshev reach is 1.4× the cardinal step count. | `Rs2Walker.java:476–531` | M | -| 22 | DONE | `Telemetry.recordUnreachable(cause, player, target, pathEndpoint, pathSize, threshold, pathfinder)` logs at `warn` with pathfinder stats; wired into both UNREACHABLE exits (no-walkable-path and partial-retries-exhausted) with `unreachableCount` counter exposed to probes | `Rs2Walker.java:108, 128–141, 158, 306–308, 532–533` | S | +| 22 | DONE | `Telemetry.recordUnreachable(cause, player, target, pathEndpoint, pathSize, threshold, routeMetrics)` logs at `warn` with planner-independent route metrics; wired into both UNREACHABLE exits (no-walkable-path and partial-retries-exhausted) with `unreachableCount` counter exposed to probes | `Rs2Walker.java` | S | --- @@ -128,6 +128,14 @@ Items already catalogued in `UPSTREAM_COMPARISON.md` are surfaced here only wher ## Facade migration (2026-07-20) +> **2026-08-05 boundary review:** direct `ShortestPathPlugin` state access has been migrated back behind +> `Rs2PathApi` and is now CI-enforced by `scripts/check-shortest-path-boundary.py`. The class remains a +> compatibility seam rather than the final stable API because it still exposes concrete `Pathfinder`, +> mutable `PathfinderConfig` and `Transport` values. The canonical next steps are in +> `docs/walker-roadmap.md` “Tighten the planner boundary.” The first operation-level slice now provides +> immutable route requests/results and has migrated bank, deposit-box and banked-destination searches off +> direct `Pathfinder` construction. + **Goal:** decouple automation from the shortest-path *internals* so future upstream backports stop rippling into `Rs2Walker` and the other consumers. The fork stays a fork; this is a boundary, not a rewrite. Once the boundary exists, backporting an upstream fix means changing code behind the facade only. ### Why first diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/CollisionMap.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/CollisionMap.java index edb7b7cdaea..e40d9123ebe 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/CollisionMap.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/CollisionMap.java @@ -13,6 +13,7 @@ import net.runelite.client.plugins.microbot.util.player.Rs2Player; import java.util.*; +import java.util.function.IntSupplier; @Slf4j public class CollisionMap { @@ -29,23 +30,38 @@ public class CollisionMap { */ private final LiveCollisionOverlay overlay; + /** + * Supplies the live player region for instance-only obstacle policy. Static/offline maps use a + * sentinel supplier so pathfinding tests never reach into the RuneLite client thread. + */ + private final IntSupplier currentRegionIdSupplier; + /** * Live view pinned for the duration of one search, so a mid-search merge on the client thread cannot * mix two states into a single path. Refreshed via {@link #beginSearch()}. */ private LiveEdgeSource pinnedLive; + /** Number of edge reads answered by the pinned live overlay during the current search. */ + private long liveEdgeQueries; + public byte[] getPlanes() { return collisionData.getRegionMapPlaneCounts(); } public CollisionMap(SplitFlagMap collisionData) { - this(collisionData, new LiveCollisionOverlay()); + this(collisionData, new LiveCollisionOverlay(), () -> -1); } public CollisionMap(SplitFlagMap collisionData, LiveCollisionOverlay overlay) { + this(collisionData, overlay, CollisionMap::readLivePlayerRegionId); + } + + CollisionMap(SplitFlagMap collisionData, LiveCollisionOverlay overlay, + IntSupplier currentRegionIdSupplier) { this.collisionData = collisionData; this.overlay = overlay; + this.currentRegionIdSupplier = currentRegionIdSupplier; } /** @@ -55,6 +71,7 @@ public CollisionMap(SplitFlagMap collisionData, LiveCollisionOverlay overlay) { */ public void beginSearch() { pinnedLive = overlay.current(); + liveEdgeQueries = 0L; } private boolean get(int x, int y, int z, int flag) { @@ -62,12 +79,17 @@ private boolean get(int x, int y, int z, int flag) { if (live != null) { final Boolean liveEdge = live.edge(x, y, z, flag); if (liveEdge != null) { + liveEdgeQueries++; return liveEdge; } } return collisionData.get(x, y, z, flag); } + public long getLiveEdgeQueries() { + return liveEdgeQueries; + } + public boolean n(int x, int y, int z) { return get(x, y, z, 0); } @@ -219,8 +241,7 @@ private int getCachedRegionId() { long now = System.currentTimeMillis(); if (now - cachedRegionIdTime > REGION_CACHE_MS) { try { - WorldPoint loc = Rs2Player.getWorldLocation(); - cachedRegionId = loc != null ? loc.getRegionID() : -1; + cachedRegionId = currentRegionIdSupplier.getAsInt(); } catch (Exception e) { cachedRegionId = -1; } @@ -229,6 +250,11 @@ private int getCachedRegionId() { return cachedRegionId; } + private static int readLivePlayerRegionId() { + WorldPoint loc = Rs2Player.getWorldLocation(); + return loc != null ? loc.getRegionID() : -1; + } + public List getNeighbors(Node node, VisitedTiles visited, PathfinderConfig config, Set targets) { final int x = WorldPointUtil.unpackWorldX(node.packedPosition); final int y = WorldPointUtil.unpackWorldY(node.packedPosition); @@ -264,14 +290,15 @@ public List getNeighbors(Node node, VisitedTiles visited, PathfinderConfig continue; } int cost = config.getDistanceBeforeUsingTeleport() + transport.getDuration(); - neighbors.add(new TransportNode(transport.getDestination(), node, cost)); + neighbors.add(new TransportNode(transport.getDestination(), node, cost, transport)); if (isMoa) { moaAddedHere++; if (moaCosts == null) moaCosts = new ArrayList<>(); moaCosts.add(cost); } } else { - neighbors.add(new TransportNode(transport.getDestination(), node, transport.getDuration())); + neighbors.add(new TransportNode( + transport.getDestination(), node, transport.getDuration(), transport)); } //END microbot variables } @@ -371,9 +398,10 @@ public List getReverseNeighbors(Node node, VisitedTiles visitedBackward, P if (config.isIgnoreTeleportAndItems()) { continue; } - neighbors.add(new TransportNode(origin, node, config.getDistanceBeforeUsingTeleport() + transport.getDuration())); + neighbors.add(new TransportNode(origin, node, + config.getDistanceBeforeUsingTeleport() + transport.getDuration(), transport)); } else { - neighbors.add(new TransportNode(origin, node, transport.getDuration())); + neighbors.add(new TransportNode(origin, node, transport.getDuration(), transport)); } } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Node.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Node.java index b5235d3b9d3..258fcb6f214 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Node.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Node.java @@ -12,18 +12,12 @@ public class Node { public final int packedPosition; public final Node previous; public final int cost; - public int heuristic; // Per-node random value used as a secondary priority-queue comparator. Breaks ties - // between equal-fCost nodes in random order so the pathfinder explores equivalent + // between equal-cost nodes in random order so the pathfinder explores equivalent // routes in a different sequence each run, producing distinct (but still optimal) - // tile sequences between the same start/target pair. Prevents the "identical route - // every trip" fingerprint a deterministic A* would leave. + // tile sequences between the same start/target pair. public final int tiebreaker; - public int fCost() { - return cost + heuristic; - } - public Node(WorldPoint position, Node previous, int wait) { this.packedPosition = WorldPointUtil.packWorldPoint(position); this.previous = previous; @@ -47,10 +41,19 @@ public Node(int packedPosition, Node previous) { } public List getPath() { - List path = new ArrayList<>(); - for (Node n = this; n != null; n = n.previous) { + List nodes = getNodePath(); + List path = new ArrayList<>(nodes.size()); + for (Node n : nodes) { path.add(WorldPointUtil.unpackWorldPoint(n.packedPosition)); } + return path; + } + + List getNodePath() { + List path = new ArrayList<>(); + for (Node n = this; n != null; n = n.previous) { + path.add(n); + } Collections.reverse(path); return path; } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathEdge.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathEdge.java new file mode 100644 index 00000000000..59d6b14d8ae --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathEdge.java @@ -0,0 +1,126 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** One materialized edge in a chosen local pathfinder route. */ +public final class PathEdge +{ + private final WorldPoint from; + private final WorldPoint to; + private final Transport transport; + + PathEdge(WorldPoint from, WorldPoint to, Transport transport) + { + this.from = from; + this.to = to; + this.transport = transport; + } + + static List fromForwardChain(Node lastNode) + { + if (lastNode == null) + { + return Collections.emptyList(); + } + List nodes = lastNode.getNodePath(); + List edges = new ArrayList<>(Math.max(0, nodes.size() - 1)); + for (int i = 1; i < nodes.size(); i++) + { + Node from = nodes.get(i - 1); + Node to = nodes.get(i); + Transport transport = to instanceof TransportNode + ? ((TransportNode) to).getTransport() + : null; + edges.add(new PathEdge( + WorldPointUtil.unpackWorldPoint(from.packedPosition), + WorldPointUtil.unpackWorldPoint(to.packedPosition), + transport)); + } + return Collections.unmodifiableList(edges); + } + + /** + * Build the temporary local compatibility view for a completed engine-neutral route. + * The transport list is aligned with path edges and may contain {@code null} walking entries. + */ + static List fromMaterializedRoute( + List path, List transportsByStep) + { + if (path == null || transportsByStep == null) + { + throw new IllegalArgumentException("materialized path and transports are required"); + } + int expected = Math.max(0, path.size() - 1); + if (transportsByStep.size() != expected) + { + throw new IllegalArgumentException( + "materialized transport count must match path edge count"); + } + List edges = new ArrayList<>(expected); + for (int i = 0; i < expected; i++) + { + WorldPoint from = path.get(i); + WorldPoint to = path.get(i + 1); + if (from == null || to == null) + { + throw new IllegalArgumentException("materialized path points must be non-null"); + } + Transport transport = transportsByStep.get(i); + if (transport != null && !to.equals(transport.getDestination())) + { + throw new IllegalArgumentException( + "materialized transport destination must match its route step"); + } + edges.add(new PathEdge(from, to, transport)); + } + return Collections.unmodifiableList(edges); + } + + /** + * Combine a normal start-to-meeting chain with the reverse-search meeting-to-goal chain. + * Reverse transport metadata belongs to the {@code from} node, unlike a forward chain where it + * belongs to the {@code to} node. + */ + static List fromBidirectionalChains(Node forwardAtMeet, Node backwardAtMeet) + { + List edges = new ArrayList<>(fromForwardChain(forwardAtMeet)); + for (Node from = backwardAtMeet; from != null && from.previous != null; from = from.previous) + { + Node to = from.previous; + Transport transport = from instanceof TransportNode + ? ((TransportNode) from).getTransport() + : null; + edges.add(new PathEdge( + WorldPointUtil.unpackWorldPoint(from.packedPosition), + WorldPointUtil.unpackWorldPoint(to.packedPosition), + transport)); + } + return Collections.unmodifiableList(edges); + } + + public WorldPoint getFrom() + { + return from; + } + + public WorldPoint getTo() + { + return to; + } + + public Transport getTransport() + { + return transport; + } + + public boolean isTransport() + { + return transport != null; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathTerminationReason.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathTerminationReason.java new file mode 100644 index 00000000000..a2d4c5bebe6 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathTerminationReason.java @@ -0,0 +1,17 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder; + +/** + * Why a pathfinder run stopped. + * + *

The first four values intentionally match the tracked shortest-path upstream contract. Microbot + * adds {@link #FAILED} because its legacy pathfinder catches runtime failures in order to keep the + * client alive; callers must be able to distinguish that case from an exhausted graph.

+ */ +public enum PathTerminationReason +{ + TARGET_REACHED, + SEARCH_EXHAUSTED, + CUTOFF_REACHED, + CANCELLED, + FAILED +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Pathfinder.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Pathfinder.java index f7d32a4b1f9..7cd40039930 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Pathfinder.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Pathfinder.java @@ -25,8 +25,7 @@ private static void pathfinderDiag(String format, Object... args) { } private static final Comparator NODE_ORDER = Comparator - .comparingInt(Node::fCost) - .thenComparingInt(n -> n.cost) + .comparingInt((Node n) -> n.cost) .thenComparingInt(n -> n.tiebreaker); /** @@ -39,6 +38,11 @@ private static void pathfinderDiag(String format, Object... args) { @Getter private volatile boolean done = false; private volatile boolean cancelled = false; + @Getter + private volatile PathTerminationReason terminationReason; + /** Search cost of the returned raw path, or {@code -1} when no path node was selected. */ + @Getter + private volatile long selectedPathCost = -1L; private final int start; private final Set targets; @@ -50,24 +54,18 @@ private static void pathfinderDiag(String format, Object... args) { private CollisionMap map; private final boolean targetInWilderness; - // Walking subgraph uses A* (boundary is a PQ keyed on f = g + Chebyshev heuristic), - // so among walking nodes the search picks the most promising direction first. - // Transports stay in a separate PQ keyed on g-cost only — they're picked when their - // travel cost is cheaper than any frontier walking node's g-cost, preserving the - // existing "try cheap transports before walking farther" selection behavior. + // Both walking and transport frontiers are ordered by travelled cost. A geometric + // heuristic is not admissible in a graph containing canoes, teleports and other + // long-distance edges: it can permanently visit a farther transport origin before + // a cheaper origin whose straight-line direction initially points away from the + // target. Cost ordering matches the reviewed upstream search semantics and keeps + // exact selected transport identity stable across the engine boundary. // - // Comparator chain is (fCost, gCost, tiebreaker): - // 1. fCost — standard A* primary ordering. - // 2. gCost — required for correctness under early-discovery. addNeighbors() marks - // a neighbor visited at insert time (not at pop), so a node only ever enters - // the PQ once. If two equal-fCost nodes have different gCost, popping the - // higher-gCost one first would fix their shared neighbor's gCost to a - // suboptimal value (because visited is already set when the lower-g node later - // tries to discover the same neighbor). Preferring lower gCost on ties keeps - // early-discovery optimal. - // 3. tiebreaker — per-node random. Among nodes with identical (f, g) — common in - // open-grid regions where many tiles share the same distance-from-start and - // distance-to-goal — this rotates the exploration order each run so paths + // Comparator chain is (gCost, tiebreaker): + // 1. gCost — required for correctness because addNeighbors() marks a neighbor + // visited at insert time and therefore never relaxes it later. + // 2. tiebreaker — per-node random. Among nodes with identical cost — common in + // open-grid regions — this rotates the exploration order each run so paths // diverge tile-by-tile between successive searches with the same endpoints. // Kills the deterministic "identical route every trip" fingerprint. private final Queue boundary = new PriorityQueue<>(4096, NODE_ORDER); @@ -78,11 +76,14 @@ private static void pathfinderDiag(String format, Object... args) { private volatile List path = Collections.emptyList(); private volatile List smoothedPath = Collections.emptyList(); - private volatile boolean pathNeedsUpdate = false; + /** Node identity represented by {@link #path}; avoids a lost-update race with live path readers. */ + private volatile Node materializedPathLastNode; private volatile boolean smoothed = false; private volatile Node bestLastNode; /** When set, {@link #getPath()} returns this list (bidirectional join or early exact hit). */ private volatile List joinedPath; + /** Edge-preserving counterpart to {@link #joinedPath}. */ + private volatile List joinedPathEdges; /** * Teleportation transports are updated when this changes. * Can be either: @@ -120,6 +121,67 @@ public Pathfinder(PathfinderConfig config, WorldPoint start, WorldPoint target) this(config, start, Set.of(target)); } + /** + * Materialize a completed planner-independent route behind the legacy concrete pathfinder surface. + * + *

This is a transitional adapter for the shortest-path overlays and out-of-tree callers that still + * consume {@code ShortestPathPlugin.pathfinder}. New walker code must consume the immutable route + * contract instead. Remove this factory with the local planner after the staged rollout sunset.

+ */ + public static Pathfinder completedRoute( + PathfinderConfig config, + WorldPoint start, + Set targets, + List path, + List transportsByStep, + PathTerminationReason terminationReason, + long selectedPathCost, + long searchNanos, + long nodesChecked, + long transportsChecked, + long liveCollisionEdgesChecked) { + Objects.requireNonNull(config, "config"); + Objects.requireNonNull(start, "start"); + Objects.requireNonNull(targets, "targets"); + Objects.requireNonNull(path, "path"); + Objects.requireNonNull(transportsByStep, "transportsByStep"); + Objects.requireNonNull(terminationReason, "terminationReason"); + if (!path.isEmpty() && !start.equals(path.get(0))) { + throw new IllegalArgumentException("materialized route must start at the requested start"); + } + if (selectedPathCost < -1L || searchNanos < -1L || nodesChecked < -1L + || transportsChecked < -1L || liveCollisionEdgesChecked < -1L) { + throw new IllegalArgumentException("materialized route metrics must be non-negative or unavailable"); + } + + Pathfinder completed = new Pathfinder(config, start, targets); + List immutablePath = Collections.unmodifiableList(new ArrayList<>(path)); + completed.map = config.getMap(); + completed.joinedPath = immutablePath; + completed.joinedPathEdges = PathEdge.fromMaterializedRoute(immutablePath, transportsByStep); + completed.terminationReason = terminationReason; + completed.selectedPathCost = selectedPathCost; + completed.cancelled = false; + completed.done = true; + completed.stats.complete( + metricOrZero(searchNanos), + metricAsInt(nodesChecked), + metricAsInt(transportsChecked), + metricOrZero(liveCollisionEdgesChecked)); + return completed; + } + + private static long metricOrZero(long metric) { + return metric < 0L ? 0L : metric; + } + + private static int metricAsInt(long metric) { + if (metric < 0L) { + return 0; + } + return (int) Math.min(Integer.MAX_VALUE, metric); + } + public WorldPoint getStart() { return WorldPointUtil.unpackWorldPoint(start); } @@ -151,13 +213,30 @@ public List getPath() { return path; } - if (pathNeedsUpdate) { - path = lastNode.getPath(); - pathNeedsUpdate = false; + List currentPath = path; + if (materializedPathLastNode != lastNode) { + // The walker may read a partial path while this search is still running. Identity-based + // invalidation is required here: a reader clearing a shared dirty flag can otherwise erase + // a newer pathfinder-thread update, leaving getPath() and getPathEdges() on different nodes. + currentPath = Collections.unmodifiableList(lastNode.getPath()); + path = currentPath; + materializedPathLastNode = lastNode; smoothed = false; } - return path; + return currentPath; + } + + /** + * Materialized edges for the current best route. Transport edges retain the exact catalog entry + * selected by the search; callers outside shortest-path should map them to owned immutable values. + */ + public List getPathEdges() { + List joined = joinedPathEdges; + if (joined != null) { + return joined; + } + return PathEdge.fromForwardChain(bestLastNode); } /** @@ -203,7 +282,6 @@ private Set buildTransportAnchors(List path) { private void addNeighbors(Node node) { List nodes = map.getNeighbors(node, visited, config, targets); - boolean afterTransport = node instanceof TransportNode; for (Node neighbor : nodes) { if (config.avoidWilderness(node.packedPosition, neighbor.packedPosition, targetInWilderness)) { continue; @@ -214,189 +292,12 @@ private void addNeighbors(Node node) { pending.add(neighbor); ++stats.transportsChecked; } else { - neighbor.heuristic = afterTransport ? 0 : heuristicToNearestTarget(neighbor.packedPosition); boundary.add(neighbor); ++stats.nodesChecked; } } } - // Admissible A* heuristic: Chebyshev 2D to the nearest target, with a modulo-6400 - // fallback for the surface↔underground Y-offset convention (OSRS shifts underground - // coords by +6400 on the Y axis, so Varrock sewers live at y≈9800 while Varrock sits - // at y≈3400). Plain Chebyshev would claim ~6200 tiles to any underground point, which - // misdirects A* into expanding the surface southward instead of routing through a - // nearby ladder/stairs transport. Taking min(direct, mod-6400) stays admissible - // because reaching a y-mirrored underground point still requires ≥ one transport - // (cost ≥ 0) on top of the mod-6400 walking distance. The band-aware distance lives in - // WorldPointUtil.undergroundAwareDistance so the walker uses the same metric. - - private int heuristicToNearestTarget(int packedPos) { - return applyLandmarks(packedPos, baseHeuristicToNearestTarget(packedPos), - fwdLandmark, fwdLandmarkResidual); - } - - private int baseHeuristicToNearestTarget(int packedPos) { - int posX = WorldPointUtil.unpackWorldX(packedPos); - int posY = WorldPointUtil.unpackWorldY(packedPos); - int best = Integer.MAX_VALUE; - for (int target : targetsPacked) { - int tx = WorldPointUtil.unpackWorldX(target); - int ty = WorldPointUtil.unpackWorldY(target); - int h = WorldPointUtil.undergroundAwareDistance(posX, posY, tx, ty); - if (h < best) { - best = h; - } - } - return best; - } - - private int heuristicFromStart(int packedPos) { - return applyLandmarks(packedPos, baseHeuristicFromStart(packedPos), - backLandmark, backLandmarkResidual); - } - - private int baseHeuristicFromStart(int packedPos) { - int posX = WorldPointUtil.unpackWorldX(packedPos); - int posY = WorldPointUtil.unpackWorldY(packedPos); - int sx = WorldPointUtil.unpackWorldX(start); - int sy = WorldPointUtil.unpackWorldY(start); - return WorldPointUtil.undergroundAwareDistance(posX, posY, sx, sy); - } - - // --- Network-transport-aware heuristic --------------------------------------------------- - // - // Network transports (fairy rings, spirit trees, gnome gliders, quetzals) are fully-connected - // hubs: reaching ANY origin lets you hop to ANY destination of that network for ~free. Plain - // Chebyshev is blind to this — a node next to the Ardougne fairy ring reads "~1350 tiles from - // the Farming Guild" by straight line, so A* buries the (optimal) cloak->fairy->CIR chain under - // a single direct teleport that the heuristic makes look closer. We fold the hubs into the - // heuristic as landmarks: for each enabled network whose destinations reach near the goal, every - // network origin is a landmark with residual = min(dest -> goal). Then - // h(node) = min(directWalk, dist(node, nearestOrigin) + residual). - // Each landmark term is a true lower bound (walking to the origin, a free-ish hop, then the - // residual walk to goal), so taking min with the admissible Chebyshev keeps the result both - // admissible AND consistent (the landmark set is fixed for the whole search). A* optimality is - // therefore preserved, while the search is now pulled toward useful hubs instead of ignoring - // them. The backward (bidirectional) arrays are symmetric: landmarks are destinations, residual - // is min(origin -> start). Unlike the reverted chain-bridge injection this adds no graph edges - // (so it can never teleport the player out of a building), and unlike the reverted post-transport - // cascade it never zeroes the heuristic (so it can never collapse into a whole-map Dijkstra). - private static final EnumSet NETWORK_HEURISTIC_TYPES = EnumSet.of( - TransportType.FAIRY_RING, TransportType.SPIRIT_TREE, - TransportType.GNOME_GLIDER, TransportType.QUETZAL); - - private int[] fwdLandmark = null; // packed network origins (reach a hub -> hop toward target) - private int[] fwdLandmarkResidual = null; // parallel: that network's min(dest -> nearest target) Chebyshev - private int[] backLandmark = null; // packed network destinations (symmetric, for backward search) - private int[] backLandmarkResidual = null; // parallel: that network's min(origin -> start) Chebyshev - - private int applyLandmarks(int packedPos, int base, int[] landmarks, int[] residuals) { - if (landmarks == null || landmarks.length == 0) { - return base; - } - int px = WorldPointUtil.unpackWorldX(packedPos); - int py = WorldPointUtil.unpackWorldY(packedPos); - int best = base; - for (int i = 0; i < landmarks.length; i++) { - int lx = WorldPointUtil.unpackWorldX(landmarks[i]); - int ly = WorldPointUtil.unpackWorldY(landmarks[i]); - int viaHub = Math.max(Math.abs(px - lx), Math.abs(py - ly)) + residuals[i]; - if (viaHub < best) { - best = viaHub; - } - } - return best; - } - - /** - * Builds {@link #fwdLandmark}/{@link #backLandmark} once per pathfind from the enabled network - * transports. A network only contributes landmarks if it gets you strictly closer to the goal - * (resp. start) than you already are — otherwise it is pure heuristic overhead with no benefit. - */ - private void computeNetworkLandmarks() { - Map> all = config.getTransports(); - if (all == null || all.isEmpty()) { - return; - } - - EnumMap> originsByType = new EnumMap<>(TransportType.class); - EnumMap> destsByType = new EnumMap<>(TransportType.class); - for (Set set : all.values()) { - if (set == null) { - continue; - } - for (Transport t : set) { - TransportType type = t.getType(); - if (type == null || !NETWORK_HEURISTIC_TYPES.contains(type)) { - continue; - } - WorldPoint o = t.getOrigin(); - WorldPoint d = t.getDestination(); - if (o == null || d == null) { - continue; - } - originsByType.computeIfAbsent(type, k -> new HashSet<>()).add(WorldPointUtil.packWorldPoint(o)); - destsByType.computeIfAbsent(type, k -> new HashSet<>()).add(WorldPointUtil.packWorldPoint(d)); - } - } - if (originsByType.isEmpty()) { - return; - } - - int startToGoal = minChebyshevStartToAnyTarget(); - List fwd = new ArrayList<>(); // {originPacked, residual} - List back = new ArrayList<>(); // {destPacked, residual} - for (Map.Entry> e : originsByType.entrySet()) { - Set origins = e.getValue(); - Set dests = destsByType.getOrDefault(e.getKey(), Collections.emptySet()); - if (origins.isEmpty() || dests.isEmpty()) { - continue; - } - - int residualFwd = Integer.MAX_VALUE; - for (int d : dests) { - residualFwd = Math.min(residualFwd, baseHeuristicToNearestTarget(d)); - } - if (residualFwd < startToGoal) { - for (int o : origins) { - fwd.add(new int[]{o, residualFwd}); - } - } - - int residualBack = Integer.MAX_VALUE; - for (int o : origins) { - residualBack = Math.min(residualBack, baseHeuristicFromStart(o)); - } - if (residualBack < startToGoal) { - for (int d : dests) { - back.add(new int[]{d, residualBack}); - } - } - } - - fwdLandmark = packLandmarkPositions(fwd); - fwdLandmarkResidual = packLandmarkResiduals(fwd); - backLandmark = packLandmarkPositions(back); - backLandmarkResidual = packLandmarkResiduals(back); - } - - private static int[] packLandmarkPositions(List landmarks) { - int[] out = new int[landmarks.size()]; - for (int i = 0; i < out.length; i++) { - out[i] = landmarks.get(i)[0]; - } - return out; - } - - private static int[] packLandmarkResiduals(List landmarks) { - int[] out = new int[landmarks.size()]; - for (int i = 0; i < out.length; i++) { - out[i] = landmarks.get(i)[1]; - } - return out; - } - private int minChebyshevStartToAnyTarget() { int best = Integer.MAX_VALUE; for (int t : targetsPacked) { @@ -436,16 +337,19 @@ private List combineBidirectionalPath(Node forwardAtMeet, Node backw List head = forwardAtMeet.getPath(); List full = new ArrayList<>(head.size() + 64); full.addAll(head); - for (Node n = backwardAtMeet.previous; n != null; n = n.previous) { - full.add(WorldPointUtil.unpackWorldPoint(n.packedPosition)); + + List edges = PathEdge.fromBidirectionalChains(forwardAtMeet, backwardAtMeet); + for (Node from = backwardAtMeet; from != null && from.previous != null; from = from.previous) { + Node to = from.previous; + full.add(WorldPointUtil.unpackWorldPoint(to.packedPosition)); } + joinedPathEdges = edges; return full; } private void addNeighborsForwardWithMeet(Node node, Map forwardAt, Map backwardAt, long[] bestMeetingCost, Node[] meetF, Node[] meetB) { List nodes = map.getNeighbors(node, visited, config, targets); - boolean afterTransport = node instanceof TransportNode; for (Node neighbor : nodes) { if (config.avoidWilderness(node.packedPosition, neighbor.packedPosition, targetInWilderness)) { continue; @@ -456,7 +360,6 @@ private void addNeighborsForwardWithMeet(Node node, Map forwardAt pending.add(neighbor); ++stats.transportsChecked; } else { - neighbor.heuristic = afterTransport ? 0 : heuristicToNearestTarget(neighbor.packedPosition); boundary.add(neighbor); ++stats.nodesChecked; } @@ -472,7 +375,6 @@ private void addNeighborsBackwardWithMeet(Node node, VisitedTiles visitedB, Map< Set puzzleAllow, Map forwardAt, Map backwardAt, long[] bestMeetingCost, Node[] meetF, Node[] meetB) { List nodes = map.getReverseNeighbors(node, visitedB, config, puzzleAllow, incoming); - boolean afterTransport = node instanceof TransportNode; for (Node pred : nodes) { if (config.avoidWilderness(pred.packedPosition, node.packedPosition, targetInWilderness)) { continue; @@ -483,7 +385,6 @@ private void addNeighborsBackwardWithMeet(Node node, VisitedTiles visitedB, Map< pendingBackward.add(pred); ++stats.transportsChecked; } else { - pred.heuristic = afterTransport ? 0 : heuristicFromStart(pred.packedPosition); boundaryBackward.add(pred); ++stats.nodesChecked; } @@ -497,7 +398,6 @@ private void addNeighborsBackwardWithMeet(Node node, VisitedTiles visitedB, Map< private void runUnidirectional() { Node startNode = new Node(start, null); - startNode.heuristic = heuristicToNearestTarget(start); boundary.add(startNode); int bestDistance = Integer.MAX_VALUE; @@ -542,7 +442,6 @@ private void runUnidirectional() { for (int target : targetsPacked) { if (nodePos == target) { bestLastNode = node; - pathNeedsUpdate = true; reached = true; break; } @@ -550,7 +449,6 @@ private void runUnidirectional() { long heuristic = distance + (long) WorldPointUtil.distanceBetween(nodePos, target, 2); if (heuristic < bestHeuristic || (heuristic <= bestHeuristic && distance < bestDistance)) { bestLastNode = node; - pathNeedsUpdate = true; bestDistance = distance; bestHeuristic = heuristic; cutoffTimeMillis = System.currentTimeMillis() + cutoffDurationMillis; @@ -585,6 +483,11 @@ private void runUnidirectional() { WebWalkLog.pf("uni_loop_exit cancelled={} bEmpty={} pEmpty={} bestLast={}", cancelled, boundary.isEmpty(), pending.isEmpty(), bestLastNode == null ? "null" : WorldPointUtil.toString(bestLastNode.packedPosition)); + + terminationReason = cancelled ? PathTerminationReason.CANCELLED + : reachedGoal ? PathTerminationReason.TARGET_REACHED + : timedOut ? PathTerminationReason.CUTOFF_REACHED + : PathTerminationReason.SEARCH_EXHAUSTED; } private void runBidirectional() { @@ -606,12 +509,10 @@ private void runBidirectional() { Node[] meetB = new Node[1]; Node startNode = new Node(start, null); - startNode.heuristic = heuristicToNearestTarget(start); boundary.add(startNode); forwardAt.put(start, startNode); Node goalNode = new Node(goalPacked, null); - goalNode.heuristic = heuristicFromStart(goalPacked); boundaryBackward.add(goalNode); backwardAt.put(goalPacked, goalNode); @@ -620,6 +521,7 @@ private void runBidirectional() { long cutoffDurationMillis = config.getCalculationCutoffMillis(); long cutoffTimeMillis = System.currentTimeMillis() + cutoffDurationMillis; config.refreshTeleports(start, 31); + boolean timedOut = false; while (!cancelled && (!boundary.isEmpty() || !pending.isEmpty() || !boundaryBackward.isEmpty() || !pendingBackward.isEmpty())) { if (!boundary.isEmpty() || !pending.isEmpty()) { @@ -653,8 +555,9 @@ private void runBidirectional() { final int nodePos = node.packedPosition; if (nodePos == goalPacked) { + joinedPathEdges = PathEdge.fromForwardChain(node); joinedPath = node.getPath(); - pathNeedsUpdate = false; + selectedPathCost = node.cost; bestLastNode = null; WebWalkLog.pf("bidir forward_hit_goal"); break; @@ -665,7 +568,6 @@ private void runBidirectional() { long heuristic = distance + (long) WorldPointUtil.distanceBetween(nodePos, target, 2); if (heuristic < bestHeuristic || (heuristic <= bestHeuristic && distance < bestDistance)) { bestLastNode = node; - pathNeedsUpdate = true; bestDistance = distance; bestHeuristic = heuristic; cutoffTimeMillis = System.currentTimeMillis() + cutoffDurationMillis; @@ -691,7 +593,7 @@ private void runBidirectional() { if (node.packedPosition == start) { joinedPath = combineBidirectionalPath(forwardAt.get(start), node); - pathNeedsUpdate = false; + selectedPathCost = node.cost; bestLastNode = null; WebWalkLog.pf("bidir backward_hit_start"); break; @@ -701,6 +603,7 @@ private void runBidirectional() { } if (System.currentTimeMillis() > cutoffTimeMillis) { + timedOut = true; WebWalkLog.pf("bidir_cutoff nodes={}", stats.getNodesChecked()); break; } @@ -708,7 +611,7 @@ private void runBidirectional() { if (joinedPath == null && meetF[0] != null && meetB[0] != null && bestMeetingCost[0] < Long.MAX_VALUE) { joinedPath = combineBidirectionalPath(meetF[0], meetB[0]); - pathNeedsUpdate = false; + selectedPathCost = bestMeetingCost[0]; bestLastNode = null; WebWalkLog.pf("bidir meet_at={} cost={}", WorldPointUtil.toString(meetF[0].packedPosition), bestMeetingCost[0]); @@ -726,24 +629,35 @@ private void runBidirectional() { WebWalkLog.pf("bidir_exit joined={} meetCost={}", joinedPath == null ? "null" : Integer.toString(joinedPath.size()), bestMeetingCost[0] == Long.MAX_VALUE ? "n/a" : Long.toString(bestMeetingCost[0])); + + terminationReason = cancelled ? PathTerminationReason.CANCELLED + : joinedPath != null ? PathTerminationReason.TARGET_REACHED + : timedOut ? PathTerminationReason.CUTOFF_REACHED + : PathTerminationReason.SEARCH_EXHAUSTED; } @Override public void run() { WebWalkLog.pf("run_start src={} dst={} cutoffMs={}", WorldPointUtil.toString(start), WorldPointUtil.toString(targets), config.getCalculationCutoffMillis()); + path = Collections.emptyList(); + smoothedPath = Collections.emptyList(); + materializedPathLastNode = null; + smoothed = false; joinedPath = null; - // Pathfinder instances are commonly constructed on the client thread and submitted to the - // shortest-path executor. Resolve both ThreadLocal-backed objects here so the collision map, - // visited state and pinned live snapshot all belong to the search thread for this run. - map = config.getMap(); - visited = new VisitedTiles(map); - // Pin the live-collision snapshot for this whole search so a mid-search swap on the client - // thread cannot mix two scenes into one path. No-op when live collision is disabled. - map.beginSearch(); + joinedPathEdges = null; + terminationReason = null; + selectedPathCost = -1L; try { + // Pathfinder instances are commonly constructed on the client thread and submitted to the + // shortest-path executor. Resolve both ThreadLocal-backed objects here so the collision map, + // visited state and pinned live snapshot all belong to the search thread for this run. + map = config.getMap(); + visited = new VisitedTiles(map); + // Pin the live-collision snapshot for this whole search so a mid-search swap on the client + // thread cannot mix two scenes into one path. No-op when live collision is disabled. + map.beginSearch(); stats.start(); - computeNetworkLandmarks(); int minCheb = minChebyshevStartToAnyTarget(); boolean useBidir = targetsPacked.length == 1 && minCheb >= BIDIRECTIONAL_MIN_CHEBYSHEV; @@ -761,26 +675,40 @@ public void run() { runUnidirectional(); } } catch (Exception e) { + terminationReason = PathTerminationReason.FAILED; log.error("[Pathfinder] Exception in run(): ", e); } finally { + if (terminationReason == null) { + terminationReason = cancelled + ? PathTerminationReason.CANCELLED + : PathTerminationReason.SEARCH_EXHAUSTED; + } + if (selectedPathCost < 0 && bestLastNode != null) { + selectedPathCost = bestLastNode.cost; + } done = !cancelled; boundary.clear(); pending.clear(); boundaryBackward.clear(); pendingBackward.clear(); - visited.clear(); + if (visited != null) { + visited.clear(); + } - stats.end(); + stats.end(map == null ? 0L : map.getLiveEdgeQueries()); - WebWalkLog.pf("run_done done={} cancelled={} stats={}", - done, cancelled, getStats() != null ? getStats().toString() : "null"); + WebWalkLog.pf("run_done done={} cancelled={} termination={} stats={}", + done, cancelled, terminationReason, + getStats() != null ? getStats().toString() : "null"); } } public static class PathfinderStats { @Getter private int nodesChecked = 0, transportsChecked = 0; + @Getter + private long liveCollisionEdgesChecked = 0L; private long startNanos, endNanos; private volatile boolean started = false, ended = false; @@ -799,14 +727,31 @@ private void start() { startNanos = System.nanoTime(); } - private void end() { + private void complete( + long elapsedNanos, + int nodesChecked, + int transportsChecked, + long liveCollisionEdgesChecked) { + this.started = true; + this.nodesChecked = nodesChecked; + this.transportsChecked = transportsChecked; + this.liveCollisionEdgesChecked = liveCollisionEdgesChecked; + this.startNanos = 0L; + this.endNanos = elapsedNanos; + this.ended = true; + } + + private void end(long liveCollisionEdgesChecked) { + this.liveCollisionEdgesChecked = liveCollisionEdgesChecked; endNanos = System.nanoTime(); ended = true; } @Override public String toString() { - return String.format("PathfinderStats(nodes=%d,transports=%d,time=%dms)", nodesChecked, transportsChecked, getElapsedTimeNanos() / 1_000_000); + return String.format("PathfinderStats(nodes=%d,transports=%d,liveEdges=%d,time=%dms)", + nodesChecked, transportsChecked, liveCollisionEdgesChecked, + getElapsedTimeNanos() / 1_000_000); } } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java index 80510af1637..d02209eaa6d 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java @@ -18,6 +18,7 @@ import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; import net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment; import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; +import net.runelite.client.plugins.microbot.util.inventory.Rs2ItemModel; import net.runelite.client.plugins.microbot.util.magic.Rs2Magic; import net.runelite.client.plugins.microbot.util.magic.Rs2Spells; import net.runelite.client.plugins.microbot.util.magic.RuneFilter; @@ -93,6 +94,12 @@ public class PathfinderConfig { private final Map> allTransports; @Setter private volatile Set usableTeleports; + + /** Immutable exact-object snapshot for planner adapters after transport admission has run. */ + public Set getUsableTeleportsSnapshot() { + Set current = usableTeleports; + return current == null ? Collections.emptySet() : Set.copyOf(current); + } private final List filteredTargets = new CopyOnWriteArrayList<>(); @Getter @@ -128,6 +135,7 @@ public class PathfinderConfig { private final Client client; private final ShortestPathConfig config; + private final TransportPlanningPolicy transportPlanningPolicy; private final List questStateOrder = Arrays.asList( QuestState.NOT_STARTED, @@ -159,6 +167,8 @@ public class PathfinderConfig { private volatile boolean avoidWilderness; @Getter private volatile boolean avoidDangerousNpcs; + @Getter + private volatile PlannerSelectionMode plannerSelectionMode = PlannerSelectionMode.LOCAL; @Getter private volatile boolean useSpiritTrees; private volatile boolean useAgilityShortcuts, @@ -210,7 +220,8 @@ public class PathfinderConfig { // Used to include bank items when searching for item requirements private volatile boolean useBankItems = false; - private Set refreshAvailableItemIds; + private Map refreshAvailableItemQuantities; + private Map refreshAvailableRuneQuantities; private int[] refreshBoostedLevels; private Map refreshCurrencyCache; // Varplayer values snapshot for the current refreshTransports pass. Without it, every varp @@ -259,8 +270,17 @@ protected boolean removeEldestEntry(Map.Entry public PathfinderConfig(SplitFlagMap mapData, Map> transports, List restrictions, Client client, ShortestPathConfig config) { + this(mapData, transports, restrictions, client, config, TransportPlanningPolicy.ALLOW_ALL); + } + + public PathfinderConfig(SplitFlagMap mapData, Map> transports, + List restrictions, + Client client, ShortestPathConfig config, + TransportPlanningPolicy transportPlanningPolicy) { this.mapData = mapData; - this.map = ThreadLocal.withInitial(() -> new CollisionMap(this.mapData, this.liveCollisionOverlay)); + this.map = ThreadLocal.withInitial(() -> client == null + ? new CollisionMap(this.mapData, this.liveCollisionOverlay, () -> -1) + : new CollisionMap(this.mapData, this.liveCollisionOverlay)); this.allTransports = Collections.synchronizedMap(new HashMap<>()); replaceAllTransports(transports); this.usableTeleports = ConcurrentHashMap.newKeySet(allTransports.size() / 20); @@ -272,6 +292,8 @@ public PathfinderConfig(SplitFlagMap mapData, Map> tr loadLearnedBlockedEdges(); this.client = client; this.config = config; + this.transportPlanningPolicy = Objects.requireNonNull( + transportPlanningPolicy, "transportPlanningPolicy"); //START microbot variables this.resourceRestrictions = restrictions; this.customRestrictions = Collections.emptyList(); @@ -337,6 +359,8 @@ public void refresh(WorldPoint target) { calculationCutoffMillis = (long) config.calculationCutoff() * Constants.GAME_TICK_LENGTH; avoidWilderness = ShortestPathPlugin.override("avoidWilderness", config.avoidWilderness()); avoidDangerousNpcs = ShortestPathPlugin.override("avoidDangerousNpcs", config.avoidDangerousNpcs()); + plannerSelectionMode = ShortestPathPlugin.override( + "plannerSelectionMode", config.plannerSelectionMode()); useAgilityShortcuts = ShortestPathPlugin.override("useAgilityShortcuts", config.useAgilityShortcuts()); useGrappleShortcuts = ShortestPathPlugin.override("useGrappleShortcuts", config.useGrappleShortcuts()); useBoats = ShortestPathPlugin.override("useBoats", config.useBoats()); @@ -467,15 +491,15 @@ private void refreshTransports(WorldPoint target) { TransportRefreshSnapshot snap = transportRefreshSnapshots.get(refreshCacheKeyHash); if (snap != null && client != null) { - int[] boostedProbe = new int[SKILLS.length]; + int[] boostedProbe = new int[Transport.REQUIREMENT_LEVEL_COUNT]; final int[] probeOrdinals = snap.sortedSkillOrdinals; Microbot.getClientThread().runOnClientThreadOptional(() -> { // Only the skills some transport gates on; probing all 23 both cost client-thread // time and let hitpoints/prayer drift invalidate an otherwise valid cache. if (probeOrdinals != null) { for (int ordinal : probeOrdinals) { - if (ordinal >= 0 && ordinal < SKILLS.length) { - boostedProbe[ordinal] = client.getBoostedSkillLevel(SKILLS[ordinal]); + if (ordinal >= 0 && ordinal < Transport.REQUIREMENT_LEVEL_COUNT) { + boostedProbe[ordinal] = currentRequirementLevel(ordinal); } } } @@ -540,13 +564,20 @@ private void refreshTransports(WorldPoint target) { long mergeTime = System.currentTimeMillis() - mergeStart; long cacheStart = System.currentTimeMillis(); - refreshAvailableItemIds = new HashSet<>(); + refreshAvailableItemQuantities = new HashMap<>(); refreshCurrencyCache = new HashMap<>(); - Rs2Inventory.items().forEach(item -> refreshAvailableItemIds.add(item.getId())); - Rs2Equipment.all().forEach(item -> refreshAvailableItemIds.add(item.getId())); + Rs2Inventory.items().forEach(item -> refreshAvailableItemQuantities.merge( + item.getId(), Math.max(0, item.getQuantity()), Integer::sum)); + Rs2Equipment.all().forEach(item -> refreshAvailableItemQuantities.merge( + item.getId(), Math.max(0, item.getQuantity()), Integer::sum)); if (useBankItems) { - Rs2Bank.getAll().forEach(item -> refreshAvailableItemIds.add(item.getId())); + Rs2Bank.getAll().forEach(item -> refreshAvailableItemQuantities.merge( + item.getId(), Math.max(0, item.getQuantity()), Integer::sum)); } + refreshAvailableRuneQuantities = new HashMap<>(); + Rs2Magic.getRunes(RuneFilter.builder().includeBank(useBankItems).build()) + .forEach((rune, quantity) -> refreshAvailableRuneQuantities.put( + rune.getItemId(), quantity)); Set varbitIds = new HashSet<>(); List varbitConditions = new ArrayList<>(); @@ -622,12 +653,17 @@ private void refreshTransports(WorldPoint target) { ? Collections.unmodifiableSet(relevantItemIds) : null; - refreshBoostedLevels = new int[SKILLS.length]; + refreshBoostedLevels = new int[Transport.REQUIREMENT_LEVEL_COUNT]; Map varplayerValues = new HashMap<>(); Microbot.getClientThread().runOnClientThreadOptional(() -> { for (int i = 0; i < SKILLS.length; i++) { refreshBoostedLevels[i] = client.getBoostedSkillLevel(SKILLS[i]); } + refreshBoostedLevels[Transport.TOTAL_LEVEL_INDEX] = client.getTotalLevel(); + Player localPlayer = client.getLocalPlayer(); + refreshBoostedLevels[Transport.COMBAT_LEVEL_INDEX] = + localPlayer == null ? 0 : localPlayer.getCombatLevel(); + refreshBoostedLevels[Transport.QUEST_POINTS_INDEX] = client.getVarpValue(VarPlayer.QUEST_POINTS); for (int id : varbitIds) { Microbot.getVarbitValue(id); } @@ -693,7 +729,13 @@ private void refreshTransports(WorldPoint target) { } } - Rs2LeaguesTransport.injectLeaguesTransports(this, leaguesCtx, usableTeleports, transports, transportsPacked, typeStats); + Rs2LeaguesTransport.injectLeaguesTransports( + transport -> isTransportUsableWithLeaguesContext(transport, leaguesCtx), + leaguesCtx, + usableTeleports, + transports, + transportsPacked, + typeStats); long filterTime = System.currentTimeMillis() - filterStart; int[] sortedVarbitConditions = encodeSortedConditionTriples(varbitConditions); @@ -725,7 +767,8 @@ private void refreshTransports(WorldPoint target) { } long similarTime = System.currentTimeMillis() - similarStart; - refreshAvailableItemIds = null; + refreshAvailableItemQuantities = null; + refreshAvailableRuneQuantities = null; refreshBoostedLevels = null; refreshCurrencyCache = null; refreshVarplayerValues = null; @@ -1245,40 +1288,49 @@ private int getLiveVarplayerValue(int varplayerId) { } private boolean useTransport(Transport transport) { + // This runs once per expanded catalog edge during every refresh. Keep individual rejection + // reasons at TRACE; DEBUG already receives the per-type aggregate emitted by refreshTransports. + if (!transportPlanningPolicy.isAdmitted(transport)) { + log.trace("Transport ( O: {} D: {} type={} ) has no registered Microbot executor", + transport == null ? null : transport.getOrigin(), + transport == null ? null : transport.getDestination(), + transport == null ? null : transport.getType()); + return false; + } // Check if the feature flag is disabled if (!isFeatureEnabled(transport)) { - log.debug("Transport Type {} is disabled by feature flag", transport.getType()); + log.trace("Transport Type {} is disabled by feature flag", transport.getType()); return false; } // If the transport requires you to be in a members world (used for more granular member requirements) if (transport.isMembers() && !client.getWorldType().contains(WorldType.MEMBERS)) { - log.debug("Transport ( O: {} D: {} ) requires members world", transport.getOrigin(), transport.getDestination()); + log.trace("Transport ( O: {} D: {} ) requires members world", transport.getOrigin(), transport.getDestination()); return false; } if (transport.getType() == TransportType.SPIRIT_TREE && !isSpiritTreeRouteEnabled(transport)) { - log.debug("Transport ( O: {} D: {} ) is a spirit tree route but the tree is disabled", transport.getOrigin(), transport.getDestination()); + log.trace("Transport ( O: {} D: {} ) is a spirit tree route but the tree is disabled", transport.getOrigin(), transport.getDestination()); return false; } // If you don't meet level requirements if (!hasRequiredLevels(transport)) { - log.debug("Transport ( O: {} D: {} ) requires skill levels {}", transport.getOrigin(), transport.getDestination(), Arrays.toString(transport.getSkillLevels())); + log.trace("Transport ( O: {} D: {} ) requires skill levels {}", transport.getOrigin(), transport.getDestination(), Arrays.toString(transport.getSkillLevels())); return false; } // If the transport has quest requirements & the quest haven't been completed if (transport.isQuestLocked() && !completedQuests(transport)) { - log.debug("Transport ( O: {} D: {} ) requires quests {}", transport.getOrigin(), transport.getDestination(), transport.getQuests()); + log.trace("Transport ( O: {} D: {} ) requires quests {}", transport.getOrigin(), transport.getDestination(), transport.getQuests()); return false; } // If the transport has varbit requirements & the varbits do not match if (!varbitChecks(transport)) { - log.debug("Transport ( O: {} D: {} ) requires varbits {}", transport.getOrigin(), transport.getDestination(), transport.getVarbits()); + log.trace("Transport ( O: {} D: {} ) requires varbits {}", transport.getOrigin(), transport.getDestination(), transport.getVarbits()); return false; } // If the transport has varplayer requirements & the varplayers do not match if (!varplayerChecks(transport)) { - log.debug("Transport ( O: {} D: {} ) requires varplayers {}", transport.getOrigin(), transport.getDestination(), transport.getVarplayers()); + log.trace("Transport ( O: {} D: {} ) requires varplayers {}", transport.getOrigin(), transport.getDestination(), transport.getVarplayers()); return false; } @@ -1291,19 +1343,19 @@ private boolean useTransport(Transport transport) { return new int[]{invCount, bankCount}; }); if (cached[0] < transport.getCurrencyAmount() && cached[1] < transport.getCurrencyAmount()) { - log.debug("Transport ( O: {} D: {} ) requires {} x {}", transport.getOrigin(), transport.getDestination(), transport.getCurrencyAmount(), transport.getCurrencyName()); + log.trace("Transport ( O: {} D: {} ) requires {} x {}", transport.getOrigin(), transport.getDestination(), transport.getCurrencyAmount(), transport.getCurrencyName()); return false; } } else if (!Rs2Inventory.hasItemAmount(transport.getCurrencyName(), transport.getCurrencyAmount()) && !(useBankItems && Rs2Bank.count(transport.getCurrencyName()) >= transport.getCurrencyAmount())) { - log.debug("Transport ( O: {} D: {} ) requires {} x {}", transport.getOrigin(), transport.getDestination(), transport.getCurrencyAmount(), transport.getCurrencyName()); + log.trace("Transport ( O: {} D: {} ) requires {} x {}", transport.getOrigin(), transport.getDestination(), transport.getCurrencyAmount(), transport.getCurrencyName()); return false; } } // Check if Teleports are globally disabled if (TransportType.isTeleport(transport.getType(), transport.getOrigin()) && Rs2Walker.disableTeleports) { - log.debug("Transport ( O: {} D: {} ) is a teleport but teleports are globally disabled", transport.getOrigin(), transport.getDestination()); + log.trace("Transport ( O: {} D: {} ) is a teleport but teleports are globally disabled", transport.getOrigin(), transport.getDestination()); return false; } @@ -1311,7 +1363,7 @@ private boolean useTransport(Transport transport) { if (transport.getType() == TELEPORTATION_ITEM) { boolean isUsable = isTeleportationItemUsable(transport); if (!isUsable) { - log.debug("Transport ( O: {} D: {} ) is a teleport item but is not usable", transport.getOrigin(), transport.getDestination()); + log.trace("Transport ( O: {} D: {} ) is a teleport item but is not usable", transport.getOrigin(), transport.getDestination()); } return isUsable; } @@ -1319,7 +1371,7 @@ private boolean useTransport(Transport transport) { if (transport.getType() == TELEPORTATION_SPELL) { boolean isUsable = isTeleportationSpellUsable(transport); if (!isUsable) { - log.debug("Transport ( O: {} D: {} ) is a teleport spell but is not usable", transport.getOrigin(), transport.getDestination()); + log.trace("Transport ( O: {} D: {} ) is a teleport spell but is not usable", transport.getOrigin(), transport.getDestination()); } return isUsable; } @@ -1328,7 +1380,7 @@ private boolean useTransport(Transport transport) { if (!transport.getItemIdRequirements().isEmpty()) { boolean hasRequiredItems = hasRequiredItems(transport); if (!hasRequiredItems) { - log.debug("Transport ( O: {} D: {} ) requires items {}", transport.getOrigin(), transport.getDestination(), transport.getItemIdRequirements().stream().flatMap(Set::stream).collect(Collectors.toSet())); + log.trace("Transport ( O: {} D: {} ) requires items {}", transport.getOrigin(), transport.getDestination(), transport.getItemIdRequirements().stream().flatMap(Set::stream).collect(Collectors.toSet())); } return hasRequiredItems; } @@ -1357,14 +1409,40 @@ public boolean isTransportUsableWithLeaguesContext(Transport transport, Rs2Leagu private boolean hasRequiredLevels(Transport transport) { int[] requiredLevels = transport.getSkillLevels(); if (refreshBoostedLevels != null) { - for (int i = 0; i < requiredLevels.length; i++) { - if (requiredLevels[i] > 0 && refreshBoostedLevels[i] < requiredLevels[i]) return false; - } - return true; + return meetsRequiredLevels(requiredLevels, refreshBoostedLevels); } return IntStream.range(0, requiredLevels.length) .filter(i -> requiredLevels[i] > 0) - .allMatch(i -> Microbot.getClient().getBoostedSkillLevel(SKILLS[i]) >= requiredLevels[i]); + .allMatch(i -> currentRequirementLevel(i) >= requiredLevels[i]); + } + + static boolean meetsRequiredLevels(int[] requiredLevels, int[] currentLevels) { + if (requiredLevels == null || currentLevels == null || currentLevels.length < requiredLevels.length) { + return false; + } + for (int i = 0; i < requiredLevels.length; i++) { + if (requiredLevels[i] > 0 && currentLevels[i] < requiredLevels[i]) { + return false; + } + } + return true; + } + + private int currentRequirementLevel(int index) { + if (index >= 0 && index < SKILLS.length) { + return client.getBoostedSkillLevel(SKILLS[index]); + } + if (index == Transport.TOTAL_LEVEL_INDEX) { + return client.getTotalLevel(); + } + if (index == Transport.COMBAT_LEVEL_INDEX) { + Player localPlayer = client.getLocalPlayer(); + return localPlayer == null ? 0 : localPlayer.getCombatLevel(); + } + if (index == Transport.QUEST_POINTS_INDEX) { + return client.getVarpValue(VarPlayer.QUEST_POINTS); + } + return 0; } /** @@ -1447,6 +1525,29 @@ private boolean isFeatureEnabled(Transport transport) { } } + return isTransportTypeEnabled(type); + } + + /** Immutable feature-toggle snapshot for planner-independent request policy. */ + public Set getEnabledTransportTypes() { + EnumSet enabled = EnumSet.noneOf(TransportType.class); + for (TransportType type : TransportType.values()) { + if (isTransportTypeEnabled(type)) { + enabled.add(type); + } + } + return Collections.unmodifiableSet(enabled); + } + + public TeleportationItem getTeleportationItemPolicy() { + return useTeleportationItems == null ? TeleportationItem.NONE : useTeleportationItems; + } + + public boolean isMembersWorld() { + return client == null || client.getWorldType().contains(WorldType.MEMBERS); + } + + private boolean isTransportTypeEnabled(TransportType type) { switch (type) { case AGILITY_SHORTCUT: return useAgilityShortcuts; @@ -1503,30 +1604,70 @@ private boolean isFeatureEnabled(Transport transport) { * Checks if a teleportation item is usable */ private boolean isTeleportationItemUsable(Transport transport) { - if (useTeleportationItems == TeleportationItem.NONE) return false; - // Check consumable items configuration - if (useTeleportationItems == TeleportationItem.INVENTORY_NON_CONSUMABLE && transport.isConsumable()) + if (!isTeleportationItemAllowedByPolicy(useTeleportationItems, transport.isConsumable())) { return false; + } return hasRequiredItems(transport); } + static boolean isTeleportationItemAllowedByPolicy( + TeleportationItem policy, + boolean consumable) { + return policy != TeleportationItem.NONE + && (policy != TeleportationItem.INVENTORY_NON_CONSUMABLE || !consumable); + } + /** * Checks if the player has any of the required equipment and inventory items for the transport */ private boolean hasRequiredItems(Transport transport) { - if (requiresChronicle(transport)) return hasChronicleCharges(); + return TransportItemRequirement.selectProviders( + transport.getItemRequirements(), + this::availableRequirementItemQuantity, + itemId -> availableItemQuantity(itemId) > 0, + itemId -> availableItemQuantity(itemId) > 0).isPresent(); + } - if (refreshAvailableItemIds != null) { - return transport.getItemIdRequirements() - .stream() - .flatMap(Collection::stream) - .anyMatch(refreshAvailableItemIds::contains); + static boolean meetsItemRequirements( + List requirements, + java.util.function.IntUnaryOperator availableQuantity) { + if (requirements == null || requirements.isEmpty()) { + return true; } - return transport.getItemIdRequirements() - .stream() - .flatMap(Collection::stream) - .anyMatch(itemId -> Rs2Equipment.isWearing(itemId) || Rs2Inventory.hasItem(itemId) || (ShortestPathPlugin.getPathfinderConfig().useBankItems && Rs2Bank.hasItem(itemId))); + return requirements.stream().allMatch(requirement -> requirement.isSatisfiedBy(availableQuantity)); + } + + private int availableItemQuantity(int itemId) { + if (itemId == ItemID.CHRONICLE && !hasChronicleCharges()) { + return 0; + } + if (refreshAvailableItemQuantities != null) { + return refreshAvailableItemQuantities.getOrDefault(itemId, 0); + } + int quantity = Rs2Inventory.itemQuantity(itemId); + Rs2ItemModel equipped = Rs2Equipment.get(itemId); + if (equipped != null) { + quantity += Math.max(1, equipped.getQuantity()); + } + if (useBankItems) { + quantity += Rs2Bank.count(itemId); + } + return quantity; + } + + private int availableRequirementItemQuantity(int itemId) { + Map runeSnapshot = refreshAvailableRuneQuantities; + if (runeSnapshot != null) { + return Math.max(availableItemQuantity(itemId), runeSnapshot.getOrDefault(itemId, 0)); + } + Runes rune = Runes.byItemId(itemId); + if (rune == null) { + return availableItemQuantity(itemId); + } + int runeQuantity = Rs2Magic.getRunes( + RuneFilter.builder().includeBank(useBankItems).build()).getOrDefault(rune, 0); + return Math.max(availableItemQuantity(itemId), runeQuantity); } /** @@ -1540,7 +1681,16 @@ private boolean hasRequiredItems(Restriction restriction) { } - private boolean isTeleportationSpellUsable(Transport transport) { + boolean isTeleportationSpellUsable(Transport transport) { + if (transportPlanningPolicy.isZeroRuneSpell(transport)) { + // Every spellbook home teleport is a zero-rune widget action. Spellbook, membership, + // quest, Wilderness and cooldown requirements were checked earlier in useTransport(). + return true; + } + + if (!transport.getItemRequirements().isEmpty()) { + return hasRequiredItems(transport); + } boolean hasMultipleDestination = transport.getDisplayInfo().contains(":"); String displayInfo = hasMultipleDestination @@ -1552,16 +1702,6 @@ private boolean isTeleportationSpellUsable(Transport transport) { // return Rs2Magic.quickCanCast(displayInfo); } - /** - * Checks if the transport requires the Chronicle - */ - private boolean requiresChronicle(Transport transport) { - return transport.getItemIdRequirements() - .stream() - .flatMap(Collection::stream) - .anyMatch(itemId -> itemId == ItemID.CHRONICLE); - } - /** * Checks if the Chronicle has charges */ diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportNode.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportNode.java index 28b048e6c0b..4a2c0791858 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportNode.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportNode.java @@ -1,9 +1,12 @@ package net.runelite.client.plugins.microbot.shortestpath.pathfinder; import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.shortestpath.Transport; public class TransportNode extends Node implements Comparable { - public TransportNode(WorldPoint point, Node previous, int travelTime) { + private final Transport transport; + + public TransportNode(WorldPoint point, Node previous, int travelTime, Transport transport) { // Use Node(int, Node, int cost) which assigns cost directly. The WorldPoint // Node constructor re-adds previous.cost via its cost(previous, wait) method, // which caused (a) double-counting when we passed prev.cost + travelTime as @@ -12,6 +15,11 @@ public TransportNode(WorldPoint point, Node previous, int travelTime) { super(net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil.packWorldPoint(point), previous, (previous != null ? previous.cost : 0) + travelTime); + this.transport = transport; + } + + public Transport getTransport() { + return transport; } @Override diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportPlanningPolicy.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportPlanningPolicy.java new file mode 100644 index 00000000000..0edf4ba9863 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportPlanningPolicy.java @@ -0,0 +1,24 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder; + +import net.runelite.client.plugins.microbot.shortestpath.Transport; + +/** + * Engine-side admission seam for an already parsed transport catalog. + * + *

The pathfinder owns graph search, not knowledge of which interactions Microbot can execute. + * Production therefore supplies a Microbot-owned policy, while headless planner tests may admit an + * explicitly constructed catalog without depending on runtime executor classes.

+ */ +public interface TransportPlanningPolicy +{ + TransportPlanningPolicy ALLOW_ALL = transport -> true; + + /** Whether this catalog row may enter the planner graph. */ + boolean isAdmitted(Transport transport); + + /** Whether a spell row is a registered zero-rune widget action. */ + default boolean isZeroRuneSpell(Transport transport) + { + return false; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/TestRunnerPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/TestRunnerPlugin.java index a598cf8cd58..50ad781823f 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/TestRunnerPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/TestRunnerPlugin.java @@ -1,8 +1,11 @@ package net.runelite.client.plugins.microbot.testing; import lombok.extern.slf4j.Slf4j; +import net.runelite.api.Client; import net.runelite.api.GameState; -import net.runelite.api.events.GameStateChanged; +import net.runelite.api.events.GameTick; +import net.runelite.api.gameval.InterfaceID; +import net.runelite.api.widgets.Widget; import net.runelite.client.eventbus.Subscribe; import net.runelite.client.plugins.Plugin; import net.runelite.client.plugins.PluginDescriptor; @@ -19,7 +22,8 @@ @PluginDescriptor( name = "Test Runner", hidden = true, - alwaysOn = true + alwaysOn = true, + priority = true ) @Slf4j public class TestRunnerPlugin extends Plugin { @@ -31,6 +35,9 @@ public class TestRunnerPlugin extends Plugin { @Inject private PluginManager pluginManager; + @Inject + private Client client; + private ScheduledExecutorService executor; private boolean testStarted = false; @@ -41,6 +48,7 @@ protected void startUp() { log.info("[TestRunner] Test mode active. Target plugin: {}", getTargetPluginName()); executor = Executors.newSingleThreadScheduledExecutor(); + prepareTargetPlugin(); enableAutoLogin(); long timeout = getTimeout(); @@ -53,6 +61,24 @@ protected void startUp() { }, timeout, TimeUnit.MILLISECONDS); } + private void prepareTargetPlugin() { + Plugin target = findTargetPlugin(); + if (target == null) { + return; + } + + try { + if (pluginManager.isActive(target)) { + pluginManager.stopPlugin(target); + } + // Test targets are process-scoped. Leaving this persisted as enabled makes them start + // before the runner can observe a playable client on the next JVM launch. + pluginManager.setPluginEnabled(target, false); + } catch (PluginInstantiationException e) { + throw new IllegalStateException("Unable to prepare target plugin " + getTargetPluginName(), e); + } + } + private void enableAutoLogin() { for (Plugin plugin : pluginManager.getPlugins()) { if (plugin instanceof AutoLoginPlugin) { @@ -90,49 +116,59 @@ protected void shutDown() { } @Subscribe - public void onGameStateChanged(GameStateChanged event) { + public void onGameTick(GameTick event) { if (!isTestMode()) return; if (testStarted) return; - if (event.getGameState() != GameState.LOGGED_IN) return; + if (!isClientReady()) return; testStarted = true; - log.info("[TestRunner] Logged in. Starting target plugin in 2s..."); + log.info("[TestRunner] Client is playable. Starting target plugin..."); - executor.schedule(this::enableTargetPlugin, 2, TimeUnit.SECONDS); + executor.execute(this::enableTargetPlugin); + } + + private boolean isClientReady() { + Widget playWidget = client.getWidget(InterfaceID.WelcomeScreen.PLAY); + boolean welcomeScreenVisible = playWidget != null && !playWidget.isHidden(); + return isClientReady(client.getGameState(), client.getLocalPlayer() != null, welcomeScreenVisible); + } + + static boolean isClientReady(GameState gameState, boolean localPlayerAvailable, boolean welcomeScreenVisible) { + return gameState == GameState.LOGGED_IN && localPlayerAvailable && !welcomeScreenVisible; } private void enableTargetPlugin() { String targetName = getTargetPluginName(); log.info("[TestRunner] Looking for plugin: '{}'", targetName); - for (Plugin plugin : pluginManager.getPlugins()) { + Plugin plugin = findTargetPlugin(); + if (plugin != null) { PluginDescriptor descriptor = plugin.getClass().getAnnotation(PluginDescriptor.class); - if (descriptor == null) continue; - - if (descriptor.name().contains(targetName)) { - log.info("[TestRunner] Found plugin: {} ({})", descriptor.name(), plugin.getClass().getSimpleName()); - pluginManager.setPluginEnabled(plugin, true); - - SwingUtilities.invokeLater(() -> { - try { - pluginManager.startPlugin(plugin); - log.info("[TestRunner] Started plugin: {}", descriptor.name()); - } catch (PluginInstantiationException e) { - log.error("[TestRunner] Failed to start plugin", e); - TestResult result = new TestResult(targetName); - result.addError("Failed to start plugin: " + e.getMessage()); - result.complete("crash"); - TestResultWriter.write(result); - System.exit(3); - } - }); - return; - } + log.info("[TestRunner] Found plugin: {} ({})", descriptor.name(), plugin.getClass().getSimpleName()); + pluginManager.setPluginEnabled(plugin, true); + + SwingUtilities.invokeLater(() -> { + try { + boolean started = pluginManager.startPlugin(plugin); + log.info("[TestRunner] {} plugin: {}", started ? "Started" : "Target already active", descriptor.name()); + } catch (PluginInstantiationException e) { + log.error("[TestRunner] Failed to start plugin", e); + TestResult result = new TestResult(targetName); + result.addError("Failed to start plugin: " + e.getMessage()); + result.complete("crash"); + TestResultWriter.write(result); + System.exit(3); + } finally { + // Keep the target active for this process without auto-starting it on the next run. + pluginManager.setPluginEnabled(plugin, false); + } + }); + return; } log.error("[TestRunner] Plugin '{}' not found! Available plugins:", targetName); - for (Plugin plugin : pluginManager.getPlugins()) { - PluginDescriptor d = plugin.getClass().getAnnotation(PluginDescriptor.class); + for (Plugin availablePlugin : pluginManager.getPlugins()) { + PluginDescriptor d = availablePlugin.getClass().getAnnotation(PluginDescriptor.class); if (d != null) log.error(" - {}", d.name()); } @@ -143,6 +179,21 @@ private void enableTargetPlugin() { System.exit(3); } + private Plugin findTargetPlugin() { + String targetName = getTargetPluginName(); + if (targetName.isBlank()) { + return null; + } + + for (Plugin plugin : pluginManager.getPlugins()) { + PluginDescriptor descriptor = plugin.getClass().getAnnotation(PluginDescriptor.class); + if (descriptor != null && descriptor.name().contains(targetName)) { + return plugin; + } + } + return null; + } + private static boolean isTestMode() { return "true".equals(System.getProperty(PROP_TEST_MODE)); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/webwalker/F2PWebWalkerHarnessPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/webwalker/F2PWebWalkerHarnessPlugin.java index e2c48d229ac..e5ba96d7fa3 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/webwalker/F2PWebWalkerHarnessPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/webwalker/F2PWebWalkerHarnessPlugin.java @@ -13,8 +13,15 @@ import net.runelite.client.plugins.Plugin; import net.runelite.client.plugins.PluginDescriptor; import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.shortestpath.PlannerSelectionMode; +import net.runelite.client.plugins.microbot.agentserver.handler.WalkerShadowHandler; import net.runelite.client.plugins.microbot.testing.TestResult; import net.runelite.client.plugins.microbot.testing.TestResultWriter; +import net.runelite.client.plugins.microbot.util.walker.Rs2PathApi; +import net.runelite.client.plugins.microbot.util.walker.Rs2PlannerShadowContext; +import net.runelite.client.plugins.microbot.util.walker.Rs2PlannerShadowCoverageStats; +import net.runelite.client.plugins.microbot.util.walker.Rs2PlannerShadowStats; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportExecutor; import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; import net.runelite.client.plugins.microbot.util.walker.WalkerState; @@ -48,9 +55,17 @@ public class F2PWebWalkerHarnessPlugin extends Plugin { private static final String TEST_WALK_TIMEOUT_PROPERTY = "microbot.test.webwalker.walkTimeoutMs"; private static final String USE_TELEPORTATION_SPELLS_PROPERTY = "microbot.webwalker.useTeleportationSpells"; private static final String TEST_USE_TELEPORTATION_SPELLS_PROPERTY = "microbot.test.webwalker.useTeleportationSpells"; + private static final String UPSTREAM_PLANNER_SHADOW_PROPERTY = "microbot.webwalker.upstreamPlannerShadow"; + private static final String TEST_UPSTREAM_PLANNER_SHADOW_PROPERTY = "microbot.test.webwalker.upstreamPlannerShadow"; + private static final String PLANNER_MODE_PROPERTY = "microbot.webwalker.plannerMode"; + private static final String TEST_PLANNER_MODE_PROPERTY = "microbot.test.webwalker.plannerMode"; + private static final String EXPECT_LOCAL_FALLBACK_PROPERTY = "microbot.webwalker.expectLocalFallback"; + private static final String TEST_EXPECT_LOCAL_FALLBACK_PROPERTY = "microbot.test.webwalker.expectLocalFallback"; private static final String TEST_SCRIPT_PROPERTY = "microbot.test.script"; private static final String SCRIPT_NAME = "F2P Web Walker Harness"; private static final int DEFAULT_WALK_TIMEOUT_MS = 240000; + private static final int SHADOW_SETTLE_TIMEOUT_MS = 120000; + private static final int RECOVERY_REPLAN_ACK_TIMEOUT_MS = 30000; @Inject private EventBus eventBus; @@ -94,6 +109,12 @@ private void runHarness() { result.routeFilter = property(TEST_ROUTE_FILTER_PROPERTY, ROUTE_FILTER_PROPERTY, "all"); result.stopOnFailure = Boolean.parseBoolean(property(TEST_STOP_ON_FAILURE_PROPERTY, STOP_ON_FAILURE_PROPERTY, "true")); result.walkTimeoutMs = intProperty(TEST_WALK_TIMEOUT_PROPERTY, WALK_TIMEOUT_PROPERTY, DEFAULT_WALK_TIMEOUT_MS); + boolean legacyShadow = Boolean.parseBoolean(property( + TEST_UPSTREAM_PLANNER_SHADOW_PROPERTY, UPSTREAM_PLANNER_SHADOW_PROPERTY, "false")); + result.plannerMode = plannerModeProperty(legacyShadow).name(); + result.upstreamPlannerShadow = plannerMode(result.plannerMode).comparisonEnabled(); + result.expectLocalFallback = Boolean.parseBoolean(property( + TEST_EXPECT_LOCAL_FALLBACK_PROPERTY, EXPECT_LOCAL_FALLBACK_PROPERTY, "false")); int exitCode = 0; try { @@ -115,14 +136,58 @@ private void runHarness() { break; } - applyShortestPathOverrides(route); + applyShortestPathOverrides(route, result.plannerMode); + long expectedExecutorBefore = shadowExecutorCompleted(route.expectedShadowExecutor); + long activeReplansBefore = shadowCoverageCompleted( + Rs2PlannerShadowContext.Coverage.ACTIVE_REPLAN); + long recoveryReplansBefore = shadowCoverageCompleted( + Rs2PlannerShadowContext.Coverage.RECOVERY_REPLAN); + long recoveryArrivalsBefore = shadowRecoveryArrivals(); + long bankRouteFromBankBefore = shadowCoverageCompleted( + Rs2PlannerShadowContext.Coverage.BANK_ROUTE_FROM_BANK); + long bankRouteItemGatedBefore = shadowCoverageCompleted( + Rs2PlannerShadowContext.Coverage.BANK_ROUTE_FROM_BANK_SELECTS_ITEM_GATED_TRANSPORT); + runBankRouteComparisons(route); RouteOutcome outcome = runRoute(route, result.walkTimeoutMs); result.routes.add(outcome); result.addCheck(route.id + " setup", outcome.setupPassed, outcome.setupError); result.addCheck(route.id + " route", outcome.passed, outcome.error); - if (!outcome.passed) { + boolean executorEvidencePassed = verifyExpectedShadowExecutor( + route, outcome, expectedExecutorBefore, result.upstreamPlannerShadow); + if (route.expectedShadowExecutor != null) { + result.addCheck(route.id + " shadow executor", executorEvidencePassed, + outcome.shadowExecutorError); + } + + boolean activeReplanEvidencePassed = verifyExpectedActiveReplans( + route, outcome, activeReplansBefore, result.upstreamPlannerShadow); + if (route.minimumExpectedActiveReplanComparisons > 0) { + result.addCheck(route.id + " active replans", activeReplanEvidencePassed, + outcome.activeReplanError); + } + + boolean recoveryEvidencePassed = verifyExpectedRecoveryReplans( + route, outcome, recoveryReplansBefore, recoveryArrivalsBefore, + result.upstreamPlannerShadow); + if (route.minimumExpectedRecoveryReplanComparisons > 0 + || route.minimumExpectedRecoveryArrivals > 0) { + result.addCheck(route.id + " recovery replans", recoveryEvidencePassed, + outcome.recoveryReplanError); + } + + boolean bankRouteEvidencePassed = verifyExpectedBankRoutes( + route, outcome, bankRouteFromBankBefore, bankRouteItemGatedBefore, + result.upstreamPlannerShadow); + if (route.minimumExpectedBankRouteFromBankComparisons > 0 + || route.minimumExpectedBankRouteFromBankItemGatedComparisons > 0) { + result.addCheck(route.id + " bank route comparisons", bankRouteEvidencePassed, + outcome.bankRouteComparisonError); + } + + if (!outcome.passed || !executorEvidencePassed || !activeReplanEvidencePassed + || !recoveryEvidencePassed || !bankRouteEvidencePassed) { exitCode = 1; if (result.stopOnFailure) { log.warn("[F2PWebWalkerHarness] Stopping on first failed route: {}", route.id); @@ -131,6 +196,10 @@ private void runHarness() { } } + if (!captureShadowEvidence(result)) { + exitCode = 1; + } + result.complete("completed"); writeAndExit(result, exitCode == 0 ? result.exitCode : exitCode); } catch (Throwable t) { @@ -151,8 +220,26 @@ private RouteOutcome runRoute(F2PWebWalkerRoute route, int walkTimeoutMs) { outcome.repetitions = route.repetitions; outcome.currentLocationStart = route.currentLocationStart; outcome.requireF2PWorld = route.requireF2PWorld; + outcome.requireMembersWorld = route.requireMembersWorld; outcome.forceNoAgilityShortcuts = route.forceNoAgilityShortcuts; outcome.forceNoTeleports = route.forceNoTeleports; + outcome.forceCanoes = route.forceCanoes; + outcome.forceShips = route.forceShips; + outcome.expectedShadowExecutor = route.expectedShadowExecutor == null + ? null : route.expectedShadowExecutor.name(); + outcome.minimumExpectedShadowExecutions = route.minimumExpectedShadowExecutions; + outcome.forcedActiveReplans = route.forcedActiveReplans; + outcome.minimumExpectedActiveReplanComparisons = route.minimumExpectedActiveReplanComparisons; + outcome.forcedRecoveryReplans = route.forcedRecoveryReplans; + outcome.forcedSetupRecoveryReplans = route.forcedSetupRecoveryReplans; + outcome.minimumExpectedRecoveryReplanComparisons = + route.minimumExpectedRecoveryReplanComparisons; + outcome.minimumExpectedRecoveryArrivals = route.minimumExpectedRecoveryArrivals; + outcome.forcedBankRouteComparisons = route.forcedBankRouteComparisons; + outcome.minimumExpectedBankRouteFromBankComparisons = + route.minimumExpectedBankRouteFromBankComparisons; + outcome.minimumExpectedBankRouteFromBankItemGatedComparisons = + route.minimumExpectedBankRouteFromBankItemGatedComparisons; outcome.startedAt = Instant.now().toString(); outcome.setupPassed = true; @@ -175,6 +262,14 @@ private RouteOutcome runRoute(F2PWebWalkerRoute route, int walkTimeoutMs) { outcome.finishedAt = Instant.now().toString(); return outcome; } + if (route.requireMembersWorld && !membersWorld) { + outcome.setupPassed = false; + outcome.setupError = "Route " + route.id + + " requires a members world, but the client is on a free world"; + outcome.error = outcome.setupError; + outcome.finishedAt = Instant.now().toString(); + return outcome; + } WorldPoint start = route.currentLocationStart ? current : route.start; outcome.start = format(start); @@ -234,7 +329,8 @@ private RouteAttemptOutcome runAttempt(F2PWebWalkerRoute route, WorldPoint start attempt.initialDistanceToStart = distance(current, start); if (attempt.initialDistanceToStart > route.startTolerance) { long setupStart = System.currentTimeMillis(); - attempt.setupState = walk(start, route.startTolerance, walkTimeoutMs); + attempt.setupState = walk(start, route.startTolerance, walkTimeoutMs, 0, + route.forcedSetupRecoveryReplans); attempt.setupDurationMs = System.currentTimeMillis() - setupStart; } else { attempt.setupState = WalkerState.ARRIVED.name(); @@ -257,7 +353,8 @@ private RouteAttemptOutcome runAttempt(F2PWebWalkerRoute route, WorldPoint start } long walkStart = System.currentTimeMillis(); - attempt.walkerState = walk(route.destination, route.destinationTolerance, walkTimeoutMs); + attempt.walkerState = walk(route.destination, route.destinationTolerance, walkTimeoutMs, + route.forcedActiveReplans, route.forcedRecoveryReplans); attempt.walkDurationMs = System.currentTimeMillis() - walkStart; WorldPoint end = safeLocation(); @@ -276,11 +373,33 @@ private RouteAttemptOutcome runAttempt(F2PWebWalkerRoute route, WorldPoint start return attempt; } - private String walk(WorldPoint destination, int tolerance, int timeoutMs) { + private String walk( + WorldPoint destination, + int tolerance, + int timeoutMs, + int forcedActiveReplans, + int forcedRecoveryReplans + ) { ExecutorService walkExecutor = Executors.newSingleThreadExecutor(new ThreadFactoryBuilder() .setNameFormat("F2PWebWalkerLeg-%d") .build()); Future future = walkExecutor.submit(() -> Rs2Walker.walkWithState(destination, tolerance)); + ExecutorService activeReplanExecutor = null; + if (forcedActiveReplans > 0) { + activeReplanExecutor = Executors.newSingleThreadExecutor(new ThreadFactoryBuilder() + .setNameFormat("F2PWebWalkerReplan-%d") + .build()); + activeReplanExecutor.submit(() -> forceActiveReplans( + future, destination, tolerance, forcedActiveReplans)); + } + ExecutorService recoveryReplanExecutor = null; + if (forcedRecoveryReplans > 0) { + recoveryReplanExecutor = Executors.newSingleThreadExecutor(new ThreadFactoryBuilder() + .setNameFormat("F2PWebWalkerRecovery-%d") + .build()); + recoveryReplanExecutor.submit(() -> forceRecoveryReplans( + future, destination, tolerance, forcedRecoveryReplans)); + } try { return future.get(timeoutMs, TimeUnit.MILLISECONDS).name(); @@ -292,17 +411,312 @@ private String walk(WorldPoint destination, int tolerance, int timeoutMs) { log.warn("[F2PWebWalkerHarness] Webwalker leg failed for destination {}", destination, e); return "ERROR:" + e.getClass().getSimpleName(); } finally { + if (activeReplanExecutor != null) { + activeReplanExecutor.shutdownNow(); + } + if (recoveryReplanExecutor != null) { + recoveryReplanExecutor.shutdownNow(); + } walkExecutor.shutdownNow(); } } + private void forceActiveReplans( + Future walkFuture, + WorldPoint destination, + int tolerance, + int count + ) { + WorldPoint lastReplanLocation = null; + for (int index = 1; index <= count && !Thread.currentThread().isInterrupted(); index++) { + WorldPoint previous = lastReplanLocation; + boolean ready = sleepUntil(() -> { + WorldPoint current = safeLocation(); + return !walkFuture.isDone() + && current != null + && current.distanceTo2D(destination) > tolerance + 10 + && Rs2PathApi.getActiveRouteStatus().isReady() + && (previous == null || current.distanceTo2D(previous) >= 4); + }, 30000); + if (!ready || walkFuture.isDone()) { + log.warn("[F2PWebWalkerHarness] Unable to trigger active replan {}/{} before route ended", + index, count); + return; + } + + long generation = Rs2PathApi.getActiveRouteStatus().getGeneration(); + lastReplanLocation = safeLocation(); + Rs2Walker.recalculatePath(); + log.info("[F2PWebWalkerHarness] Triggered active replan {}/{} at generation {}", + index, count, generation); + sleepUntil(() -> walkFuture.isDone() + || Rs2PathApi.getActiveRouteStatus().getGeneration() > generation, 10000); + } + } + + private void forceRecoveryReplans( + Future walkFuture, + WorldPoint destination, + int tolerance, + int count + ) { + WorldPoint lastReplanLocation = safeLocation(); + for (int index = 1; index <= count && !Thread.currentThread().isInterrupted(); index++) { + WorldPoint previous = lastReplanLocation; + boolean ready = sleepUntil(() -> { + WorldPoint current = safeLocation(); + return !walkFuture.isDone() + && current != null + && current.distanceTo2D(destination) > tolerance + 10 + && Rs2PathApi.getActiveRouteStatus().isReady() + && (previous == null || current.distanceTo2D(previous) >= 4); + }, 30000); + if (!ready || walkFuture.isDone()) { + log.warn("[F2PWebWalkerHarness] Unable to trigger recovery replan {}/{} before route ended", + index, count); + return; + } + + long generation = Rs2PathApi.getActiveRouteStatus().getGeneration(); + lastReplanLocation = safeLocation(); + if (!Rs2Walker.requestRecoveryReplanForTest()) { + log.warn("[F2PWebWalkerHarness] Recovery replan {}/{} was rejected at generation {}", + index, count, generation); + return; + } + log.info("[F2PWebWalkerHarness] Queued recovery replan {}/{} at generation {}", + index, count, generation); + boolean consumed = sleepUntil(() -> walkFuture.isDone() + || Rs2PathApi.getActiveRouteStatus().getGeneration() > generation, + RECOVERY_REPLAN_ACK_TIMEOUT_MS); + if (!consumed || (walkFuture.isDone() + && Rs2PathApi.getActiveRouteStatus().getGeneration() <= generation)) { + log.warn("[F2PWebWalkerHarness] Recovery replan {}/{} was not acknowledged", + index, count); + return; + } + // Measure the next progress interval from the point where this replan was consumed, + // not from where it was queued while the previous route was still moving. + lastReplanLocation = safeLocation(); + } + } + private WorldPoint safeLocation() { return lastLocation; } - private void applyShortestPathOverrides(F2PWebWalkerRoute route) { + private boolean captureShadowEvidence(WebWalkerTestResult result) { + if (!result.upstreamPlannerShadow) { + return true; + } + + result.shadowSettled = sleepUntil(() -> Rs2PathApi.getShadowStats().getPending() == 0, + SHADOW_SETTLE_TIMEOUT_MS); + Rs2PlannerShadowStats stats = Rs2PathApi.getShadowStats(); + result.shadowEvidence = WalkerShadowHandler.snapshot(); + + boolean expectedFallbackObserved = !result.expectLocalFallback + || (stats.getFailures() > 0 + && stats.getLocalFallbackFailures() > 0 + && stats.getUpstreamCanarySelections() == 0); + boolean unexpectedFailure = !result.expectLocalFallback && stats.getFailures() != 0; + boolean canarySelectionObserved = plannerMode(result.plannerMode).f2pCanaryEnabled() + && !result.expectLocalFallback + ? stats.getUpstreamCanarySelections() > 0 + : true; + boolean passed = result.shadowSettled + && stats.getSubmitted() > 0 + && stats.getCompleted() > 0 + && stats.getPending() == 0 + && stats.getDivergences() == 0 + && !unexpectedFailure + && expectedFallbackObserved + && canarySelectionObserved; + if (!passed) { + result.shadowError = "Planner shadow evidence failed: settled=" + result.shadowSettled + + ", submitted=" + stats.getSubmitted() + + ", completed=" + stats.getCompleted() + + ", pending=" + stats.getPending() + + ", divergences=" + stats.getDivergences() + + ", failures=" + stats.getFailures() + + ", upstreamCanarySelections=" + stats.getUpstreamCanarySelections() + + ", localFallbackFailures=" + stats.getLocalFallbackFailures() + + ", expectLocalFallback=" + result.expectLocalFallback; + } + result.addCheck("planner comparison and selection evidence", passed, result.shadowError); + return passed; + } + + private boolean verifyExpectedShadowExecutor( + F2PWebWalkerRoute route, + RouteOutcome outcome, + long before, + boolean upstreamPlannerShadow + ) { + if (route.expectedShadowExecutor == null) { + return true; + } + outcome.shadowExecutorSettled = upstreamPlannerShadow + && sleepUntil(() -> Rs2PathApi.getShadowStats().getPending() == 0, + SHADOW_SETTLE_TIMEOUT_MS); + long after = shadowExecutorCompleted(route.expectedShadowExecutor); + outcome.observedShadowExecutions = Math.max(0L, after - before); + boolean passed = upstreamPlannerShadow + && outcome.shadowExecutorSettled + && outcome.observedShadowExecutions >= route.minimumExpectedShadowExecutions; + if (!passed) { + outcome.shadowExecutorError = "Expected at least " + route.minimumExpectedShadowExecutions + + " completed " + route.expectedShadowExecutor + " shadow comparison(s), observed " + + outcome.observedShadowExecutions + "; shadowEnabled=" + upstreamPlannerShadow + + ", settled=" + outcome.shadowExecutorSettled; + } + return passed; + } + + private static long shadowExecutorCompleted(Rs2TransportExecutor executor) { + if (executor == null) { + return 0L; + } + return Rs2PathApi.getShadowStats().getTransportExecutors().get(executor).getCompleted(); + } + + private boolean verifyExpectedActiveReplans( + F2PWebWalkerRoute route, + RouteOutcome outcome, + long before, + boolean upstreamPlannerShadow + ) { + if (route.minimumExpectedActiveReplanComparisons == 0) { + return true; + } + outcome.activeReplansSettled = upstreamPlannerShadow + && sleepUntil(() -> Rs2PathApi.getShadowStats().getPending() == 0, + SHADOW_SETTLE_TIMEOUT_MS); + long after = shadowCoverageCompleted(Rs2PlannerShadowContext.Coverage.ACTIVE_REPLAN); + outcome.observedActiveReplanComparisons = Math.max(0L, after - before); + boolean passed = upstreamPlannerShadow + && outcome.activeReplansSettled + && outcome.observedActiveReplanComparisons + >= route.minimumExpectedActiveReplanComparisons; + if (!passed) { + outcome.activeReplanError = "Expected at least " + + route.minimumExpectedActiveReplanComparisons + + " completed ACTIVE_REPLAN shadow comparison(s), observed " + + outcome.observedActiveReplanComparisons + + "; shadowEnabled=" + upstreamPlannerShadow + + ", settled=" + outcome.activeReplansSettled; + } + return passed; + } + + private static long shadowCoverageCompleted(Rs2PlannerShadowContext.Coverage coverage) { + Rs2PlannerShadowCoverageStats stats = Rs2PathApi.getShadowStats().getCoverage().get(coverage); + return stats == null ? 0L : stats.getCompleted(); + } + + private boolean verifyExpectedRecoveryReplans( + F2PWebWalkerRoute route, + RouteOutcome outcome, + long replansBefore, + long arrivalsBefore, + boolean upstreamPlannerShadow + ) { + if (route.minimumExpectedRecoveryReplanComparisons == 0 + && route.minimumExpectedRecoveryArrivals == 0) { + return true; + } + outcome.recoveryReplansSettled = upstreamPlannerShadow + && sleepUntil(() -> Rs2PathApi.getShadowStats().getPending() == 0, + SHADOW_SETTLE_TIMEOUT_MS); + outcome.observedRecoveryReplanComparisons = Math.max(0L, + shadowCoverageCompleted(Rs2PlannerShadowContext.Coverage.RECOVERY_REPLAN) + - replansBefore); + outcome.observedRecoveryArrivals = Math.max(0L, + shadowRecoveryArrivals() - arrivalsBefore); + boolean passed = upstreamPlannerShadow + && outcome.recoveryReplansSettled + && outcome.observedRecoveryReplanComparisons + >= route.minimumExpectedRecoveryReplanComparisons + && outcome.observedRecoveryArrivals >= route.minimumExpectedRecoveryArrivals; + if (!passed) { + outcome.recoveryReplanError = "Expected at least " + + route.minimumExpectedRecoveryReplanComparisons + + " completed RECOVERY_REPLAN comparison(s) and " + + route.minimumExpectedRecoveryArrivals + + " recovered arrival(s), observed " + + outcome.observedRecoveryReplanComparisons + " and " + + outcome.observedRecoveryArrivals + + "; shadowEnabled=" + upstreamPlannerShadow + + ", settled=" + outcome.recoveryReplansSettled; + } + return passed; + } + + private static long shadowRecoveryArrivals() { + return Rs2PathApi.getShadowStats().getExecution().getRecoveryArrived(); + } + + private void runBankRouteComparisons(F2PWebWalkerRoute route) { + for (int index = 1; index <= route.forcedBankRouteComparisons; index++) { + Rs2Walker.compareRoutes(route.start, route.destination); + boolean settled = sleepUntil(() -> Rs2PathApi.getShadowStats().getPending() == 0, + SHADOW_SETTLE_TIMEOUT_MS); + if (!settled) { + log.warn("[F2PWebWalkerHarness] Bank route comparison {}/{} did not settle", + index, route.forcedBankRouteComparisons); + return; + } + log.info("[F2PWebWalkerHarness] Completed bank route comparison {}/{} for {}", + index, route.forcedBankRouteComparisons, route.id); + } + } + + private boolean verifyExpectedBankRoutes( + F2PWebWalkerRoute route, + RouteOutcome outcome, + long beforeFromBank, + long beforeItemGated, + boolean upstreamPlannerShadow + ) { + if (route.minimumExpectedBankRouteFromBankComparisons == 0 + && route.minimumExpectedBankRouteFromBankItemGatedComparisons == 0) { + return true; + } + outcome.bankRouteComparisonsSettled = upstreamPlannerShadow + && sleepUntil(() -> Rs2PathApi.getShadowStats().getPending() == 0, + SHADOW_SETTLE_TIMEOUT_MS); + outcome.observedBankRouteFromBankComparisons = Math.max(0L, + shadowCoverageCompleted(Rs2PlannerShadowContext.Coverage.BANK_ROUTE_FROM_BANK) + - beforeFromBank); + outcome.observedBankRouteFromBankItemGatedComparisons = Math.max(0L, + shadowCoverageCompleted( + Rs2PlannerShadowContext.Coverage.BANK_ROUTE_FROM_BANK_SELECTS_ITEM_GATED_TRANSPORT) + - beforeItemGated); + boolean passed = upstreamPlannerShadow + && outcome.bankRouteComparisonsSettled + && outcome.observedBankRouteFromBankComparisons + >= route.minimumExpectedBankRouteFromBankComparisons + && outcome.observedBankRouteFromBankItemGatedComparisons + >= route.minimumExpectedBankRouteFromBankItemGatedComparisons; + if (!passed) { + outcome.bankRouteComparisonError = "Expected at least " + + route.minimumExpectedBankRouteFromBankComparisons + + " BANK_ROUTE_FROM_BANK comparison(s) and " + + route.minimumExpectedBankRouteFromBankItemGatedComparisons + + " item-gated bank comparison(s), observed " + + outcome.observedBankRouteFromBankComparisons + " and " + + outcome.observedBankRouteFromBankItemGatedComparisons + + "; shadowEnabled=" + upstreamPlannerShadow + + ", settled=" + outcome.bankRouteComparisonsSettled; + } + return passed; + } + + private void applyShortestPathOverrides(F2PWebWalkerRoute route, String plannerMode) { Map config = new HashMap<>(); + config.put("plannerSelectionMode", plannerMode); + String value = property(TEST_USE_TELEPORTATION_SPELLS_PROPERTY, USE_TELEPORTATION_SPELLS_PROPERTY, ""); if (!value.isBlank()) { config.put("useTeleportationSpells", Boolean.parseBoolean(value)); @@ -322,6 +736,33 @@ private void applyShortestPathOverrides(F2PWebWalkerRoute route) { config.put("useWildernessObelisks", false); } + if (route.forceCanoes) { + config.put("useCanoes", true); + } + + if (route.forceShips) { + config.put("useShips", true); + } + + if (route.requireMembersWorld) { + config.put("useBoats", false); + config.put("useFairyRings", false); + config.put("useGnomeGliders", false); + config.put("useMagicCarpets", false); + config.put("useMagicMushtrees", false); + config.put("useQuetzals", false); + config.put("useSpiritTrees", false); + config.put("useWildernessObelisks", false); + if (route.expectedShadowExecutor == Rs2TransportExecutor.TERMINAL_TRAVEL) { + config.put("useBoats", true); + config.put("useShips", true); + } else if (route.expectedShadowExecutor == Rs2TransportExecutor.GNOME_GLIDER) { + config.put("useGnomeGliders", true); + } else if (route.expectedShadowExecutor == Rs2TransportExecutor.SPIRIT_TREE) { + config.put("useSpiritTrees", true); + } + } + if (config.isEmpty()) { return; } @@ -352,6 +793,17 @@ private static String property(String preferred, String legacy, String defaultVa return defaultValue; } + private static PlannerSelectionMode plannerModeProperty(boolean legacyShadow) { + String value = property(TEST_PLANNER_MODE_PROPERTY, PLANNER_MODE_PROPERTY, ""); + return value.isBlank() + ? (legacyShadow ? PlannerSelectionMode.SHADOW : PlannerSelectionMode.LOCAL) + : plannerMode(value); + } + + private static PlannerSelectionMode plannerMode(String value) { + return PlannerSelectionMode.fromConfigValue(value, PlannerSelectionMode.LOCAL); + } + private static int intProperty(String preferred, String legacy, int defaultValue) { String value = property(preferred, legacy, String.valueOf(defaultValue)); try { @@ -384,6 +836,12 @@ public static class WebWalkerTestResult extends TestResult { public String routeFilter; public boolean stopOnFailure; public int walkTimeoutMs; + public String plannerMode; + public boolean upstreamPlannerShadow; + public boolean expectLocalFallback; + public boolean shadowSettled; + public String shadowError; + public Map shadowEvidence; public List selectedRoutes = new ArrayList<>(); public List routes = new ArrayList<>(); @@ -403,9 +861,37 @@ public static class RouteOutcome { public int successfulAttempts; public boolean currentLocationStart; public boolean requireF2PWorld; + public boolean requireMembersWorld; public boolean membersWorld; public boolean forceNoAgilityShortcuts; public boolean forceNoTeleports; + public boolean forceCanoes; + public boolean forceShips; + public String expectedShadowExecutor; + public int minimumExpectedShadowExecutions; + public int forcedActiveReplans; + public int minimumExpectedActiveReplanComparisons; + public boolean activeReplansSettled; + public long observedActiveReplanComparisons; + public String activeReplanError; + public int forcedRecoveryReplans; + public int forcedSetupRecoveryReplans; + public int minimumExpectedRecoveryReplanComparisons; + public int minimumExpectedRecoveryArrivals; + public boolean recoveryReplansSettled; + public long observedRecoveryReplanComparisons; + public long observedRecoveryArrivals; + public String recoveryReplanError; + public int forcedBankRouteComparisons; + public int minimumExpectedBankRouteFromBankComparisons; + public int minimumExpectedBankRouteFromBankItemGatedComparisons; + public boolean bankRouteComparisonsSettled; + public long observedBankRouteFromBankComparisons; + public long observedBankRouteFromBankItemGatedComparisons; + public String bankRouteComparisonError; + public boolean shadowExecutorSettled; + public long observedShadowExecutions; + public String shadowExecutorError; public String startedAt; public String finishedAt; public String initialLocation; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/webwalker/F2PWebWalkerRoute.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/webwalker/F2PWebWalkerRoute.java index 82f00b72a1f..94a91f5c7f4 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/webwalker/F2PWebWalkerRoute.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/webwalker/F2PWebWalkerRoute.java @@ -1,14 +1,16 @@ package net.runelite.client.plugins.microbot.testing.webwalker; import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportExecutor; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Locale; import java.util.stream.Collectors; final class F2PWebWalkerRoute { - static final List ROUTES = List.of( + static final List REQUIRED_ROUTES = List.of( route("F2P-01", "Lumbridge courtyard to Lumbridge bank", point(3222, 3218, 0), point(3208, 3220, 2), 1, 1), route("F2P-02", "Lumbridge bank to cow pen", @@ -41,9 +43,43 @@ final class F2PWebWalkerRoute { point(3109, 3341, 0), point(3106, 3363, 0), 1, 1), route("F2P-16", "Draynor Manor interior to Draynor bank", point(3106, 3363, 0), point(3092, 3245, 0), 1, 2), - currentRoute("F2P-17", "Current location to Varrock Sewers", - point(3237, 9858, 0), 1, 1, 5, true, true) + repeatedRoute("F2P-17", "Varrock manhole to Varrock Sewers", + point(3236, 3458, 0), point(3237, 9858, 0), 1, 1, 5, true, true) ); + static final List SELECTION_GATE_ROUTES = List.of( + bankSelectionGateRoute("F2P-18", "Lumbridge to Champions' Guild by canoe", + point(3243, 3237, 0), point(3199, 3344, 0), 2, 2, 3, + true, false, Rs2TransportExecutor.CANOE, 5, 10), + selectionGateRoute("F2P-19", "Port Sarim to Musa Point by ship", + point(3029, 3217, 0), point(2956, 3146, 0), 1, 1, 2, + false, true, Rs2TransportExecutor.TERMINAL_TRAVEL, 3), + activeReplanSelectionGateRoute("F2P-20", "Port Sarim to Falador with active replans", + point(3029, 3217, 0), point(2946, 3368, 0), 2, 2, 12), + recoveryReplanSelectionGateRoute("F2P-21", "Port Sarim to Rimmington with recovery replans", + point(3029, 3217, 0), point(2957, 3214, 0), 2, 2, 3, 2) + ); + static final List MEMBERS_ROUTES = List.of( + membersNetworkRoute("P2P-01", "Al Kharid to Tempoross Cove by ferry", + point(3272, 3143, 0), point(3148, 2843, 0), 1, 1, 3, + Rs2TransportExecutor.TERMINAL_TRAVEL, 5), + membersNetworkRoute("P2P-02", "Al Kharid to Gnome Stronghold by glider", + point(3284, 3211, 0), point(2465, 3501, 3), 1, 1, 2, + Rs2TransportExecutor.GNOME_GLIDER, 3), + membersNetworkRoute("P2P-03", "Grand Exchange to Gnome Stronghold by spirit tree", + point(3185, 3508, 0), point(2461, 3444, 0), 1, 1, 2, + Rs2TransportExecutor.SPIRIT_TREE, 3), + membersNetworkRoute("P2P-04", "Ardougne to Brimhaven by members ship", + point(2673, 3275, 0), point(2775, 3233, 1), 1, 1, 3, + Rs2TransportExecutor.TERMINAL_TRAVEL, 5) + ); + static final List ROUTES; + + static { + List routes = new ArrayList<>(REQUIRED_ROUTES); + routes.addAll(SELECTION_GATE_ROUTES); + routes.addAll(MEMBERS_ROUTES); + ROUTES = List.copyOf(routes); + } final String id; final String name; @@ -54,8 +90,22 @@ final class F2PWebWalkerRoute { final int repetitions; final boolean currentLocationStart; final boolean requireF2PWorld; + final boolean requireMembersWorld; final boolean forceNoAgilityShortcuts; final boolean forceNoTeleports; + final boolean forceCanoes; + final boolean forceShips; + final Rs2TransportExecutor expectedShadowExecutor; + final int minimumExpectedShadowExecutions; + final int forcedActiveReplans; + final int minimumExpectedActiveReplanComparisons; + final int forcedRecoveryReplans; + final int forcedSetupRecoveryReplans; + final int minimumExpectedRecoveryReplanComparisons; + final int minimumExpectedRecoveryArrivals; + final int forcedBankRouteComparisons; + final int minimumExpectedBankRouteFromBankComparisons; + final int minimumExpectedBankRouteFromBankItemGatedComparisons; private F2PWebWalkerRoute( String id, @@ -67,8 +117,22 @@ private F2PWebWalkerRoute( int repetitions, boolean currentLocationStart, boolean requireF2PWorld, + boolean requireMembersWorld, boolean forceNoAgilityShortcuts, - boolean forceNoTeleports + boolean forceNoTeleports, + boolean forceCanoes, + boolean forceShips, + Rs2TransportExecutor expectedShadowExecutor, + int minimumExpectedShadowExecutions, + int forcedActiveReplans, + int minimumExpectedActiveReplanComparisons, + int forcedRecoveryReplans, + int forcedSetupRecoveryReplans, + int minimumExpectedRecoveryReplanComparisons, + int minimumExpectedRecoveryArrivals, + int forcedBankRouteComparisons, + int minimumExpectedBankRouteFromBankComparisons, + int minimumExpectedBankRouteFromBankItemGatedComparisons ) { this.id = id; this.name = name; @@ -79,13 +143,31 @@ private F2PWebWalkerRoute( this.repetitions = repetitions; this.currentLocationStart = currentLocationStart; this.requireF2PWorld = requireF2PWorld; + this.requireMembersWorld = requireMembersWorld; this.forceNoAgilityShortcuts = forceNoAgilityShortcuts; this.forceNoTeleports = forceNoTeleports; + this.forceCanoes = forceCanoes; + this.forceShips = forceShips; + this.expectedShadowExecutor = expectedShadowExecutor; + this.minimumExpectedShadowExecutions = minimumExpectedShadowExecutions; + this.forcedActiveReplans = forcedActiveReplans; + this.minimumExpectedActiveReplanComparisons = minimumExpectedActiveReplanComparisons; + this.forcedRecoveryReplans = forcedRecoveryReplans; + this.forcedSetupRecoveryReplans = forcedSetupRecoveryReplans; + this.minimumExpectedRecoveryReplanComparisons = minimumExpectedRecoveryReplanComparisons; + this.minimumExpectedRecoveryArrivals = minimumExpectedRecoveryArrivals; + this.forcedBankRouteComparisons = forcedBankRouteComparisons; + this.minimumExpectedBankRouteFromBankComparisons = minimumExpectedBankRouteFromBankComparisons; + this.minimumExpectedBankRouteFromBankItemGatedComparisons = + minimumExpectedBankRouteFromBankItemGatedComparisons; } static List selected(String routeFilter) { if (routeFilter == null || routeFilter.isBlank() || "all".equalsIgnoreCase(routeFilter)) { - return ROUTES; + return REQUIRED_ROUTES; + } + if ("members".equalsIgnoreCase(routeFilter)) { + return MEMBERS_ROUTES; } List requested = Arrays.stream(routeFilter.split(",")) @@ -114,12 +196,14 @@ private static F2PWebWalkerRoute route( int destinationTolerance ) { return new F2PWebWalkerRoute(id, name, start, destination, startTolerance, destinationTolerance, - 1, false, false, false, false); + 1, false, false, false, false, false, false, false, null, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0); } - private static F2PWebWalkerRoute currentRoute( + private static F2PWebWalkerRoute repeatedRoute( String id, String name, + WorldPoint start, WorldPoint destination, int startTolerance, int destinationTolerance, @@ -127,8 +211,94 @@ private static F2PWebWalkerRoute currentRoute( boolean forceNoAgilityShortcuts, boolean forceNoTeleports ) { - return new F2PWebWalkerRoute(id, name, null, destination, startTolerance, destinationTolerance, - repetitions, true, true, forceNoAgilityShortcuts, forceNoTeleports); + return new F2PWebWalkerRoute(id, name, start, destination, startTolerance, destinationTolerance, + repetitions, false, true, false, forceNoAgilityShortcuts, forceNoTeleports, false, false, null, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0); + } + + private static F2PWebWalkerRoute selectionGateRoute( + String id, + String name, + WorldPoint start, + WorldPoint destination, + int startTolerance, + int destinationTolerance, + int repetitions, + boolean forceCanoes, + boolean forceShips, + Rs2TransportExecutor expectedShadowExecutor, + int minimumExpectedShadowExecutions + ) { + return new F2PWebWalkerRoute(id, name, start, destination, startTolerance, destinationTolerance, + repetitions, false, false, false, true, true, forceCanoes, forceShips, expectedShadowExecutor, + minimumExpectedShadowExecutions, 0, 0, 0, 0, 0, 0, 0, 0, 0); + } + + private static F2PWebWalkerRoute bankSelectionGateRoute( + String id, + String name, + WorldPoint start, + WorldPoint destination, + int startTolerance, + int destinationTolerance, + int repetitions, + boolean forceCanoes, + boolean forceShips, + Rs2TransportExecutor expectedShadowExecutor, + int minimumExpectedShadowExecutions, + int bankRouteComparisons + ) { + return new F2PWebWalkerRoute(id, name, start, destination, startTolerance, destinationTolerance, + repetitions, false, false, false, true, true, forceCanoes, forceShips, expectedShadowExecutor, + minimumExpectedShadowExecutions, 0, 0, 0, 0, 0, 0, + bankRouteComparisons, bankRouteComparisons, bankRouteComparisons); + } + + private static F2PWebWalkerRoute activeReplanSelectionGateRoute( + String id, + String name, + WorldPoint start, + WorldPoint destination, + int startTolerance, + int destinationTolerance, + int forcedActiveReplans + ) { + return new F2PWebWalkerRoute(id, name, start, destination, startTolerance, destinationTolerance, + 1, false, false, false, true, true, false, true, null, 0, + forcedActiveReplans, forcedActiveReplans, 0, 0, 0, 0, 0, 0, 0); + } + + private static F2PWebWalkerRoute recoveryReplanSelectionGateRoute( + String id, + String name, + WorldPoint start, + WorldPoint destination, + int startTolerance, + int destinationTolerance, + int repetitions, + int forcedRecoveryReplans + ) { + int evidenceLegs = repetitions + Math.max(0, repetitions - 1); + return new F2PWebWalkerRoute(id, name, start, destination, startTolerance, destinationTolerance, + repetitions, false, true, false, true, true, false, false, null, 0, + 0, 0, forcedRecoveryReplans, forcedRecoveryReplans, + evidenceLegs * forcedRecoveryReplans, evidenceLegs, 0, 0, 0); + } + + private static F2PWebWalkerRoute membersNetworkRoute( + String id, + String name, + WorldPoint start, + WorldPoint destination, + int startTolerance, + int destinationTolerance, + int repetitions, + Rs2TransportExecutor expectedShadowExecutor, + int minimumExpectedShadowExecutions + ) { + return new F2PWebWalkerRoute(id, name, start, destination, startTolerance, destinationTolerance, + repetitions, false, false, true, true, true, false, false, expectedShadowExecutor, + minimumExpectedShadowExecutions, 0, 0, 0, 0, 0, 0, 0, 0, 0); } private static WorldPoint point(int x, int y, int plane) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/webwalker/GeLumbridgeTeleportHarnessPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/webwalker/GeLumbridgeTeleportHarnessPlugin.java index b3661476322..feb6bed0e59 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/webwalker/GeLumbridgeTeleportHarnessPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/webwalker/GeLumbridgeTeleportHarnessPlugin.java @@ -12,8 +12,11 @@ import net.runelite.client.plugins.Plugin; import net.runelite.client.plugins.PluginDescriptor; import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.agentserver.handler.WalkerShadowHandler; import net.runelite.client.plugins.microbot.testing.TestResult; import net.runelite.client.plugins.microbot.testing.TestResultWriter; +import net.runelite.client.plugins.microbot.util.walker.Rs2PathApi; +import net.runelite.client.plugins.microbot.util.walker.Rs2PlannerShadowStats; import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; import net.runelite.client.plugins.microbot.util.walker.WalkerState; @@ -42,8 +45,11 @@ public class GeLumbridgeTeleportHarnessPlugin extends Plugin { private static final String SCRIPT_NAME = "GE Lumbridge Teleport Harness"; private static final String ITERATIONS_PROPERTY = "microbot.test.geLumbridge.iterations"; private static final String WALK_TIMEOUT_PROPERTY = "microbot.test.geLumbridge.walkTimeoutMs"; + private static final String UPSTREAM_PLANNER_SHADOW_PROPERTY = + "microbot.test.geLumbridge.upstreamPlannerShadow"; private static final int DEFAULT_ITERATIONS = 10; private static final int DEFAULT_WALK_TIMEOUT_MS = 300000; + private static final int SHADOW_SETTLE_TIMEOUT_MS = 120000; private static final WorldPoint LUMBRIDGE_CASTLE = new WorldPoint(3222, 3218, 0); private static final WorldPoint GRAND_EXCHANGE = new WorldPoint(3164, 3486, 0); private static final WorldPoint VARROCK_TELEPORT = new WorldPoint(3213, 3424, 0); @@ -101,6 +107,8 @@ private void runHarness() { GeLumbridgeTeleportResult result = new GeLumbridgeTeleportResult(SCRIPT_NAME); result.iterations = intProperty(ITERATIONS_PROPERTY, DEFAULT_ITERATIONS); result.walkTimeoutMs = intProperty(WALK_TIMEOUT_PROPERTY, DEFAULT_WALK_TIMEOUT_MS); + result.upstreamPlannerShadow = Boolean.parseBoolean( + System.getProperty(UPSTREAM_PLANNER_SHADOW_PROPERTY, "false")); int exitCode = 0; try { @@ -111,7 +119,7 @@ private void runHarness() { return; } - applyTeleportSpellOverride(); + applyShortestPathOverrides(result.upstreamPlannerShadow); log.info("[GeLumbridgeTeleportHarness] Starting {} iteration(s)", result.iterations); LegOutcome setup = runLeg("setup", 0, "setup-to-lumbridge-castle", @@ -144,6 +152,10 @@ private void runHarness() { } } + if (!captureShadowEvidence(result)) { + exitCode = 1; + } + result.complete("completed"); writeAndExit(result, exitCode == 0 ? result.exitCode : exitCode); } catch (Throwable t) { @@ -263,15 +275,44 @@ private String walk(WorldPoint destination, int tolerance, int timeoutMs, LegOut } } - private void applyTeleportSpellOverride() { + private boolean captureShadowEvidence(GeLumbridgeTeleportResult result) { + if (!result.upstreamPlannerShadow) { + return true; + } + + result.shadowSettled = sleepUntil(() -> Rs2PathApi.getShadowStats().getPending() == 0, + SHADOW_SETTLE_TIMEOUT_MS); + Rs2PlannerShadowStats stats = Rs2PathApi.getShadowStats(); + result.shadowEvidence = WalkerShadowHandler.snapshot(); + + boolean passed = result.shadowSettled + && stats.getSubmitted() > 0 + && stats.getCompleted() > 0 + && stats.getPending() == 0 + && stats.getDivergences() == 0 + && stats.getFailures() == 0; + if (!passed) { + result.shadowError = "Planner shadow evidence failed: settled=" + result.shadowSettled + + ", submitted=" + stats.getSubmitted() + + ", completed=" + stats.getCompleted() + + ", pending=" + stats.getPending() + + ", divergences=" + stats.getDivergences() + + ", failures=" + stats.getFailures(); + } + result.addCheck("upstream planner shadow", passed, result.shadowError); + return passed; + } + + private void applyShortestPathOverrides(boolean upstreamPlannerShadow) { Map config = new HashMap<>(); config.put("useTeleportationSpells", true); + config.put("plannerSelectionMode", upstreamPlannerShadow ? "SHADOW" : "LOCAL"); Map data = new HashMap<>(); data.put("config", config); eventBus.post(new PluginMessage("shortestpath", "path", data)); - log.info("[GeLumbridgeTeleportHarness] Applied shortest path override: useTeleportationSpells=true"); + log.info("[GeLumbridgeTeleportHarness] Applied shortest path overrides: {}", config); } private WorldPoint safeLocation() { @@ -328,6 +369,10 @@ private static void writeAndExit(GeLumbridgeTeleportResult result, int exitCode) public static class GeLumbridgeTeleportResult extends TestResult { public int iterations; public int walkTimeoutMs; + public boolean upstreamPlannerShadow; + public boolean shadowSettled; + public String shadowError; + public Map shadowEvidence; public List legs = new ArrayList<>(); public GeLumbridgeTeleportResult(String script) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java index 6666d71245b..9fa193c6448 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java @@ -18,8 +18,6 @@ import net.runelite.client.plugins.loottracker.LootTrackerRecord; import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.api.player.models.Rs2PlayerModel; -import net.runelite.client.plugins.microbot.shortestpath.ShortestPathPlugin; -import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; import net.runelite.client.plugins.microbot.util.antiban.Rs2AntibanSettings; import net.runelite.client.plugins.microbot.util.bank.enums.BankLocation; import net.runelite.client.plugins.microbot.util.coords.Rs2WorldPoint; @@ -41,6 +39,9 @@ import net.runelite.client.config.ConfigProfile; import net.runelite.client.plugins.microbot.util.settings.Rs2Settings; import net.runelite.client.plugins.microbot.util.tile.Rs2Tile; +import net.runelite.client.plugins.microbot.util.walker.Rs2PathApi; +import net.runelite.client.plugins.microbot.util.walker.Rs2RouteRequest; +import net.runelite.client.plugins.microbot.util.walker.Rs2RouteResult; import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; @@ -2327,15 +2328,8 @@ private static AbstractMap.SimpleEntry, BankLocation> getPathAn .map(BankLocation::getWorldPoint) .collect(Collectors.toSet()); - if (ShortestPathPlugin.getPathfinderConfig().getTransports().isEmpty()) { - ShortestPathPlugin.getPathfinderConfig().refresh(); - } - - long originalStart = System.nanoTime(); - Pathfinder pf = new Pathfinder(ShortestPathPlugin.getPathfinderConfig(), worldPoint, targets); - pf.run(); - List path = pf.getPath(); - long originalTime = System.nanoTime() - originalStart; + Rs2RouteResult route = Rs2PathApi.plan(Rs2RouteRequest.toAny(worldPoint, targets)); + List path = route.getPath(); if (path.isEmpty()) { Microbot.log("Unable to find path to nearest bank"); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/depositbox/Rs2DepositBox.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/depositbox/Rs2DepositBox.java index f60a2cb99b1..9b97747d0eb 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/depositbox/Rs2DepositBox.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/depositbox/Rs2DepositBox.java @@ -7,8 +7,6 @@ import net.runelite.api.coords.WorldPoint; import net.runelite.api.widgets.Widget; import net.runelite.client.plugins.microbot.Microbot; -import net.runelite.client.plugins.microbot.shortestpath.ShortestPathPlugin; -import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; import net.runelite.client.plugins.microbot.util.gameobject.Rs2BankID; import net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject; import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; @@ -18,6 +16,9 @@ import net.runelite.client.plugins.microbot.util.menu.NewMenuEntry; import net.runelite.client.plugins.microbot.util.player.Rs2Player; import net.runelite.client.plugins.microbot.util.tile.Rs2Tile; +import net.runelite.client.plugins.microbot.util.walker.Rs2PathApi; +import net.runelite.client.plugins.microbot.util.walker.Rs2RouteRequest; +import net.runelite.client.plugins.microbot.util.walker.Rs2RouteResult; import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; @@ -492,14 +493,8 @@ public static DepositBoxLocation getNearestDepositBox(WorldPoint worldPoint, int .map(DepositBoxLocation::getWorldPoint) .collect(Collectors.toSet()); - if (ShortestPathPlugin.getPathfinderConfig().getTransports().isEmpty()) { - ShortestPathPlugin.getPathfinderConfig().refresh(); - } - - Pathfinder pf = new Pathfinder(ShortestPathPlugin.getPathfinderConfig(), worldPoint, targets); - pf.run(); - - List path = pf.getPath(); + Rs2RouteResult route = Rs2PathApi.plan(Rs2RouteRequest.toAny(worldPoint, targets)); + List path = route.getPath(); if (path.isEmpty()) { Microbot.log("Unable to find path to any deposit box"); return null; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportInjection.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportInjection.java index b259fccdade..874f0e01bf6 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportInjection.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportInjection.java @@ -4,7 +4,6 @@ import net.runelite.client.plugins.microbot.shortestpath.Transport; import net.runelite.client.plugins.microbot.shortestpath.TransportType; import net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil; -import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathfinderConfig; import net.runelite.client.plugins.microbot.util.walker.WebWalkLog; import net.runelite.client.plugins.microbot.shortestpath.PrimitiveIntHashMap; @@ -13,6 +12,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.function.Predicate; /** * Pathfinder injection for Leagues Area and catalog transports. @@ -26,14 +26,14 @@ private LeaguesTransportInjection() private static volatile EnumSet lastInjectedUnlockedForBlacklistPrune = null; static void injectLeaguesTransports( - PathfinderConfig pathfinderConfig, + Predicate transportUsable, Rs2LeaguesTransport.LeaguesContext ctx, Set usableTeleports, Map> transports, PrimitiveIntHashMap> transportsPacked, Map typeStats) { - if (pathfinderConfig == null || ctx == null || !ctx.isActive() || ctx.getUnlockedRegions().isEmpty() + if (transportUsable == null || ctx == null || !ctx.isActive() || ctx.getUnlockedRegions().isEmpty() || usableTeleports == null || transports == null || transportsPacked == null || typeStats == null) { return; @@ -57,8 +57,8 @@ static void injectLeaguesTransports( // Uses same unlock snapshot as inject below (tickLeaguesCalibration still rate-limits standalone probes). LeaguesTransportTeleport.calibrateMissingLandingsAsync(unlockedNow); - injectLeaguesAreaTeleports(pathfinderConfig, ctx, ctx.getUnlockedRegions(), usableTeleports, typeStats); - injectLeaguesCatalogTransports(pathfinderConfig, ctx, ctx.getUnlockedRegions(), usableTeleports, transports, transportsPacked, typeStats); + injectLeaguesAreaTeleports(transportUsable, ctx.getUnlockedRegions(), usableTeleports, typeStats); + injectLeaguesCatalogTransports(transportUsable, ctx.getUnlockedRegions(), usableTeleports, transports, transportsPacked, typeStats); } private static boolean mergeOriginlessTeleportByBestDuration(Set usableTeleports, Transport candidate) @@ -90,8 +90,7 @@ private static boolean mergeOriginlessTeleportByBestDuration(Set usab } private static void injectLeaguesAreaTeleports( - PathfinderConfig pathfinderConfig, - Rs2LeaguesTransport.LeaguesContext ctx, + Predicate transportUsable, EnumSet unlockedLeaguesRegions, Set usableTeleports, Map typeStats) @@ -115,7 +114,7 @@ private static void injectLeaguesAreaTeleports( true, 31, java.util.Collections.emptySet()); - if (!pathfinderConfig.isTransportUsableWithLeaguesContext(t, ctx)) + if (!transportUsable.test(t)) { continue; } @@ -136,8 +135,7 @@ private static void injectLeaguesAreaTeleports( } private static void injectLeaguesCatalogTransports( - PathfinderConfig pathfinderConfig, - Rs2LeaguesTransport.LeaguesContext ctx, + Predicate transportUsable, EnumSet unlockedLeaguesRegions, Set usableTeleports, Map> transports, @@ -156,7 +154,7 @@ private static void injectLeaguesCatalogTransports( continue; } - if (!pathfinderConfig.isTransportUsableWithLeaguesContext(t, ctx)) + if (!transportUsable.test(t)) { continue; } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2LeaguesTransport.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2LeaguesTransport.java index 09fdec97b98..00b935cfd7d 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2LeaguesTransport.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2LeaguesTransport.java @@ -2,18 +2,18 @@ import lombok.extern.slf4j.Slf4j; import net.runelite.api.coords.WorldPoint; -import net.runelite.client.plugins.microbot.shortestpath.ShortestPathPlugin; import net.runelite.client.plugins.microbot.shortestpath.Transport; import net.runelite.client.plugins.microbot.shortestpath.TransportType; -import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathfinderConfig; import net.runelite.client.plugins.microbot.shortestpath.PrimitiveIntHashMap; import net.runelite.client.plugins.microbot.util.player.Rs2Player; import net.runelite.client.plugins.microbot.util.text.Rs2TextSanitizer; +import net.runelite.client.plugins.microbot.util.walker.Rs2PathApi; import java.util.EnumSet; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.function.Predicate; import java.util.regex.Matcher; /** @@ -198,11 +198,7 @@ public static boolean isTransportAllowed(LeaguesContext ctx, Transport transport public static void invalidateContext() { - PathfinderConfig cfg = ShortestPathPlugin.pathfinderConfig; - if (cfg != null) - { - cfg.invalidateTransportRefreshCache(); - } + Rs2PathApi.invalidateTransportRefreshCache(); } public static boolean isDestinationBlacklisted(int packedWorldPoint) @@ -250,7 +246,7 @@ public static java.util.List loadCatalogTransports(EnumSet transportUsable, LeaguesContext ctx, Set usableTeleports, Map> transports, @@ -258,7 +254,7 @@ public static void injectLeaguesTransports( Map typeStats) { LeaguesTransportInjection.injectLeaguesTransports( - pathfinderConfig, ctx, usableTeleports, transports, transportsPacked, typeStats); + transportUsable, ctx, usableTeleports, transports, transportsPacked, typeStats); } public static LeaguesRegion parseRegionName(String regionNameRaw) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Magic.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Magic.java index c03ee07d937..5c3b5bb813a 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Magic.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Magic.java @@ -190,6 +190,25 @@ public static boolean quickCast(Spell spell) { return quickCast(spell.getMagicAction()); } + /** + * Click a zero-rune spellbook action by its exact displayed name. + * + *

Home teleports exist on every spellbook but only the standard-book variant is represented by + * {@link MagicAction}. The active spellbook and cooldown are planner requirements; at execution time + * the exact visible widget is the authoritative capability check.

+ */ + public static boolean quickCast(String spellName) { + if (spellName == null || spellName.trim().isEmpty()) return false; + + Microbot.status = "Casting " + spellName; + if (Rs2Tab.getCurrentTab() != InterfaceTab.MAGIC) { + Rs2Tab.switchToMagicTab(); + if (!sleepUntil(() -> Rs2Tab.getCurrentTab() == InterfaceTab.MAGIC)) return false; + } + + return Rs2Widget.clickWidget(spellName, Optional.of(218), 3, true); + } + public static boolean quickCast(MagicAction magicSpell) { Microbot.status = "Casting " + magicSpell.getName(); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Staff.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Staff.java index 64e84005308..338f836f1f6 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Staff.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Staff.java @@ -6,8 +6,10 @@ import java.util.Arrays; import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -40,7 +42,10 @@ public enum Rs2Staff { MYSTIC_MUD_STAFF(ItemID.MYSTIC_MUD_STAFF, List.of(Runes.WATER, Runes.EARTH)), MYSTIC_SMOKE_STAFF(ItemID.MYSTIC_SMOKE_BATTLESTAFF, List.of(Runes.AIR, Runes.FIRE)), MYSTIC_STEAM_STAFF(ItemID.MYSTIC_STEAM_BATTLESTAFF, List.of(Runes.WATER, Runes.FIRE)), - TWINFLAME_STAFF(ItemID.TWINFLAME_STAFF, List.of(Runes.FIRE, Runes.WATER)); + TWINFLAME_STAFF(ItemID.TWINFLAME_STAFF, List.of(Runes.FIRE, Runes.WATER)), + BRYOPHYTAS_STAFF(ItemID.NATURE_STAFF_CHARGED, List.of(Runes.NATURE)), + SHADOWFLAME_QUADRANT(ItemID.SHADOWFLAME_QUADRANT, + List.of(Runes.AIR, Runes.WATER, Runes.EARTH, Runes.FIRE)); private final int itemID; private final List runes; @@ -49,7 +54,22 @@ public enum Rs2Staff { .filter(s -> s != NONE) .collect(Collectors.toMap(Rs2Staff::getItemID, Function.identity())); - static Rs2Staff byItemId(int itemID) { + public boolean provides(Runes rune) { + if (rune == null) return false; + if (runes.contains(rune)) return true; + Runes[] baseRunes = rune.getBaseRunes(); + return baseRunes.length > 0 && runes.containsAll(Arrays.asList(baseRunes)); + } + + public static Set itemIdsProviding(Runes rune) { + LinkedHashSet itemIds = Arrays.stream(values()) + .filter(staff -> staff != NONE && staff.provides(rune)) + .map(Rs2Staff::getItemID) + .collect(Collectors.toCollection(LinkedHashSet::new)); + return Collections.unmodifiableSet(itemIds); + } + + public static Rs2Staff byItemId(int itemID) { return BY_ITEM_ID.getOrDefault(itemID, NONE); } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Tome.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Tome.java index 7a6fdadc740..51da5e6f34f 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Tome.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Tome.java @@ -6,10 +6,13 @@ import java.util.Arrays; import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; + @Getter @RequiredArgsConstructor public enum Rs2Tome { @@ -27,7 +30,22 @@ public enum Rs2Tome { .filter(t -> t != NONE) .collect(Collectors.toMap(Rs2Tome::getItemID, Function.identity())); - static Rs2Tome byItemId(int itemID) { + public boolean provides(Runes rune) { + if (rune == null) return false; + if (runes.contains(rune)) return true; + Runes[] baseRunes = rune.getBaseRunes(); + return baseRunes.length > 0 && runes.containsAll(Arrays.asList(baseRunes)); + } + + public static Set itemIdsProviding(Runes rune) { + LinkedHashSet itemIds = Arrays.stream(values()) + .filter(tome -> tome != NONE && tome.provides(rune)) + .map(Rs2Tome::getItemID) + .collect(Collectors.toCollection(LinkedHashSet::new)); + return Collections.unmodifiableSet(itemIds); + } + + public static Rs2Tome byItemId(int itemID) { return BY_ITEM_ID.getOrDefault(itemID, NONE); } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/npc/Rs2NpcManager.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/npc/Rs2NpcManager.java index a63d7687a8b..00be6896b20 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/npc/Rs2NpcManager.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/npc/Rs2NpcManager.java @@ -5,7 +5,6 @@ import com.google.gson.reflect.TypeToken; import net.runelite.api.coords.WorldPoint; import net.runelite.client.plugins.microbot.Microbot; -import net.runelite.client.plugins.microbot.shortestpath.ShortestPathPlugin; import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; import org.slf4j.event.Level; @@ -285,8 +284,6 @@ public static List getNpcLocations(String npcName) * @param avoidWilderness Whether to avoid locations in the Wilderness. */ public static MonsterLocation getClosestLocation(String npcName, int minClustering, boolean avoidWilderness) { - boolean originalUseBankItems = ShortestPathPlugin.getPathfinderConfig().isUseBankItems(); - ShortestPathPlugin.getPathfinderConfig().setUseBankItems(true); Microbot.log(Level.INFO,"Finding closest location for: " + npcName); List allLocations = getNpcLocations(npcName); @@ -309,9 +306,11 @@ public static MonsterLocation getClosestLocation(String npcName, int minClusteri return null; } - List centers = validLocations.stream() + List centeredLocations = validLocations.stream() + .filter(location -> location.getBestClusterCenter() != null) + .collect(Collectors.toList()); + List centers = centeredLocations.stream() .map(MonsterLocation::getBestClusterCenter) - .filter(Objects::nonNull) .collect(Collectors.toList()); if (centers.isEmpty()) { Microbot.log(Level.INFO,"Could not compute any centers for " + npcName); @@ -320,14 +319,17 @@ public static MonsterLocation getClosestLocation(String npcName, int minClusteri // 6) Find nearest and return int idx = Rs2Walker.findNearestAccessibleTarget(centers, true); - MonsterLocation closest = validLocations.get(idx); + if (idx < 0 || idx >= centeredLocations.size()) { + Microbot.log(Level.INFO,"No accessible location found for " + npcName); + return null; + } + MonsterLocation closest = centeredLocations.get(idx); if (closest.getCoords().isEmpty()) { Microbot.log(Level.INFO,"Closest location had no coords for " + npcName); return null; } Microbot.log(Level.INFO,"Closest location for " + npcName + ": " + closest.getLocationName()); - ShortestPathPlugin.getPathfinderConfig().setUseBankItems(originalUseBankItems); return closest; } @@ -342,4 +344,4 @@ public static MonsterLocation getClosestLocation(String npcName) return getClosestLocation(npcName, 1, false); } -} \ No newline at end of file +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/poh/PohTeleports.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/poh/PohTeleports.java index 4b929f5715c..d0237f71391 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/poh/PohTeleports.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/poh/PohTeleports.java @@ -9,8 +9,6 @@ import net.runelite.api.gameval.ObjectID; import net.runelite.api.widgets.Widget; import net.runelite.client.plugins.microbot.Microbot; -import net.runelite.client.plugins.microbot.shortestpath.Transport; -import net.runelite.client.plugins.microbot.shortestpath.TransportType; import net.runelite.client.plugins.microbot.util.equipment.JewelleryLocationEnum; import net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject; import net.runelite.client.plugins.microbot.util.keyboard.Rs2Keyboard; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/skills/slayer/Rs2Slayer.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/skills/slayer/Rs2Slayer.java index 3574882938c..eacd3a83927 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/skills/slayer/Rs2Slayer.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/skills/slayer/Rs2Slayer.java @@ -5,7 +5,6 @@ import net.runelite.api.gameval.ItemID; import net.runelite.api.gameval.VarPlayerID; import net.runelite.client.plugins.microbot.Microbot; -import net.runelite.client.plugins.microbot.shortestpath.ShortestPathPlugin; import net.runelite.client.plugins.microbot.shortestpath.Transport; import net.runelite.client.plugins.microbot.shortestpath.TransportType; import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; @@ -15,6 +14,8 @@ import net.runelite.client.plugins.microbot.util.npc.Rs2NpcManager; import net.runelite.client.plugins.microbot.util.skills.slayer.enums.ProtectiveEquipment; import net.runelite.client.plugins.microbot.util.skills.slayer.enums.SlayerMaster; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportEdge; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportType; import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; import net.runelite.client.plugins.slayer.Task; import org.jetbrains.annotations.NotNull; @@ -268,8 +269,7 @@ public static boolean walkToSlayerMaster(SlayerMaster master) { * @return the list */ public static List prepareItemTransports(WorldPoint cachedMonsterLocation) { - ShortestPathPlugin.getPathfinderConfig().setUseBankItems(true); - List transports = Rs2Walker.getTransportsForPath(Rs2Walker.getWalkPath(cachedMonsterLocation), 0) + List transports = Rs2Walker.getTransportsForDestination(cachedMonsterLocation, true) .stream() .filter(t -> t.getType() == TransportType.TELEPORTATION_ITEM || t.getType() == TransportType.FAIRY_RING) .peek(t -> { @@ -278,7 +278,6 @@ public static List prepareItemTransports(WorldPoint cachedMonsterLoca } }) .collect(Collectors.toList()); - ShortestPathPlugin.getPathfinderConfig().setUseBankItems(false); transports .forEach(t -> Microbot.log(Level.DEBUG,"Item required: " + t)); @@ -286,6 +285,23 @@ public static List prepareItemTransports(WorldPoint cachedMonsterLoca return getMissingItemTransports(transports); } + /** + * Planner-independent replacement for {@link #prepareItemTransports(WorldPoint)}. + * + *

The bank-item policy belongs to this one route request and the returned edges are the exact + * selections made by the planner. No shared planner configuration or mutable catalog rematch is + * exposed to the caller.

+ */ + public static List prepareItemTransportEdges(WorldPoint cachedMonsterLocation) { + List selected = Rs2Walker + .getTransportEdgesForDestination(cachedMonsterLocation, true) + .stream() + .filter(edge -> edge.getType() == Rs2TransportType.TELEPORTATION_ITEM + || edge.getType() == Rs2TransportType.FAIRY_RING) + .collect(Collectors.toUnmodifiableList()); + return Rs2Walker.getMissingTransportEdges(selected); + } + private static boolean hasRequiredTeleportItem(Transport transport) { if (transport.getType() == TransportType.FAIRY_RING) { return Rs2Inventory.hasItem(ItemID.DRAMEN_STAFF) || @@ -293,10 +309,8 @@ private static boolean hasRequiredTeleportItem(Transport transport) { Rs2Inventory.hasItem(ItemID.LUNAR_MOONCLAN_LIMINAL_STAFF) || Rs2Equipment.isWearing(ItemID.LUNAR_MOONCLAN_LIMINAL_STAFF); } else if (transport.getType() == TransportType.TELEPORTATION_ITEM) { - return transport.getItemIdRequirements() - .stream() - .flatMap(Collection::stream) - .anyMatch(itemId -> Rs2Equipment.isWearing(itemId) || Rs2Inventory.hasItem(itemId)); + return transport.getItemRequirements().stream() + .allMatch(requirement -> requirement.isSatisfiedBy(Rs2Slayer::carriedItemQuantity)); } return false; } @@ -316,12 +330,21 @@ private static List getMissingItemTransports(@NotNull List */ public static List getMissingItemIds(@NotNull List transports) { return transports.stream() - .flatMap(transport -> transport.getItemIdRequirements() - .stream() - .flatMap(Collection::stream) - .filter(Rs2Bank::hasItem) + .flatMap(transport -> transport.getItemRequirements().stream()) + .flatMap(requirement -> requirement.getItemIds().stream() + .filter(itemId -> Rs2Bank.count(itemId) + >= requirement.getRequiredQuantity(itemId)) .findFirst().stream()) .collect(Collectors.toList()); } + private static int carriedItemQuantity(int itemId) { + int quantity = Rs2Inventory.itemQuantity(itemId); + net.runelite.client.plugins.microbot.util.inventory.Rs2ItemModel equipped = Rs2Equipment.get(itemId); + if (equipped != null) { + quantity += Math.max(1, equipped.getQuantity()); + } + return quantity; + } + } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2ActiveRouteStatus.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2ActiveRouteStatus.java new file mode 100644 index 00000000000..354a6f22224 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2ActiveRouteStatus.java @@ -0,0 +1,152 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * Immutable, planner-independent snapshot of the route currently owned by the walker. + * + *

The generation identifies one published calculation without exposing the concrete planner. + * Callers that wait asynchronously can therefore reject a result when a newer route replaced the + * one they observed.

+ */ +public final class Rs2ActiveRouteStatus +{ + public enum Phase + { + ABSENT, + CALCULATING, + READY + } + + private final long generation; + private final Phase phase; + private final WorldPoint start; + private final Set targets; + private final List rawPath; + private final List walkablePath; + private final Rs2RouteTermination terminationReason; + private final Rs2RouteMetrics metrics; + + Rs2ActiveRouteStatus( + long generation, + Phase phase, + WorldPoint start, + Set targets, + List rawPath, + List walkablePath, + Rs2RouteTermination terminationReason, + Rs2RouteMetrics metrics) + { + if (generation < 0) + { + throw new IllegalArgumentException("generation must be non-negative"); + } + this.generation = generation; + this.phase = Objects.requireNonNull(phase, "phase"); + this.start = start; + this.targets = targets == null + ? Collections.emptySet() + : Collections.unmodifiableSet(new LinkedHashSet<>(targets)); + this.rawPath = rawPath == null ? Collections.emptyList() : List.copyOf(rawPath); + this.walkablePath = walkablePath == null + ? Collections.emptyList() + : List.copyOf(walkablePath); + this.terminationReason = terminationReason; + this.metrics = metrics; + validatePhase(); + } + + private void validatePhase() + { + if (phase == Phase.ABSENT + && (start != null || !targets.isEmpty() || !rawPath.isEmpty() || !walkablePath.isEmpty() + || terminationReason != null || metrics != null)) + { + throw new IllegalArgumentException("an absent route cannot carry planner state"); + } + if (phase == Phase.READY && (terminationReason == null || metrics == null)) + { + throw new IllegalArgumentException("a ready route must include termination and metrics"); + } + if (phase == Phase.CALCULATING && (terminationReason != null || metrics != null)) + { + throw new IllegalArgumentException("a calculating route cannot include completed search state"); + } + } + + static Rs2ActiveRouteStatus absent(long generation) + { + return new Rs2ActiveRouteStatus( + generation, Phase.ABSENT, null, Collections.emptySet(), + Collections.emptyList(), Collections.emptyList(), null, null); + } + + public long getGeneration() + { + return generation; + } + + public Phase getPhase() + { + return phase; + } + + public boolean isPresent() + { + return phase != Phase.ABSENT; + } + + public boolean isCalculating() + { + return phase == Phase.CALCULATING; + } + + public boolean isReady() + { + return phase == Phase.READY; + } + + public Optional getStart() + { + return Optional.ofNullable(start); + } + + public Set getTargets() + { + return targets; + } + + public List getRawPath() + { + return rawPath; + } + + public List getWalkablePath() + { + return walkablePath; + } + + public Optional getEndpoint() + { + return rawPath.isEmpty() + ? Optional.empty() + : Optional.of(rawPath.get(rawPath.size() - 1)); + } + + public Optional getTerminationReason() + { + return Optional.ofNullable(terminationReason); + } + + public Optional getMetrics() + { + return Optional.ofNullable(metrics); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2HotAirBalloon.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2HotAirBalloon.java new file mode 100644 index 00000000000..b1daed5d3e9 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2HotAirBalloon.java @@ -0,0 +1,114 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.GameObject; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.InterfaceID; +import net.runelite.api.gameval.ObjectID; +import net.runelite.client.plugins.microbot.shortestpath.TransportExecutionRegistry; +import net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject; +import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; + +import java.util.Optional; +import java.util.List; + +import static net.runelite.client.plugins.microbot.util.Global.sleepUntilTrue; + +/** Executes an already-unlocked hot-air-balloon network edge. */ +final class Rs2HotAirBalloon +{ + private static final int MAP_WAIT_POLL_MS = 100; + private static final int MAP_WAIT_TIMEOUT_MS = 5_000; + private static final int BASKET_SEARCH_RADIUS = 8; + private static final List BASKET_OBJECT_IDS = List.of( + ObjectID.ZEP_BASKET_ENTRANA, + ObjectID.ZEP_BASKET, + ObjectID.ZEP_MULTI_BASKET_ENTRANA, + ObjectID.ZEP_MULTI_BASKET_TAV, + ObjectID.ZEP_MULTI_BASKET_CAST, + ObjectID.ZEP_MULTI_BASKET_GNO, + ObjectID.ZEP_MULTI_BASKET_CRAFT, + ObjectID.ZEP_MULTI_BASKET_VARR); + + private Rs2HotAirBalloon() + { + } + + static boolean handle(Rs2TransportEdge transport) + { + if (transport == null || transport.getOrigin() == null) + { + return false; + } + Optional destination = + TransportExecutionRegistry.balloonDestinationFor(transport.getDisplayInfo()); + if (destination.isEmpty()) + { + return false; + } + + if (!isMapVisible()) + { + GameObject basket = findBasket(transport.getOrigin()); + if (basket == null || !Rs2GameObject.interact(basket, transport.getAction())) + { + return false; + } + if (!sleepUntilTrue(Rs2HotAirBalloon::isMapVisible, + MAP_WAIT_POLL_MS, MAP_WAIT_TIMEOUT_MS)) + { + return false; + } + } + + return Rs2Widget.clickWidget(destinationButton(destination.get())); + } + + static boolean isBasketObjectId(int objectId) + { + return BASKET_OBJECT_IDS.contains(objectId); + } + + private static GameObject findBasket(WorldPoint origin) + { + for (int objectId : BASKET_OBJECT_IDS) + { + GameObject basket = Rs2GameObject.getGameObject( + objectId, origin, BASKET_SEARCH_RADIUS); + if (basket != null) + { + return basket; + } + } + return null; + } + + static int destinationButton(TransportExecutionRegistry.BalloonDestination destination) + { + if (destination == null) + { + return -1; + } + switch (destination) + { + case CASTLE_WARS: + return InterfaceID.ZepBalloonMap.BTN_CAST; + case GRAND_TREE: + return InterfaceID.ZepBalloonMap.BTN_GNO; + case CRAFTING_GUILD: + return InterfaceID.ZepBalloonMap.BTN_CRAFT; + case ENTRANA: + return InterfaceID.ZepBalloonMap.BTN_ENT; + case TAVERLEY: + return InterfaceID.ZepBalloonMap.BTN_TAV; + case VARROCK: + return InterfaceID.ZepBalloonMap.BTN_VARR; + default: + return -1; + } + } + + private static boolean isMapVisible() + { + return Rs2Widget.isWidgetVisible(InterfaceID.ZepBalloonMap.ROOT_RECT0); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PathApi.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PathApi.java index c22ee17ba07..a8c5005095f 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PathApi.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PathApi.java @@ -1,18 +1,45 @@ package net.runelite.client.plugins.microbot.util.walker; +import com.google.common.util.concurrent.ThreadFactoryBuilder; import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.shortestpath.ShortestPathPlugin; +import net.runelite.client.plugins.microbot.shortestpath.PlannerSelectionMode; import net.runelite.client.plugins.microbot.shortestpath.TeleportationItem; import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.TransportExecutionRegistry; +import net.runelite.client.plugins.microbot.shortestpath.TransportItemRequirement; +import net.runelite.client.plugins.microbot.shortestpath.TransportType; +import net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.CollisionMap; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathEdge; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathfinderConfig; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathTerminationReason; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionView; import net.runelite.client.ui.overlay.worldmap.WorldMapPoint; import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Optional; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; /** * Microbot-owned facade over the shortest-path plugin's mutable static state. @@ -22,28 +49,153 @@ * Automation code ({@code Rs2Walker} and ~25 other consumers) currently reaches directly into * {@link ShortestPathPlugin}'s public static fields and accessors. Every time an upstream * (Skretzo/shortest-path) fix touches that internal wiring, the walker is at risk. Routing all - * plugin-state access through this single class freezes the surface the walker sees, so future - * upstream backports can change the plugin internals while only this facade (and not every - * consumer) has to move with them.

+ * plugin-state access through this single class confines direct static coupling, so future upstream + * backports have one compatibility seam instead of many consumers to update.

* - *

Contract. This is a thin, 1:1 delegation. Every method here forwards verbatim to - * the corresponding {@link ShortestPathPlugin} static member catalogued in the Stage 1 sweep. It - * intentionally introduces no behaviour change and holds no state of its own. The - * value types it returns ({@link Pathfinder}, {@link PathfinderConfig}, {@link Transport}, - * {@code TransportType}, {@code WorldPointUtil}) are treated as the stable Microbot-facing path API - * and are deliberately not re-wrapped — they are pure data / pure functions.

+ *

Contract. Legacy methods here retain thin delegation to the corresponding + * {@link ShortestPathPlugin} static member. New planning operations accept Microbot-owned immutable + * request/result values and keep refresh, construction, cancellation, executor ownership and temporary + * policy changes behind this seam. The + * legacy concrete accessors for {@link Pathfinder}, {@link PathfinderConfig} and {@link Transport} + * remain for binary compatibility, but new operations expose immutable route values or narrow named + * queries. That makes this a compatibility seam, not yet the final stable planning contract: active route + * state, lifecycle, synchronous queries and migrated catalog consumers no longer require concrete planner + * types outside this class.

* *

Migration status. Stage 3 is complete: every consumer under {@code microbot/util/} now * routes through this facade, so the only remaining references to {@link ShortestPathPlugin}'s - * static members outside the {@code shortestpath} package are the delegations below. That invariant - * is greppable, and is what keeps the blast radius of an upstream backport confined to this class: - *

grep -rn "ShortestPathPlugin\." microbot/util/   # expect hits in Rs2PathApi only
- * {@link ShortestPathPlugin}'s members remain public and binary-compatible for out-of-tree callers. - * Do not add logic here — if a call needs new behaviour, put it behind the plugin and expose it - * through a matching delegate.

+ * static members outside the {@code shortestpath} package are the delegations below, plus the plugin + * class literal used by {@code MicrobotPluginChoice}. That invariant is enforced by + * {@code scripts/check-shortest-path-boundary.py}. + * {@link ShortestPathPlugin}'s members remain public and binary-compatible for out-of-tree callers.

*/ public final class Rs2PathApi { + private static volatile Pathfinder activeRouteSnapshotSource; + private static volatile Rs2RouteResult activeRouteSnapshot; + private static final AtomicLong activeRouteGeneration = new AtomicLong(); + private static volatile boolean activeRouteComparisonEligible; + private static final Object shadowEvidenceMutex = new Object(); + private static final long shadowEvidenceStartedAtEpochMillis = System.currentTimeMillis(); + private static final long[][] shadowCoverageOutcomes = new long + [Rs2PlannerShadowContext.Coverage.values().length] + [Rs2PlannerShadowComparison.Status.values().length]; + private static final long[][] shadowTransportExecutorOutcomes = new long + [Rs2TransportExecutor.values().length] + [Rs2PlannerShadowComparison.Status.values().length]; + private static final long[][] shadowTransportTypeOutcomes = new long + [Rs2TransportType.values().length] + [Rs2PlannerShadowComparison.Status.values().length]; + private static long shadowGeneration; + private static long shadowSubmitted; + private static long shadowCompleted; + private static long shadowMatches; + private static long shadowDivergences; + private static long shadowFailures; + private static long shadowStaleResults; + private static long shadowDiscarded; + private static long shadowRouteShapeDifferences; + private static long upstreamCanarySelections; + private static long localFallbackDivergences; + private static long localFallbackFailures; + private static long shadowWalkerArrivals; + private static long shadowWalkerUnreachable; + private static long shadowWalkerExits; + private static long shadowRecoveryArrivals; + private static long shadowRecoveryUnreachable; + private static long shadowRecoveryExits; + private static long canaryPlanningSamples; + private static long canaryPlanningNanosTotal; + private static long canaryPlanningNanosMax; + private static long canaryLocalSearchNanosTotal; + private static long canaryLocalSearchNanosMax; + private static long canaryUpstreamSearchSamples; + private static long canaryUpstreamSearchNanosTotal; + private static long canaryUpstreamSearchNanosMax; + private static volatile Rs2PlannerShadowComparison lastShadowComparison; + private static volatile Rs2PlannerShadowComparison lastRouteShapeDifference; + private static volatile Rs2PlannerShadowComparison lastDivergence; + private static volatile Rs2PlannerShadowComparison lastPlannerFailure; + private static final ThreadPoolExecutor SHADOW_EXECUTOR = new ThreadPoolExecutor( + 1, 1, 0L, TimeUnit.MILLISECONDS, + new ArrayBlockingQueue<>(1), + new ThreadFactoryBuilder().setDaemon(true) + .setNameFormat("microbot-upstream-planner-shadow-%d").build(), + (command, executor) -> + { + if (executor.isShutdown()) + { + recordShadowDiscarded(); + return; + } + Runnable discarded = executor.getQueue().poll(); + if (discarded != null) + { + recordShadowDiscarded(); + } + if (!executor.getQueue().offer(command)) + { + recordShadowDiscarded(); + } + }); + private static final Map catalogEdgeSnapshots = + new ConcurrentHashMap<>(); + + private static final class CatalogEdgeSnapshot + { + private final Set source; + private final List edges; + + private CatalogEdgeSnapshot(Set source, List edges) + { + this.source = source; + this.edges = edges; + } + } + + private static final class PlannerComparisonTicket + { + private final long generation; + private final Rs2PlannerShadowContext context; + + private PlannerComparisonTicket(long generation, Rs2PlannerShadowContext context) + { + this.generation = generation; + this.context = context; + } + } + + private static final class PlannerEvaluation + { + private final PlannerComparisonTicket ticket; + private final Rs2RouteResult local; + private final Rs2RouteResult candidate; + private final Rs2PlannerShadowComparison comparison; + + private PlannerEvaluation( + PlannerComparisonTicket ticket, + Rs2RouteResult local, + Rs2RouteResult candidate, + Rs2PlannerShadowComparison comparison) + { + this.ticket = ticket; + this.local = local; + this.candidate = candidate; + this.comparison = comparison; + } + + private PlannerEvaluation materializationFailed(RuntimeException failure) + { + return new PlannerEvaluation( + ticket, + local, + null, + Rs2PlannerShadowComparison.failed( + comparison.getShadowEngineId(), ticket.context, + local, failure)); + } + } + private Rs2PathApi() { } @@ -66,9 +218,41 @@ public static Pathfinder getPathfinder() public static void setPathfinder(Pathfinder pathfinder) { + if (ShortestPathPlugin.getPathfinder() != pathfinder) + { + clearActiveRouteSnapshot(); + activeRouteComparisonEligible = false; + activeRouteGeneration.incrementAndGet(); + // A route replacement makes any in-flight comparison evidence stale, even when + // the replacement has shadow mode disabled and therefore submits no newer task. + synchronized (shadowEvidenceMutex) + { + shadowGeneration++; + lastShadowComparison = null; + } + } ShortestPathPlugin.setPathfinder(pathfinder); } + /** Replace the concrete compatibility view without starting a new logical route generation. */ + private static boolean replaceActivePathfinderLocked( + Pathfinder expected, Pathfinder replacement) + { + if (getPathfinder() != expected) + { + return false; + } + clearActiveRouteSnapshot(); + ShortestPathPlugin.setPathfinder(replacement); + return true; + } + + private static void clearActiveRouteSnapshot() + { + activeRouteSnapshotSource = null; + activeRouteSnapshot = null; + } + /** @return the {@link Future} tracking the in-flight pathfinding task, or {@code null}. */ public static Future getPathfinderFuture() { @@ -97,10 +281,1562 @@ public static Object getPathfinderMutex() return ShortestPathPlugin.getPathfinderMutex(); } + /** Immutable start point of the currently published route, if one exists. */ + public static Optional getActiveRouteStart() + { + return getActiveRouteStatus().getStart(); + } + + /** Immutable target snapshot of the currently published route. */ + public static Set getActiveRouteTargets() + { + return getActiveRouteStatus().getTargets(); + } + + /** + * Capture a coherent immutable view of the currently published route. + * + *

The lifecycle mutex prevents a route replacement halfway through the copy. The pathfinder may + * continue improving its partial path while calculating, but the returned lists cannot change under + * the caller.

+ */ + public static Rs2ActiveRouteStatus getActiveRouteStatus() + { + synchronized (getPathfinderMutex()) + { + long generation = activeRouteGeneration.get(); + Pathfinder source = getPathfinder(); + if (source == null) + { + return Rs2ActiveRouteStatus.absent(generation); + } + + Future activeFuture = getPathfinderFuture(); + boolean selectionComplete = activeFuture == null || activeFuture.isDone(); + boolean ready = source.isDone() && selectionComplete; + List rawPath = immutablePath(source.getPath()); + List walkablePath = ready + ? immutablePath(source.getWalkablePath()) + : rawPath; + if (!ready) + { + return new Rs2ActiveRouteStatus( + generation, + Rs2ActiveRouteStatus.Phase.CALCULATING, + source.getStart(), + source.getTargets(), + rawPath, + walkablePath, + null, + null); + } + + Pathfinder.PathfinderStats stats = source.getStats(); + Rs2RouteMetrics metrics = new Rs2RouteMetrics( + stats == null ? Rs2RouteMetrics.UNAVAILABLE : stats.getElapsedTimeNanos(), + source.getSelectedPathCost(), + stats == null ? Rs2RouteMetrics.UNAVAILABLE : stats.getNodesChecked(), + stats == null ? Rs2RouteMetrics.UNAVAILABLE : stats.getTransportsChecked()); + return new Rs2ActiveRouteStatus( + generation, + Rs2ActiveRouteStatus.Phase.READY, + source.getStart(), + source.getTargets(), + rawPath, + walkablePath, + mapTermination(source.getTerminationReason()), + metrics); + } + } + + private static List immutablePath(List path) + { + return path == null || path.isEmpty() ? Collections.emptyList() : List.copyOf(path); + } + + /** + * Cancel and unpublish the active local planner without exposing its concrete lifecycle to callers. + */ + public static void cancelAndClearActiveRoute() + { + synchronized (getPathfinderMutex()) + { + cancelAndClearActiveRouteLocked(); + } + } + + private static void cancelAndClearActiveRouteLocked() + { + Pathfinder active = getPathfinder(); + if (active != null) + { + active.cancel(); + } + Future activeFuture = getPathfinderFuture(); + if (activeFuture != null && !activeFuture.isDone()) + { + activeFuture.cancel(true); + } + setPathfinderFuture(null); + setPathfinder(null); + } + + /** + * Refresh, create and publish the active route using only Microbot-owned request policy. + * + *

The cave preference preserves the existing walker policy: calculate both the normal route and + * a walking-only route, then prefer walking when it reaches any requested target and is not longer. + * Non-cave planning remains asynchronous on the owned single-thread executor.

+ */ + public static boolean restartActiveRoute( + Rs2RouteRequest request, boolean preferWalkingOnly, int reachedDistance) + { + return restartActiveRoute( + request, preferWalkingOnly, reachedDistance, + Rs2PlannerShadowContext.Invocation.ACTIVE_ROUTE); + } + + /** + * Restart an active route with coordinate-free invocation metadata for shadow evidence. + */ + public static boolean restartActiveRoute( + Rs2RouteRequest request, + boolean preferWalkingOnly, + int reachedDistance, + Rs2PlannerShadowContext.Invocation invocation) + { + Objects.requireNonNull(request, "request"); + Objects.requireNonNull(invocation, "invocation"); + if (invocation == Rs2PlannerShadowContext.Invocation.SYNCHRONOUS_QUERY) + { + throw new IllegalArgumentException( + "an active route cannot use the synchronous-query shadow invocation"); + } + if (reachedDistance < 0) + { + throw new IllegalArgumentException("reachedDistance must be non-negative"); + } + if (request.getUseBankItems() != null) + { + throw new IllegalArgumentException( + "active asynchronous routes cannot use a temporary bank-item policy"); + } + PathfinderConfig config = getPathfinderConfig(); + if (config == null) + { + return false; + } + + synchronized (getPathfinderMutex()) + { + cancelAndClearActiveRouteLocked(); + if (shouldRefresh(request, config)) + { + config.refresh(request.getRefreshTarget()); + } + + if (preferWalkingOnly) + { + PlannerSelectionMode plannerMode = config.getPlannerSelectionMode(); + boolean comparisonEnabled = plannerMode.comparisonEnabled(); + Rs2RouteRequest normalRequest = comparisonEnabled + ? resolvePolicy(request, config) : null; + Rs2PlanningSnapshot normalSnapshot = comparisonEnabled + ? resolvePlanningSnapshot(normalRequest, config) : null; + long canaryPlanningStarted = System.nanoTime(); + Pathfinder normal = runLocalPlanner(config, request); + Pathfinder walkingOnly; + Rs2RouteRequest walkingRequest = null; + Rs2PlanningSnapshot walkingSnapshot = null; + try + { + config.setIgnoreTeleportAndItems(true); + if (comparisonEnabled) + { + walkingRequest = resolvePolicy(request, config); + walkingSnapshot = resolvePlanningSnapshot(walkingRequest, config); + } + walkingOnly = runLocalPlanner(config, request); + } + finally + { + config.setIgnoreTeleportAndItems(false); + } + Pathfinder selected = selectCaveRoute( + normal, walkingOnly, request.getTargets(), reachedDistance); + setPathfinder(selected); + boolean walkingSelected = selected == walkingOnly; + Rs2RouteRequest selectedRequest = walkingSelected ? walkingRequest : normalRequest; + Rs2PlanningSnapshot selectedSnapshot = walkingSelected ? walkingSnapshot : normalSnapshot; + activeRouteComparisonEligible = plannerMode == PlannerSelectionMode.SHADOW + || isF2pCanary(plannerMode, selectedRequest); + if (isF2pCanary(plannerMode, selectedRequest)) + { + Rs2RouteResult local = snapshot(selected, activeSearchNanos(selected)); + PlannerEvaluation evaluation = evaluateUpstream( + selectedRequest, selectedSnapshot, local, invocation, walkingSelected); + Pathfinder materialized = null; + if (shouldSelectUpstream(evaluation.comparison)) + { + try + { + materialized = materializeUpstreamRoute( + evaluation.candidate, config); + } + catch (RuntimeException failure) + { + evaluation = evaluation.materializationFailed(failure); + } + } + if (materialized != null) + { + replaceActivePathfinderLocked(selected, materialized); + } + recordPlannerComparison(evaluation); + recordCanaryOutcome( + evaluation.comparison, + elapsedNanos(canaryPlanningStarted), + combinedSearchNanos(normal, walkingOnly)); + } + else if (plannerMode == PlannerSelectionMode.SHADOW) + { + submitUpstreamShadow( + selectedRequest, + selectedSnapshot, + snapshot(selected, activeSearchNanos(selected)), + invocation, + walkingSelected); + } + return true; + } + + ExecutorService executor = getPathfindingExecutor(); + if (executor == null || executor.isShutdown()) + { + ThreadFactory threadFactory = new ThreadFactoryBuilder() + .setNameFormat("shortest-path-%d") + .build(); + executor = Executors.newSingleThreadExecutor(threadFactory); + setPathfindingExecutor(executor); + } + PlannerSelectionMode plannerMode = config.getPlannerSelectionMode(); + boolean comparisonEnabled = plannerMode.comparisonEnabled(); + Rs2RouteRequest comparisonRequest = comparisonEnabled + ? resolvePolicy(request, config) : null; + Rs2PlanningSnapshot comparisonSnapshot = comparisonEnabled + ? resolvePlanningSnapshot(comparisonRequest, config) : null; + Pathfinder active = new Pathfinder(config, request.getStart(), request.getTargets()); + setPathfinder(active); + activeRouteComparisonEligible = plannerMode == PlannerSelectionMode.SHADOW + || isF2pCanary(plannerMode, comparisonRequest); + long routeGeneration = activeRouteGeneration.get(); + long canaryPlanningStarted = System.nanoTime(); + setPathfinderFuture(executor.submit(() -> + { + active.run(); + if (!comparisonEnabled) + { + return; + } + if (isF2pCanary(plannerMode, comparisonRequest)) + { + runActiveCanarySelection( + active, routeGeneration, comparisonRequest, comparisonSnapshot, + config, invocation, canaryPlanningStarted); + return; + } + if (plannerMode != PlannerSelectionMode.SHADOW) + { + return; + } + synchronized (getPathfinderMutex()) + { + if (getPathfinder() != active + || activeRouteGeneration.get() != routeGeneration) + { + return; + } + submitUpstreamShadow( + comparisonRequest, + comparisonSnapshot, + snapshot(active, activeSearchNanos(active)), + invocation, + false); + } + })); + return true; + } + } + + private static Pathfinder runLocalPlanner(PathfinderConfig config, Rs2RouteRequest request) + { + Pathfinder pathfinder = new Pathfinder(config, request.getStart(), request.getTargets()); + pathfinder.run(); + return pathfinder; + } + + static boolean isF2pCanary( + PlannerSelectionMode plannerMode, Rs2RouteRequest request) + { + return plannerMode != null + && plannerMode.f2pCanaryEnabled() + && request != null + && request.getPolicy().map(policy -> !policy.isMembersWorld()).orElse(false); + } + + private static void runActiveCanarySelection( + Pathfinder active, + long routeGeneration, + Rs2RouteRequest request, + Rs2PlanningSnapshot planningSnapshot, + PathfinderConfig config, + Rs2PlannerShadowContext.Invocation invocation, + long canaryPlanningStarted) + { + synchronized (getPathfinderMutex()) + { + if (getPathfinder() != active || activeRouteGeneration.get() != routeGeneration) + { + return; + } + } + + Rs2RouteResult local = snapshot(active, activeSearchNanos(active)); + PlannerEvaluation evaluation = evaluateUpstream( + request, planningSnapshot, local, invocation, false); + Pathfinder materialized = null; + if (shouldSelectUpstream(evaluation.comparison)) + { + try + { + materialized = materializeUpstreamRoute(evaluation.candidate, config); + } + catch (RuntimeException failure) + { + evaluation = evaluation.materializationFailed(failure); + } + } + + synchronized (getPathfinderMutex()) + { + if (getPathfinder() != active || activeRouteGeneration.get() != routeGeneration) + { + recordPlannerComparison(evaluation); + return; + } + if (materialized != null) + { + replaceActivePathfinderLocked(active, materialized); + } + recordPlannerComparison(evaluation); + recordCanaryOutcome( + evaluation.comparison, + elapsedNanos(canaryPlanningStarted), + evaluation.comparison.getLocalSearchNanos()); + } + } + + /** Package-private selection seam for cave lifecycle policy regressions. */ + static Pathfinder selectCaveRoute( + Pathfinder normal, + Pathfinder walkingOnly, + Set targets, + int reachedDistance) + { + boolean normalAvailable = normal != null && !normal.getPath().isEmpty(); + boolean walkingAvailable = walkingOnly != null && !walkingOnly.getPath().isEmpty(); + if (!walkingAvailable) + { + return normalAvailable ? normal : walkingOnly; + } + WorldPoint endpoint = walkingOnly.getPath().get(walkingOnly.getPath().size() - 1); + boolean walkingReachesTarget = targets.stream() + .anyMatch(target -> target.getPlane() == endpoint.getPlane() + && target.distanceTo2D(endpoint) <= reachedDistance); + if (walkingReachesTarget + && normalAvailable + && normal.getPath().size() >= walkingOnly.getPath().size()) + { + return walkingOnly; + } + return normalAvailable ? normal : walkingOnly; + } + + // ------------------------------------------------------------------ + // Microbot-owned synchronous planning contract + // ------------------------------------------------------------------ + + /** + * Calculate a route without publishing it as the active walker pathfinder. + * + *

The shared configuration and its transport snapshots are mutable, so synchronous searches are + * serialized with walker start/cancel transitions. A request-level bank-item policy is temporary and + * always restored, including when refresh or pathfinding fails. This operation must run on a script or + * worker thread because refresh and search are blocking.

+ */ + public static Rs2RouteResult plan(Rs2RouteRequest request) + { + return calculate(request); + } + + private static Rs2RouteResult calculate(Rs2RouteRequest request) + { + Objects.requireNonNull(request, "request"); + if (Microbot.getClientThread() != null && Microbot.getClientThread().isClientThread()) + { + throw new IllegalStateException("synchronous route planning must not run on the client thread"); + } + PathfinderConfig config = getPathfinderConfig(); + if (config == null) + { + throw new IllegalStateException("shortest-path configuration is not initialized"); + } + + synchronized (getPathfinderMutex()) + { + Boolean requestedBankItems = request.getUseBankItems(); + boolean originalUseBankItems = config.isUseBankItems(); + boolean bankPolicyChanged = requestedBankItems != null + && requestedBankItems != originalUseBankItems; + try + { + if (bankPolicyChanged) + { + config.setUseBankItems(requestedBankItems); + } + if (shouldRefresh(request, config)) + { + config.refresh(request.getRefreshTarget()); + } + Rs2RouteRequest resolved = resolvePolicy(request, config); + Rs2PlanningSnapshot snapshot = resolvePlanningSnapshot(resolved, config); + long canaryPlanningStarted = System.nanoTime(); + Rs2RouteResult local = localPlanner(config).plan(resolved, snapshot); + PlannerSelectionMode plannerMode = config.getPlannerSelectionMode(); + if (isF2pCanary(plannerMode, resolved)) + { + PlannerEvaluation evaluation = evaluateUpstream( + resolved, + snapshot, + local, + Rs2PlannerShadowContext.Invocation.SYNCHRONOUS_QUERY, + false); + recordPlannerComparison(evaluation); + recordCanaryOutcome( + evaluation.comparison, + elapsedNanos(canaryPlanningStarted), + evaluation.comparison.getLocalSearchNanos()); + return shouldSelectUpstream(evaluation.comparison) + ? evaluation.candidate : local; + } + if (plannerMode == PlannerSelectionMode.SHADOW) + { + submitUpstreamShadow( + resolved, + snapshot, + local, + Rs2PlannerShadowContext.Invocation.SYNCHRONOUS_QUERY, + false); + } + return local; + } + finally + { + if (bankPolicyChanged) + { + config.setUseBankItems(originalUseBankItems); + config.refresh(request.getRefreshTarget()); + } + } + } + } + + private static boolean shouldRefresh(Rs2RouteRequest request, PathfinderConfig config) + { + if (request.getUseBankItems() != null + || request.getRefreshPolicy() == Rs2RouteRequest.RefreshPolicy.ALWAYS) + { + return true; + } + return request.getRefreshPolicy() == Rs2RouteRequest.RefreshPolicy.IF_TRANSPORTS_EMPTY + && config.getTransports().isEmpty(); + } + + /** Package-private calculation seam for headless tests with an isolated configuration. */ + static Rs2RouteResult planWithConfig(Rs2RouteRequest request, PathfinderConfig config) + { + Rs2RouteRequest resolved = resolvePolicy(request, config); + return localPlanner(config).plan(resolved, resolvePlanningSnapshot(resolved, config)); + } + + static Rs2RoutePlanner localPlanner(PathfinderConfig config) + { + return new LocalRoutePlanner(config); + } + + static Rs2RoutePlanner upstreamPlanner() + { + UpstreamRoutePlanner delegate = new UpstreamRoutePlanner(); + if (Boolean.getBoolean("microbot.test.mode") + && Boolean.getBoolean("microbot.test.walker.forceUpstreamPlannerFailure")) + { + return new Rs2RoutePlanner() + { + @Override + public String getEngineId() + { + return delegate.getEngineId(); + } + + @Override + public Rs2RouteResult plan( + Rs2RouteRequest request, Rs2PlanningSnapshot snapshot) + { + throw new IllegalStateException("synthetic upstream rollout failure"); + } + }; + } + return delegate; + } + + public static Optional getLastShadowComparison() + { + return Optional.ofNullable(lastShadowComparison); + } + + /** + * Most recently completed semantic match whose exact walking shape differed. + * + *

Unlike {@link #getLastShadowComparison()}, this process-lifetime diagnostic survives active-route + * teardown so a settled harness snapshot can still classify a non-zero aggregate shape-difference count.

+ */ + public static Optional getLastRouteShapeDifference() + { + return Optional.ofNullable(lastRouteShapeDifference); + } + + /** + * Most recently completed semantic divergence. + * + *

This process-lifetime diagnostic deliberately survives active-route teardown. It contains only the + * coordinate-free comparison summary exposed by the shadow endpoint, so a harness can explain a non-zero + * divergence counter without retaining the player's route.

+ */ + public static Optional getLastDivergence() + { + return Optional.ofNullable(lastDivergence); + } + + /** + * Most recently completed upstream planner failure, retained for the process lifetime. + * + *

The serialized comparison exposes only the exception class name, never its message or route data.

+ */ + public static Optional getLastPlannerFailure() + { + return Optional.ofNullable(lastPlannerFailure); + } + + public static boolean isUpstreamPlannerShadowEnabled() + { + PathfinderConfig config = getPathfinderConfig(); + return config != null && config.getPlannerSelectionMode().comparisonEnabled(); + } + + public static PlannerSelectionMode getPlannerSelectionMode() + { + PathfinderConfig config = getPathfinderConfig(); + return config == null ? PlannerSelectionMode.LOCAL : config.getPlannerSelectionMode(); + } + + public static String getUpstreamPlannerEngineId() + { + return upstreamPlanner().getEngineId(); + } + + /** Process-lifetime counters for bounded, non-authoritative shadow evidence. */ + public static Rs2PlannerShadowStats getShadowStats() + { + synchronized (shadowEvidenceMutex) + { + EnumMap + coverage = new EnumMap<>(Rs2PlannerShadowContext.Coverage.class); + for (Rs2PlannerShadowContext.Coverage value + : Rs2PlannerShadowContext.Coverage.values()) + { + long[] outcomes = shadowCoverageOutcomes[value.ordinal()]; + coverage.put(value, new Rs2PlannerShadowCoverageStats( + outcomes[Rs2PlannerShadowComparison.Status.MATCH.ordinal()], + outcomes[Rs2PlannerShadowComparison.Status.DIVERGENCE.ordinal()], + outcomes[Rs2PlannerShadowComparison.Status.FAILED.ordinal()])); + } + EnumMap + transportExecutors = new EnumMap<>(Rs2TransportExecutor.class); + for (Rs2TransportExecutor value : Rs2TransportExecutor.values()) + { + long[] outcomes = shadowTransportExecutorOutcomes[value.ordinal()]; + transportExecutors.put(value, new Rs2PlannerShadowCoverageStats( + outcomes[Rs2PlannerShadowComparison.Status.MATCH.ordinal()], + outcomes[Rs2PlannerShadowComparison.Status.DIVERGENCE.ordinal()], + outcomes[Rs2PlannerShadowComparison.Status.FAILED.ordinal()])); + } + EnumMap + transportTypes = new EnumMap<>(Rs2TransportType.class); + for (Rs2TransportType value : Rs2TransportType.values()) + { + long[] outcomes = shadowTransportTypeOutcomes[value.ordinal()]; + transportTypes.put(value, new Rs2PlannerShadowCoverageStats( + outcomes[Rs2PlannerShadowComparison.Status.MATCH.ordinal()], + outcomes[Rs2PlannerShadowComparison.Status.DIVERGENCE.ordinal()], + outcomes[Rs2PlannerShadowComparison.Status.FAILED.ordinal()])); + } + return new Rs2PlannerShadowStats( + shadowSubmitted, + shadowCompleted, + shadowMatches, + shadowDivergences, + shadowFailures, + shadowStaleResults, + shadowDiscarded, + shadowRouteShapeDifferences, + upstreamCanarySelections, + localFallbackDivergences, + localFallbackFailures, + shadowEvidenceStartedAtEpochMillis, + coverage, + transportExecutors, + transportTypes, + new Rs2WalkerShadowExecutionStats( + shadowWalkerArrivals, + shadowWalkerUnreachable, + shadowWalkerExits, + shadowRecoveryArrivals, + shadowRecoveryUnreachable, + shadowRecoveryExits), + new Rs2PlannerCanaryPerformanceStats( + canaryPlanningSamples, + canaryPlanningNanosTotal, + canaryPlanningNanosMax, + canaryLocalSearchNanosTotal, + canaryLocalSearchNanosMax, + canaryUpstreamSearchSamples, + canaryUpstreamSearchNanosTotal, + canaryUpstreamSearchNanosMax)); + } + } + + /** + * Whether the current logical active route was admitted to planner comparison. + * + *

This is captured when a blocking walk first consumes a generation-matched ready route because arrival + * normally clears the active route before the terminal outcome is recorded. In F2P-canary mode it is false + * for members-policy routes, even though the process-wide planner mode still has comparison capability + * enabled.

+ */ + static boolean isActiveRouteComparisonEligible() + { + return activeRouteComparisonEligible; + } + + /** Generation-matched form used when a walker consumes a previously captured active-route snapshot. */ + static boolean isActiveRouteComparisonEligible(long routeGeneration) + { + synchronized (getPathfinderMutex()) + { + return activeRouteGeneration.get() == routeGeneration + && activeRouteComparisonEligible; + } + } + + /** Record one blocking walk's terminal result for a route that actually entered planner comparison. */ + static void recordShadowWalkerOutcome( + WalkerState state, boolean recoveryTriggered, boolean comparisonEligible) + { + Objects.requireNonNull(state, "state"); + if (!comparisonEligible || state == WalkerState.MOVING) + { + return; + } + synchronized (shadowEvidenceMutex) + { + switch (state) + { + case ARRIVED: shadowWalkerArrivals++; break; + case UNREACHABLE: shadowWalkerUnreachable++; break; + case EXIT: shadowWalkerExits++; break; + default: throw new IllegalStateException("unhandled walker state " + state); + } + if (recoveryTriggered) + { + switch (state) + { + case ARRIVED: shadowRecoveryArrivals++; break; + case UNREACHABLE: shadowRecoveryUnreachable++; break; + case EXIT: shadowRecoveryExits++; break; + default: throw new IllegalStateException("unhandled walker state " + state); + } + } + } + } + + private static void recordShadowDiscarded() + { + synchronized (shadowEvidenceMutex) + { + shadowDiscarded++; + } + } + + private static void submitUpstreamShadow( + Rs2RouteRequest request, + Rs2PlanningSnapshot snapshot, + Rs2RouteResult local, + Rs2PlannerShadowContext.Invocation invocation, + boolean walkingOnlySelected) + { + Rs2PlannerShadowContext context = Rs2PlannerShadowContext.from( + invocation, walkingOnlySelected, request, local); + PlannerComparisonTicket ticket = beginPlannerComparison(context); + SHADOW_EXECUTOR.execute(() -> recordPlannerComparison( + evaluateUpstream(ticket, request, snapshot, local))); + } + + private static PlannerEvaluation evaluateUpstream( + Rs2RouteRequest request, + Rs2PlanningSnapshot snapshot, + Rs2RouteResult local, + Rs2PlannerShadowContext.Invocation invocation, + boolean walkingOnlySelected) + { + Rs2PlannerShadowContext context = Rs2PlannerShadowContext.from( + invocation, walkingOnlySelected, request, local); + return evaluateUpstream(beginPlannerComparison(context), request, snapshot, local); + } + + private static PlannerComparisonTicket beginPlannerComparison( + Rs2PlannerShadowContext context) + { + synchronized (shadowEvidenceMutex) + { + long generation = ++shadowGeneration; + shadowSubmitted++; + lastShadowComparison = null; + return new PlannerComparisonTicket(generation, context); + } + } + + private static PlannerEvaluation evaluateUpstream( + PlannerComparisonTicket ticket, + Rs2RouteRequest request, + Rs2PlanningSnapshot snapshot, + Rs2RouteResult local) + { + Rs2RoutePlanner planner = upstreamPlanner(); + try + { + Rs2RouteResult candidate = planner.plan(request, snapshot); + return new PlannerEvaluation( + ticket, + local, + candidate, + Rs2PlannerShadowComparison.compare( + planner.getEngineId(), ticket.context, local, candidate)); + } + catch (RuntimeException failure) + { + return new PlannerEvaluation( + ticket, + local, + null, + Rs2PlannerShadowComparison.failed( + planner.getEngineId(), ticket.context, local, failure)); + } + } + + private static void recordPlannerComparison(PlannerEvaluation evaluation) + { + Rs2PlannerShadowComparison comparison = evaluation.comparison; + synchronized (shadowEvidenceMutex) + { + switch (comparison.getStatus()) + { + case MATCH: shadowMatches++; break; + case DIVERGENCE: + shadowDivergences++; + lastDivergence = comparison; + break; + case FAILED: + shadowFailures++; + lastPlannerFailure = comparison; + break; + default: throw new IllegalStateException( + "unhandled shadow comparison status " + comparison.getStatus()); + } + for (Rs2PlannerShadowContext.Coverage value + : comparison.getContext().getCoverage()) + { + shadowCoverageOutcomes[value.ordinal()][comparison.getStatus().ordinal()]++; + } + for (Rs2TransportExecutor value + : comparison.getContext().getTransportExecutors()) + { + shadowTransportExecutorOutcomes[value.ordinal()] + [comparison.getStatus().ordinal()]++; + } + for (Rs2TransportType value : comparison.getContext().getTransportTypes()) + { + shadowTransportTypeOutcomes[value.ordinal()] + [comparison.getStatus().ordinal()]++; + } + if (comparison.getStatus() != Rs2PlannerShadowComparison.Status.FAILED + && !comparison.isPathMatches()) + { + shadowRouteShapeDifferences++; + lastRouteShapeDifference = comparison; + } + shadowCompleted++; + if (shadowGeneration == evaluation.ticket.generation) + { + lastShadowComparison = comparison; + } + else + { + shadowStaleResults++; + } + } + } + + private static void recordCanaryOutcome( + Rs2PlannerShadowComparison comparison, + long planningNanos, + long localPlanningNanos) + { + synchronized (shadowEvidenceMutex) + { + switch (comparison.getStatus()) + { + case MATCH: upstreamCanarySelections++; break; + case DIVERGENCE: localFallbackDivergences++; break; + case FAILED: localFallbackFailures++; break; + default: throw new IllegalStateException( + "unhandled canary comparison status " + comparison.getStatus()); + } + if (planningNanos < 0L || localPlanningNanos < 0L) + { + throw new IllegalArgumentException( + "canary planning and local search durations must be available"); + } + canaryPlanningSamples++; + canaryPlanningNanosTotal = saturatedAdd( + canaryPlanningNanosTotal, planningNanos); + canaryPlanningNanosMax = Math.max(canaryPlanningNanosMax, planningNanos); + canaryLocalSearchNanosTotal = saturatedAdd( + canaryLocalSearchNanosTotal, localPlanningNanos); + canaryLocalSearchNanosMax = Math.max( + canaryLocalSearchNanosMax, localPlanningNanos); + long upstreamSearchNanos = comparison.getShadowSearchNanos(); + if (upstreamSearchNanos >= 0L) + { + canaryUpstreamSearchSamples++; + canaryUpstreamSearchNanosTotal = saturatedAdd( + canaryUpstreamSearchNanosTotal, upstreamSearchNanos); + canaryUpstreamSearchNanosMax = Math.max( + canaryUpstreamSearchNanosMax, upstreamSearchNanos); + } + } + } + + private static long elapsedNanos(long started) + { + return Math.max(0L, System.nanoTime() - started); + } + + private static long combinedSearchNanos(Pathfinder... pathfinders) + { + long total = 0L; + for (Pathfinder pathfinder : pathfinders) + { + long searchNanos = activeSearchNanos(pathfinder); + if (searchNanos < 0L) + { + return Rs2RouteMetrics.UNAVAILABLE; + } + total = saturatedAdd(total, searchNanos); + } + return total; + } + + private static long saturatedAdd(long left, long right) + { + if (right > Long.MAX_VALUE - left) + { + return Long.MAX_VALUE; + } + return left + right; + } + + /** A route-shape-only difference is a semantic match and remains eligible for the canary. */ + static boolean shouldSelectUpstream(Rs2PlannerShadowComparison comparison) + { + return comparison != null + && comparison.getStatus() == Rs2PlannerShadowComparison.Status.MATCH; + } + + static Rs2RouteRequest resolvePolicy( + Rs2RouteRequest request, PathfinderConfig config) + { + Objects.requireNonNull(request, "request"); + Objects.requireNonNull(config, "config"); + EnumSet enabledTypes = EnumSet.noneOf(Rs2TransportType.class); + for (TransportType type : config.getEnabledTransportTypes()) + { + enabledTypes.add(mapTransportType(type)); + } + Set restrictedPoints = new LinkedHashSet<>(); + for (int packed : config.getRestrictedPointsPacked()) + { + restrictedPoints.add(WorldPointUtil.unpackWorldPoint(packed)); + } + Rs2RoutePolicy policy = new Rs2RoutePolicy( + config.isUseBankItems(), + config.isAvoidWilderness(), + config.isAvoidDangerousNpcs(), + config.isIgnoreTeleportAndItems(), + Rs2Walker.disableTeleports, + config.isMembersWorld(), + config.getLiveCollisionOverlay().isEnabled(), + config.getCalculationCutoffMillis(), + config.getDistanceBeforeUsingTeleport(), + Rs2RoutePolicy.TeleportationItemMode.valueOf( + config.getTeleportationItemPolicy().name()), + enabledTypes, + restrictedPoints); + return request.withPolicy(policy); + } + + static Rs2PlanningSnapshot resolvePlanningSnapshot( + Rs2RouteRequest request, PathfinderConfig config) + { + Objects.requireNonNull(request, "request"); + Objects.requireNonNull(config, "config"); + Rs2RoutePolicy policy = request.getPolicy().orElseThrow( + () -> new IllegalArgumentException("planning snapshot requires a resolved policy")); + Set admitted = Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + Map> activeTransports = config.getTransports(); + if (activeTransports != null) + { + for (Set values : activeTransports.values()) + { + admitted.addAll(values); + } + } + Set usableTeleports = config.getUsableTeleportsSnapshot(); + if (usableTeleports != null) + { + admitted.addAll(usableTeleports); + } + List edges = new ArrayList<>(admitted.size()); + for (Transport transport : admitted) + { + edges.add(toTransportEdge(transport)); + } + net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionOverlay overlay = + config.getLiveCollisionOverlay(); + LiveCollisionView live = overlay == null ? null : overlay.current(); + Rs2PlanningSnapshot.CollisionOverride collision = live == null ? null : live::edge; + Set blocked = config.getBlockedTransportEdgesPacked(); + return new Rs2PlanningSnapshot( + policy, + edges, + collision, + blocked == null ? Collections.emptySet() : new LinkedHashSet<>(blocked), + config::isDangerousAdjacentTile); + } + + private static final class LocalRoutePlanner implements Rs2RoutePlanner + { + private final PathfinderConfig config; + + private LocalRoutePlanner(PathfinderConfig config) + { + this.config = Objects.requireNonNull(config, "config"); + } + + @Override + public String getEngineId() + { + return "microbot-local"; + } + + @Override + public Rs2RouteResult plan(Rs2RouteRequest request, Rs2PlanningSnapshot snapshot) + { + Objects.requireNonNull(request, "request"); + Objects.requireNonNull(snapshot, "snapshot"); + Rs2RoutePolicy policy = request.getPolicy().orElseThrow( + () -> new IllegalArgumentException("route planner requires a resolved policy")); + if (snapshot.getPolicy() != policy) + { + throw new IllegalArgumentException("route request and planning snapshot policy differ"); + } + long started = System.nanoTime(); + Pathfinder pathfinder = new Pathfinder( + config, request.getStart(), request.getTargets()); + pathfinder.run(); + long elapsed = System.nanoTime() - started; + return snapshot(pathfinder, elapsed); + } + } + + /** Transitional concrete view for legacy overlays; the immutable route remains authoritative. */ + static Pathfinder materializeUpstreamRoute( + Rs2RouteResult result, PathfinderConfig config) + { + Objects.requireNonNull(result, "result"); + Objects.requireNonNull(config, "config"); + List path = result.getPath(); + List steps = result.getSteps(); + if (steps.size() != Math.max(0, path.size() - 1)) + { + throw new IllegalArgumentException( + "upstream route steps must align with the materialized path"); + } + List transportsByStep = new ArrayList<>(steps.size()); + for (int i = 0; i < steps.size(); i++) + { + Rs2RouteStep step = steps.get(i); + if (!path.get(i).equals(step.getFrom()) + || !path.get(i + 1).equals(step.getTo())) + { + throw new IllegalArgumentException( + "upstream route step endpoints must align with the materialized path"); + } + if (!step.isTransport()) + { + transportsByStep.add(null); + continue; + } + Object sourceIdentity = step.getTransport().orElseThrow( + IllegalStateException::new).getSourceIdentity(); + if (!(sourceIdentity instanceof Transport)) + { + throw new IllegalArgumentException( + "upstream transport step is missing exact local execution identity"); + } + transportsByStep.add((Transport) sourceIdentity); + } + + Rs2RouteMetrics metrics = result.getMetrics(); + return Pathfinder.completedRoute( + config, + result.getStart(), + result.getTargets(), + path, + transportsByStep, + mapTermination(result.getTerminationReason()), + metrics.getPathCost(), + metrics.getSearchNanos(), + metrics.getNodesChecked(), + metrics.getTransportsChecked(), + metrics.getLiveCollisionEdgesChecked()); + } + + /** + * Immutable view of the completed path currently published to the walker. + * + *

The source pathfinder identity is checked on every call. Replanning therefore invalidates the + * cached value even if the new route happens to contain the same points. In-flight pathfinders do not + * publish partial executor contracts.

+ */ + public static Optional getActiveRoute() + { + Pathfinder source = getPathfinder(); + Future activeFuture = getPathfinderFuture(); + if (source == null || !source.isDone() + || (activeFuture != null && !activeFuture.isDone())) + { + return Optional.empty(); + } + Rs2RouteResult snapshot = activeRouteSnapshot; + if (activeRouteSnapshotSource == source && snapshot != null) + { + return Optional.of(snapshot); + } + synchronized (getPathfinderMutex()) + { + activeFuture = getPathfinderFuture(); + if (getPathfinder() != source || !source.isDone() + || (activeFuture != null && !activeFuture.isDone())) + { + return Optional.empty(); + } + snapshot = snapshot(source, Rs2RouteMetrics.UNAVAILABLE); + activeRouteSnapshotSource = source; + activeRouteSnapshot = snapshot; + return Optional.of(snapshot); + } + } + + private static Rs2RouteResult snapshot(Pathfinder pathfinder, long elapsed) + { + Pathfinder.PathfinderStats stats = pathfinder.getStats(); + List path = pathfinder.getPath() == null + ? Collections.emptyList() + : pathfinder.getPath(); + List steps = new ArrayList<>(); + for (PathEdge edge : pathfinder.getPathEdges()) + { + if (edge.isTransport()) + { + steps.add(Rs2RouteStep.transport( + edge.getFrom(), edge.getTo(), toTransportEdge(edge.getTransport()))); + } + else + { + steps.add(Rs2RouteStep.walk(edge.getFrom(), edge.getTo())); + } + } + return new Rs2RouteResult( + pathfinder.getStart(), + pathfinder.getTargets(), + path, + steps, + mapTermination(pathfinder.getTerminationReason()), + new Rs2RouteMetrics( + elapsed, + pathfinder.getSelectedPathCost(), + stats == null ? Rs2RouteMetrics.UNAVAILABLE : stats.getNodesChecked(), + stats == null ? Rs2RouteMetrics.UNAVAILABLE : stats.getTransportsChecked(), + stats == null ? Rs2RouteMetrics.UNAVAILABLE + : stats.getLiveCollisionEdgesChecked())); + } + + private static long activeSearchNanos(Pathfinder pathfinder) + { + Pathfinder.PathfinderStats stats = pathfinder.getStats(); + return stats == null + ? Rs2RouteMetrics.UNAVAILABLE : stats.getElapsedTimeNanos(); + } + + /** + * Exact local execution selection for the runtime walker. + * + *

The immutable edge and its executor are the engine-independent contract. The local transport is + * an opaque implementation payload for the existing handlers (not a public planning value); POH in + * particular carries executable subtype behavior. It is always the exact object selected by search, + * never a catalog lookup or origin/destination rematch.

+ */ + static final class ActiveTransportSelection + { + private final int pathIndex; + private final Rs2TransportEdge edge; + private final Transport localExecutionTransport; + + private ActiveTransportSelection(int pathIndex, Rs2TransportEdge edge, Transport localExecutionTransport) + { + this.pathIndex = pathIndex; + this.edge = edge; + this.localExecutionTransport = localExecutionTransport; + } + + int getPathIndex() { return pathIndex; } + Rs2TransportEdge getEdge() { return edge; } + Rs2TransportExecutor getExecutor() { return edge.getExecutor(); } + Transport getLocalExecutionTransport() { return localExecutionTransport; } + boolean isExecutable() { return getExecutor() != Rs2TransportExecutor.UNSUPPORTED; } + } + + static Optional getActiveTransportSelection( + List expectedPath, int pathIndex) + { + List selections = getActiveTransportSelections(expectedPath); + return selections.stream() + .filter(selection -> selection.getPathIndex() == pathIndex) + .findFirst(); + } + + static List getActiveTransportSelections(List expectedPath) + { + Pathfinder source = getPathfinder(); + List selections = getTransportSelections(source, expectedPath); + return getPathfinder() == source ? selections : Collections.emptyList(); + } + + static Optional getActiveTransportEdge(WorldPoint from, WorldPoint to) + { + if (from == null || to == null) + { + return Optional.empty(); + } + return getActiveRoute().flatMap(route -> route.getTransportEdge(from, to)); + } + + /** Package-private pure seam for route-selection regressions. */ + static List getTransportSelections( + Pathfinder source, List expectedPath) + { + if (source == null || !source.isDone() || expectedPath == null) + { + return Collections.emptyList(); + } + List actualPath = source.getPath(); + if (actualPath == null || !actualPath.equals(expectedPath)) + { + return Collections.emptyList(); + } + List pathEdges = source.getPathEdges(); + if (pathEdges.size() != Math.max(0, actualPath.size() - 1)) + { + return Collections.emptyList(); + } + List selections = new ArrayList<>(); + for (int i = 0; i < pathEdges.size(); i++) + { + PathEdge pathEdge = pathEdges.get(i); + Transport selected = pathEdge.getTransport(); + if (selected == null) + { + continue; + } + if (!actualPath.get(i).equals(pathEdge.getFrom()) + || !actualPath.get(i + 1).equals(pathEdge.getTo())) + { + return Collections.emptyList(); + } + selections.add(new ActiveTransportSelection(i, toTransportEdge(selected), selected)); + } + return List.copyOf(selections); + } + + private static Rs2TransportEdge toTransportEdge(Transport transport) + { + List itemRequirements = new ArrayList<>(); + for (TransportItemRequirement requirement : transport.getItemRequirements()) + { + itemRequirements.add(new Rs2TransportItemRequirement( + requirement.getAlternatives(), + requirement.getStaffAlternatives(), + requirement.getOffhandAlternatives(), + requirement.isRuneOnly())); + } + return new Rs2TransportEdge( + transport.getOrigin(), + transport.getDestination(), + mapTransportType(transport.getType()), + mapExecutor(TransportExecutionRegistry.executorFor(transport).orElse(null)), + mapTerminalTravelMode(transport), + transport.getDisplayInfo(), + transport.getAction(), + transport.getName(), + transport.getObjectId(), + transport.getDuration(), + TransportType.isTeleport(transport.getType(), transport.getOrigin()), + transport.isConsumable(), + transport.isMembers(), + transport.getMaxWildernessLevel(), + transport.getCurrencyName(), + transport.getCurrencyAmount(), + itemRequirements, + Arrays.stream(transport.getSkillLevels()).anyMatch(level -> level > 0), + transport.isQuestLocked(), + !transport.getVarbits().isEmpty() || !transport.getVarplayers().isEmpty(), + transport); + } + + private static Rs2TerminalTravelMode mapTerminalTravelMode(Transport transport) + { + return TransportExecutionRegistry.terminalTravelModeFor(transport) + .map(mode -> Rs2TerminalTravelMode.valueOf(mode.name())) + .orElse(Rs2TerminalTravelMode.UNSUPPORTED); + } + + private static Rs2TransportExecutor mapExecutor(TransportExecutionRegistry.Executor executor) + { + if (executor == null) + { + return Rs2TransportExecutor.UNSUPPORTED; + } + try + { + return Rs2TransportExecutor.valueOf(executor.name()); + } + catch (IllegalArgumentException ignored) + { + return Rs2TransportExecutor.UNSUPPORTED; + } + } + + private static Rs2TransportType mapTransportType(TransportType type) + { + if (type == null) + { + return Rs2TransportType.UNKNOWN; + } + try + { + return Rs2TransportType.valueOf(type.name()); + } + catch (IllegalArgumentException ignored) + { + return Rs2TransportType.UNKNOWN; + } + } + + private static Rs2RouteTermination mapTermination(PathTerminationReason reason) + { + if (reason == null) + { + return Rs2RouteTermination.FAILED; + } + switch (reason) + { + case TARGET_REACHED: + return Rs2RouteTermination.TARGET_REACHED; + case SEARCH_EXHAUSTED: + return Rs2RouteTermination.SEARCH_EXHAUSTED; + case CUTOFF_REACHED: + return Rs2RouteTermination.CUTOFF_REACHED; + case CANCELLED: + return Rs2RouteTermination.CANCELLED; + case FAILED: + default: + return Rs2RouteTermination.FAILED; + } + } + + private static PathTerminationReason mapTermination(Rs2RouteTermination reason) + { + if (reason == null) + { + return PathTerminationReason.FAILED; + } + switch (reason) + { + case TARGET_REACHED: + return PathTerminationReason.TARGET_REACHED; + case SEARCH_EXHAUSTED: + return PathTerminationReason.SEARCH_EXHAUSTED; + case CUTOFF_REACHED: + return PathTerminationReason.CUTOFF_REACHED; + case CANCELLED: + return PathTerminationReason.CANCELLED; + case FAILED: + default: + return PathTerminationReason.FAILED; + } + } + // ------------------------------------------------------------------ // Config // ------------------------------------------------------------------ + /** + * Whether at least one tile within {@code distance} of {@code target} is standable in the + * configured static collision map. + * + *

This check deliberately abstains when the configuration, target or map region is absent. + * Instances and newly added regions must be left to the planner rather than rejected as blocked.

+ */ + public static boolean hasWalkableTileWithin(WorldPoint target, int distance) + { + PathfinderConfig config = getPathfinderConfig(); + return hasWalkableTileWithin(config == null ? null : config.getMap(), target, distance); + } + + /** Package-private collision seam for deterministic headless tests. */ + static boolean hasWalkableTileWithin(CollisionMap map, WorldPoint target, int distance) + { + if (map == null || target == null) + { + return true; + } + if (!map.hasRegion(target.getX(), target.getY())) + { + return true; + } + int radius = Math.max(0, distance); + for (int dx = -radius; dx <= radius; dx++) + { + for (int dy = -radius; dy <= radius; dy++) + { + int x = target.getX() + dx; + int y = target.getY() + dy; + if (!map.hasRegion(x, y) || !map.isBlocked(x, y, target.getPlane())) + { + return true; + } + } + } + return false; + } + + /** Nearest mapped standable tile to {@code target} within {@code maxRadius}, or {@code null}. */ + public static WorldPoint nearestWalkableTile(WorldPoint target, int maxRadius) + { + PathfinderConfig config = getPathfinderConfig(); + return nearestWalkableTile(config == null ? null : config.getMap(), target, maxRadius); + } + + /** Package-private collision seam for deterministic headless tests. */ + static WorldPoint nearestWalkableTile(CollisionMap map, WorldPoint target, int maxRadius) + { + if (map == null || target == null) + { + return null; + } + for (int radius = 1; radius <= Math.max(0, maxRadius); radius++) + { + for (int dx = -radius; dx <= radius; dx++) + { + for (int dy = -radius; dy <= radius; dy++) + { + if (Math.max(Math.abs(dx), Math.abs(dy)) != radius) + { + continue; + } + int x = target.getX() + dx; + int y = target.getY() + dy; + if (map.hasRegion(x, y) && !map.isBlocked(x, y, target.getPlane())) + { + return new WorldPoint(x, y, target.getPlane()); + } + } + } + } + return null; + } + + /** Refresh transport and restriction policy without exposing the mutable configuration. */ + public static boolean refreshPlanningConfiguration() + { + return refreshPlanningConfiguration(null); + } + + /** Refresh transport and restriction policy for an optional route target. */ + public static boolean refreshPlanningConfiguration(WorldPoint target) + { + PathfinderConfig config = getPathfinderConfig(); + if (config == null) + { + return false; + } + synchronized (getPathfinderMutex()) + { + config.refresh(target); + } + return true; + } + + /** Invalidate cached transport-policy snapshots so the next refresh observes external state. */ + public static boolean invalidateTransportRefreshCache() + { + PathfinderConfig config = getPathfinderConfig(); + if (config == null) + { + return false; + } + synchronized (getPathfinderMutex()) + { + config.invalidateTransportRefreshCache(); + } + return true; + } + + /** Record a stable walking edge failure in the planner's learned-block store. */ + public static boolean learnBlockedEdge(WorldPoint origin, WorldPoint destination, String reason) + { + PathfinderConfig config = getPathfinderConfig(); + if (config == null) + { + return false; + } + synchronized (getPathfinderMutex()) + { + return config.learnBlockedEdge(origin, destination, reason); + } + } + + /** Whether runtime recovery policy should avoid this dangerous-NPC adjacency tile. */ + public static boolean shouldAvoidDangerousTile(WorldPoint tile) + { + PathfinderConfig config = getPathfinderConfig(); + return config != null + && config.isAvoidDangerousNpcs() + && tile != null + && config.isDangerousAdjacentTile(WorldPointUtil.packWorldPoint(tile)); + } + + /** Whether the currently refreshed planning policy permits spirit-tree travel. */ + public static boolean isSpiritTreeTravelEnabled() + { + PathfinderConfig config = getPathfinderConfig(); + return config != null && config.isUseSpiritTrees(); + } + + /** Planner-consistent Wilderness classification without exposing its implementation class. */ + public static boolean isInWilderness(WorldPoint point) + { + return point != null && PathfinderConfig.isInWilderness(point); + } + + /** + * Whether {@code itemId} belongs to a currently known item-teleport requirement. + * + *

An empty catalog is refreshed under the lifecycle mutex before it is inspected. Additional + * compatibility IDs cover items such as fairy-ring staves that are not ordinary teleport rows.

+ */ + public static boolean isTeleportItem(int itemId, int... additionalItemIds) + { + if (additionalItemIds != null) + { + for (int additionalItemId : additionalItemIds) + { + if (itemId == additionalItemId) + { + return true; + } + } + } + PathfinderConfig config = getPathfinderConfig(); + if (config == null) + { + return false; + } + synchronized (getPathfinderMutex()) + { + if (config.getAllTransports().isEmpty()) + { + config.refresh(); + } + return config.getAllTransports().values().stream() + .flatMap(Set::stream) + .filter(transport -> TransportType.isTeleport( + transport.getType(), transport.getOrigin())) + .flatMap(transport -> transport.getItemIdRequirements().stream()) + .flatMap(Set::stream) + .anyMatch(requiredItemId -> requiredItemId == itemId); + } + } + + /** + * Switch the shared active-route policy from bank visibility to inventory/equipment visibility + * after required transport items have been withdrawn, then rebuild the target-specific catalog. + */ + public static boolean prepareInventoryOnlyRoute(WorldPoint target) + { + PathfinderConfig config = getPathfinderConfig(); + if (config == null) + { + return false; + } + synchronized (getPathfinderMutex()) + { + config.setUseBankItems(false); + config.refresh(target); + } + return true; + } + /** @return the shared pathfinder configuration (transports, restrictions, toggles). */ public static PathfinderConfig getPathfinderConfig() { @@ -172,7 +1908,115 @@ public static void setMarker(WorldMapPoint marker) // Transport data // ------------------------------------------------------------------ - /** @return the transport graph keyed by origin tile. */ + /** + * Whether the static catalog contains at least one transport keyed by {@code origin}. + * + *

This is the planner-independent query used by recovery and obstacle classification. Callers + * that only need transport presence must not consume the mutable concrete catalog.

+ */ + public static boolean hasCatalogTransportOrigin(WorldPoint origin) + { + return hasCatalogTransportOrigin(ShortestPathPlugin.getTransports(), origin); + } + + static boolean hasCatalogTransportOrigin( + Map> transports, WorldPoint origin) + { + if (transports == null || origin == null) + { + return false; + } + Set atOrigin = transports.get(origin); + return atOrigin != null && !atOrigin.isEmpty(); + } + + /** Whether the static catalog contains the exact directed {@code origin -> destination} edge. */ + public static boolean hasCatalogTransportEdge(WorldPoint origin, WorldPoint destination) + { + return hasCatalogTransportEdge(ShortestPathPlugin.getTransports(), origin, destination); + } + + static boolean hasCatalogTransportEdge( + Map> transports, + WorldPoint origin, + WorldPoint destination) + { + if (transports == null || origin == null || destination == null) + { + return false; + } + Set atOrigin = transports.get(origin); + if (atOrigin == null || atOrigin.isEmpty()) + { + return false; + } + return atOrigin.stream() + .filter(Objects::nonNull) + .anyMatch(transport -> destination.equals(transport.getDestination())); + } + + /** + * Immutable planner-independent descriptions of every catalog entry keyed by {@code origin}. + * + *

This is intended for catalog classification (for example distinguishing a door row from a + * ladder), not route execution. Runtime execution must use the exact edge selected in the active + * route so ambiguous same-endpoint transports cannot be rematched incorrectly.

+ */ + public static List getCatalogTransportEdges(WorldPoint origin) + { + if (origin == null) + { + return Collections.emptyList(); + } + Map> transports = ShortestPathPlugin.getTransports(); + Set source = transports == null ? null : transports.get(origin); + if (source == null || source.isEmpty()) + { + catalogEdgeSnapshots.remove(origin); + return Collections.emptyList(); + } + CatalogEdgeSnapshot cached = catalogEdgeSnapshots.get(origin); + if (cached != null && cached.source == source) + { + return cached.edges; + } + List edges = toCatalogTransportEdges(source); + catalogEdgeSnapshots.put(origin, new CatalogEdgeSnapshot(source, edges)); + return edges; + } + + static List getCatalogTransportEdges( + Map> transports, WorldPoint origin) + { + if (transports == null || origin == null) + { + return Collections.emptyList(); + } + return toCatalogTransportEdges(transports.get(origin)); + } + + private static List toCatalogTransportEdges(Set atOrigin) + { + if (atOrigin == null || atOrigin.isEmpty()) + { + return Collections.emptyList(); + } + List result = new ArrayList<>(atOrigin.size()); + for (Transport transport : atOrigin) + { + if (transport != null) + { + result.add(toTransportEdge(transport)); + } + } + return List.copyOf(result); + } + + /** + * Compatibility access to the concrete mutable transport graph. + * New code should use the named catalog queries above or exact immutable route steps. + */ + @Deprecated public static Map> getTransports() { return ShortestPathPlugin.getTransports(); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerCanaryPerformanceStats.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerCanaryPerformanceStats.java new file mode 100644 index 00000000000..fe990dfa63d --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerCanaryPerformanceStats.java @@ -0,0 +1,68 @@ +package net.runelite.client.plugins.microbot.util.walker; + +/** + * Coordinate-free process-lifetime timing aggregate for authoritative canary decisions. + * + *

The planning duration starts when an active route is submitted (or immediately before a + * synchronous/cave local search) and ends only after comparison, fallback/selection and upstream-route + * materialization have completed. It therefore measures when a route can actually become executable, + * rather than adding two independently reported engine search times after the fact.

+ */ +public final class Rs2PlannerCanaryPerformanceStats +{ + private final long planningSamples; + private final long planningNanosTotal; + private final long planningNanosMax; + private final long localSearchNanosTotal; + private final long localSearchNanosMax; + private final long upstreamSearchSamples; + private final long upstreamSearchNanosTotal; + private final long upstreamSearchNanosMax; + + Rs2PlannerCanaryPerformanceStats( + long planningSamples, + long planningNanosTotal, + long planningNanosMax, + long localSearchNanosTotal, + long localSearchNanosMax, + long upstreamSearchSamples, + long upstreamSearchNanosTotal, + long upstreamSearchNanosMax) + { + this.planningSamples = requireNonNegative(planningSamples, "planningSamples"); + this.planningNanosTotal = requireNonNegative(planningNanosTotal, "planningNanosTotal"); + this.planningNanosMax = requireNonNegative(planningNanosMax, "planningNanosMax"); + this.localSearchNanosTotal = requireNonNegative( + localSearchNanosTotal, "localSearchNanosTotal"); + this.localSearchNanosMax = requireNonNegative(localSearchNanosMax, "localSearchNanosMax"); + this.upstreamSearchSamples = requireNonNegative( + upstreamSearchSamples, "upstreamSearchSamples"); + this.upstreamSearchNanosTotal = requireNonNegative( + upstreamSearchNanosTotal, "upstreamSearchNanosTotal"); + this.upstreamSearchNanosMax = requireNonNegative( + upstreamSearchNanosMax, "upstreamSearchNanosMax"); + if (upstreamSearchSamples > planningSamples) + { + throw new IllegalArgumentException( + "upstream search samples cannot exceed canary planning samples"); + } + } + + private static long requireNonNegative(long value, String name) + { + if (value < 0L) + { + throw new IllegalArgumentException(name + " must be non-negative"); + } + return value; + } + + public long getPlanningSamples() { return planningSamples; } + public long getPlanningNanosTotal() { return planningNanosTotal; } + public long getPlanningNanosMax() { return planningNanosMax; } + public long getLocalSearchNanosTotal() { return localSearchNanosTotal; } + public long getLocalSearchNanosMax() { return localSearchNanosMax; } + public long getUpstreamSearchSamples() { return upstreamSearchSamples; } + public long getUpstreamSearchNanosTotal() { return upstreamSearchNanosTotal; } + public long getUpstreamSearchNanosMax() { return upstreamSearchNanosMax; } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerShadowComparison.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerShadowComparison.java new file mode 100644 index 00000000000..7b98e1d0531 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerShadowComparison.java @@ -0,0 +1,118 @@ +package net.runelite.client.plugins.microbot.util.walker; + +/** Immutable summary of the latest completed production shadow comparison. */ +public final class Rs2PlannerShadowComparison +{ + public enum Status + { + MATCH, + DIVERGENCE, + FAILED + } + + private final Status status; + private final String shadowEngineId; + private final Rs2PlannerShadowContext context; + private final boolean terminationMatches; + private final boolean endpointMatches; + private final boolean costComparable; + private final boolean costMatches; + private final boolean selectedTransportsMatch; + private final boolean pathMatches; + private final long shadowSearchNanos; + private final long localSearchNanos; + private final String failureType; + + private Rs2PlannerShadowComparison( + Status status, + String shadowEngineId, + Rs2PlannerShadowContext context, + boolean terminationMatches, + boolean endpointMatches, + boolean costComparable, + boolean costMatches, + boolean selectedTransportsMatch, + boolean pathMatches, + long shadowSearchNanos, + long localSearchNanos, + String failureType) + { + this.status = status; + this.shadowEngineId = shadowEngineId; + this.context = context; + this.terminationMatches = terminationMatches; + this.endpointMatches = endpointMatches; + this.costComparable = costComparable; + this.costMatches = costMatches; + this.selectedTransportsMatch = selectedTransportsMatch; + this.pathMatches = pathMatches; + this.shadowSearchNanos = shadowSearchNanos; + this.localSearchNanos = localSearchNanos; + this.failureType = failureType; + } + + static Rs2PlannerShadowComparison compare( + String engineId, + Rs2PlannerShadowContext context, + Rs2RouteResult local, + Rs2RouteResult shadow) + { + boolean termination = local.getTerminationReason() == shadow.getTerminationReason(); + boolean endpoint = local.getEndpoint().equals(shadow.getEndpoint()); + boolean comparable = local.getMetrics().hasPathCost() && shadow.getMetrics().hasPathCost(); + boolean cost = comparable + && local.getMetrics().getPathCost() == shadow.getMetrics().getPathCost(); + boolean transports = sameSelectedTransports(local, shadow); + boolean path = local.getPath().equals(shadow.getPath()); + Status status = termination && endpoint && comparable && cost && transports + ? Status.MATCH : Status.DIVERGENCE; + return new Rs2PlannerShadowComparison( + status, engineId, context, termination, endpoint, comparable, cost, transports, path, + shadow.getMetrics().getSearchNanos(), local.getMetrics().getSearchNanos(), null); + } + + static Rs2PlannerShadowComparison failed( + String engineId, Rs2PlannerShadowContext context, Rs2RouteResult local, + RuntimeException failure) + { + return new Rs2PlannerShadowComparison( + Status.FAILED, engineId, context, false, false, false, false, false, false, + Rs2RouteMetrics.UNAVAILABLE, local.getMetrics().getSearchNanos(), + failure.getClass().getSimpleName()); + } + + private static boolean sameSelectedTransports(Rs2RouteResult local, Rs2RouteResult shadow) + { + java.util.List localSteps = local.getTransportSteps(); + java.util.List shadowSteps = shadow.getTransportSteps(); + if (localSteps.size() != shadowSteps.size()) + { + return false; + } + for (int i = 0; i < localSteps.size(); i++) + { + Rs2TransportEdge localEdge = localSteps.get(i).getTransport().orElseThrow( + IllegalStateException::new); + Rs2TransportEdge shadowEdge = shadowSteps.get(i).getTransport().orElseThrow( + IllegalStateException::new); + if (localEdge.getSourceIdentity() != shadowEdge.getSourceIdentity()) + { + return false; + } + } + return true; + } + + public Status getStatus() { return status; } + public String getShadowEngineId() { return shadowEngineId; } + public Rs2PlannerShadowContext getContext() { return context; } + public boolean isTerminationMatches() { return terminationMatches; } + public boolean isEndpointMatches() { return endpointMatches; } + public boolean isCostComparable() { return costComparable; } + public boolean isCostMatches() { return costMatches; } + public boolean isSelectedTransportsMatch() { return selectedTransportsMatch; } + public boolean isPathMatches() { return pathMatches; } + public long getShadowSearchNanos() { return shadowSearchNanos; } + public long getLocalSearchNanos() { return localSearchNanos; } + public String getFailureType() { return failureType; } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerShadowContext.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerShadowContext.java new file mode 100644 index 00000000000..e38a416ed33 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerShadowContext.java @@ -0,0 +1,203 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.Objects; +import java.util.Set; + +/** Coordinate-free route classification attached to one production shadow comparison. */ +public final class Rs2PlannerShadowContext +{ + public enum Invocation + { + SYNCHRONOUS_QUERY, + ACTIVE_ROUTE, + ACTIVE_REPLAN, + RECOVERY_REPLAN + } + + public enum Coverage + { + SYNCHRONOUS_QUERY, + ACTIVE_ROUTE, + ACTIVE_REPLAN, + RECOVERY_REPLAN, + MEMBERS_WORLD_POLICY, + SURFACE_COORDINATES_ONLY, + UNDERGROUND_COORDINATES, + WALKING_ONLY_SELECTED, + USES_TRANSPORT, + SELECTS_MEMBERS_TRANSPORT, + SELECTS_ITEM_GATED_TRANSPORT, + SELECTS_SKILL_GATED_TRANSPORT, + SELECTS_QUEST_GATED_TRANSPORT, + SELECTS_STATE_GATED_TRANSPORT, + SELECTS_NON_ITEM_REQUIREMENT_GATED_TRANSPORT, + BANK_ITEMS_ENABLED, + BANK_ROUTE_DIRECT, + BANK_ROUTE_TO_BANK, + BANK_ROUTE_FROM_BANK, + BANK_ROUTE_FROM_BANK_SELECTS_ITEM_GATED_TRANSPORT, + LIVE_COLLISION_ENABLED, + LIVE_COLLISION_CONSULTED + } + + private static final int UNDERGROUND_Y = 6400; + + private final Invocation invocation; + private final Set coverage; + private final Set transportExecutors; + private final Set transportTypes; + + private Rs2PlannerShadowContext( + Invocation invocation, + Set coverage, + Set transportExecutors, + Set transportTypes) + { + this.invocation = Objects.requireNonNull(invocation, "invocation"); + this.coverage = Collections.unmodifiableSet(EnumSet.copyOf(coverage)); + this.transportExecutors = Collections.unmodifiableSet( + EnumSet.copyOf(transportExecutors)); + this.transportTypes = Collections.unmodifiableSet(EnumSet.copyOf(transportTypes)); + } + + static Rs2PlannerShadowContext from( + Invocation invocation, + boolean walkingOnlySelected, + Rs2RouteRequest request, + Rs2RouteResult local) + { + Objects.requireNonNull(request, "request"); + Objects.requireNonNull(local, "local"); + Rs2RoutePolicy policy = request.getPolicy().orElseThrow( + () -> new IllegalArgumentException("shadow context requires resolved policy")); + EnumSet coverage = EnumSet.noneOf(Coverage.class); + switch (invocation) + { + case SYNCHRONOUS_QUERY: coverage.add(Coverage.SYNCHRONOUS_QUERY); break; + case ACTIVE_ROUTE: coverage.add(Coverage.ACTIVE_ROUTE); break; + case ACTIVE_REPLAN: coverage.add(Coverage.ACTIVE_REPLAN); break; + case RECOVERY_REPLAN: coverage.add(Coverage.RECOVERY_REPLAN); break; + default: throw new IllegalStateException("unhandled invocation " + invocation); + } + boolean underground = isUnderground(request.getStart()) + || request.getTargets().stream().anyMatch(Rs2PlannerShadowContext::isUnderground) + || local.getPath().stream().anyMatch(Rs2PlannerShadowContext::isUnderground); + coverage.add(underground + ? Coverage.UNDERGROUND_COORDINATES : Coverage.SURFACE_COORDINATES_ONLY); + if (walkingOnlySelected) + { + coverage.add(Coverage.WALKING_ONLY_SELECTED); + } + EnumSet transportExecutors = + EnumSet.noneOf(Rs2TransportExecutor.class); + EnumSet transportTypes = EnumSet.noneOf(Rs2TransportType.class); + boolean itemGatedTransport = false; + boolean membersTransport = false; + boolean skillGatedTransport = false; + boolean questGatedTransport = false; + boolean stateGatedTransport = false; + for (Rs2RouteStep step : local.getTransportSteps()) + { + Rs2TransportEdge edge = step.getTransport().orElseThrow(IllegalStateException::new); + transportExecutors.add(edge.getExecutor()); + transportTypes.add(edge.getType()); + itemGatedTransport |= !edge.getItemRequirements().isEmpty() + || edge.getCurrencyAmount() > 0; + membersTransport |= edge.isMembers(); + skillGatedTransport |= edge.isSkillGated(); + questGatedTransport |= edge.isQuestGated(); + stateGatedTransport |= edge.isStateGated(); + } + if (policy.isMembersWorld()) + { + coverage.add(Coverage.MEMBERS_WORLD_POLICY); + } + if (!transportExecutors.isEmpty()) + { + coverage.add(Coverage.USES_TRANSPORT); + } + if (itemGatedTransport) + { + coverage.add(Coverage.SELECTS_ITEM_GATED_TRANSPORT); + } + if (membersTransport) + { + coverage.add(Coverage.SELECTS_MEMBERS_TRANSPORT); + } + if (skillGatedTransport) + { + coverage.add(Coverage.SELECTS_SKILL_GATED_TRANSPORT); + } + if (questGatedTransport) + { + coverage.add(Coverage.SELECTS_QUEST_GATED_TRANSPORT); + } + if (stateGatedTransport) + { + coverage.add(Coverage.SELECTS_STATE_GATED_TRANSPORT); + } + if (skillGatedTransport || questGatedTransport || stateGatedTransport) + { + coverage.add(Coverage.SELECTS_NON_ITEM_REQUIREMENT_GATED_TRANSPORT); + } + if (policy.isUseBankItems()) + { + coverage.add(Coverage.BANK_ITEMS_ENABLED); + } + switch (request.getPurpose()) + { + case GENERAL: break; + case BANK_ROUTE_DIRECT: coverage.add(Coverage.BANK_ROUTE_DIRECT); break; + case BANK_ROUTE_TO_BANK: coverage.add(Coverage.BANK_ROUTE_TO_BANK); break; + case BANK_ROUTE_FROM_BANK: + coverage.add(Coverage.BANK_ROUTE_FROM_BANK); + if (itemGatedTransport) + { + coverage.add(Coverage.BANK_ROUTE_FROM_BANK_SELECTS_ITEM_GATED_TRANSPORT); + } + break; + default: throw new IllegalStateException( + "unhandled route request purpose " + request.getPurpose()); + } + if (policy.isLiveCollisionEnabled()) + { + coverage.add(Coverage.LIVE_COLLISION_ENABLED); + } + if (local.getMetrics().hasLiveCollisionEdgesChecked() + && local.getMetrics().getLiveCollisionEdgesChecked() > 0L) + { + coverage.add(Coverage.LIVE_COLLISION_CONSULTED); + } + return new Rs2PlannerShadowContext( + invocation, coverage, transportExecutors, transportTypes); + } + + private static boolean isUnderground(WorldPoint point) + { + return point != null && point.getY() >= UNDERGROUND_Y; + } + + public Invocation getInvocation() + { + return invocation; + } + + public Set getCoverage() + { + return coverage; + } + + public Set getTransportExecutors() + { + return transportExecutors; + } + + public Set getTransportTypes() + { + return transportTypes; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerShadowCoverageStats.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerShadowCoverageStats.java new file mode 100644 index 00000000000..766e25d179c --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerShadowCoverageStats.java @@ -0,0 +1,32 @@ +package net.runelite.client.plugins.microbot.util.walker; + +/** Immutable outcome counters for one overlapping shadow-evidence coverage tag. */ +public final class Rs2PlannerShadowCoverageStats +{ + private final long completed; + private final long matches; + private final long divergences; + private final long failures; + + Rs2PlannerShadowCoverageStats(long matches, long divergences, long failures) + { + this.matches = requireNonNegative(matches, "matches"); + this.divergences = requireNonNegative(divergences, "divergences"); + this.failures = requireNonNegative(failures, "failures"); + this.completed = Math.addExact(Math.addExact(matches, divergences), failures); + } + + private static long requireNonNegative(long value, String name) + { + if (value < 0) + { + throw new IllegalArgumentException(name + " must be non-negative"); + } + return value; + } + + public long getCompleted() { return completed; } + public long getMatches() { return matches; } + public long getDivergences() { return divergences; } + public long getFailures() { return failures; } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerShadowStats.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerShadowStats.java new file mode 100644 index 00000000000..8d849350673 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerShadowStats.java @@ -0,0 +1,125 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.Map; + +/** Immutable process-lifetime aggregate for production planner shadow evidence. */ +public final class Rs2PlannerShadowStats +{ + private final long submitted; + private final long completed; + private final long matches; + private final long divergences; + private final long failures; + private final long staleResults; + private final long discarded; + private final long routeShapeDifferences; + private final long upstreamCanarySelections; + private final long localFallbackDivergences; + private final long localFallbackFailures; + private final long startedAtEpochMillis; + private final Map coverage; + private final Map transportExecutors; + private final Map transportTypes; + private final Rs2WalkerShadowExecutionStats execution; + private final Rs2PlannerCanaryPerformanceStats canaryPerformance; + + Rs2PlannerShadowStats( + long submitted, + long completed, + long matches, + long divergences, + long failures, + long staleResults, + long discarded, + long routeShapeDifferences, + long upstreamCanarySelections, + long localFallbackDivergences, + long localFallbackFailures, + long startedAtEpochMillis, + Map coverage, + Map transportExecutors, + Map transportTypes, + Rs2WalkerShadowExecutionStats execution, + Rs2PlannerCanaryPerformanceStats canaryPerformance) + { + this.submitted = requireNonNegative(submitted, "submitted"); + this.completed = requireNonNegative(completed, "completed"); + this.matches = requireNonNegative(matches, "matches"); + this.divergences = requireNonNegative(divergences, "divergences"); + this.failures = requireNonNegative(failures, "failures"); + this.staleResults = requireNonNegative(staleResults, "staleResults"); + this.discarded = requireNonNegative(discarded, "discarded"); + this.routeShapeDifferences = requireNonNegative( + routeShapeDifferences, "routeShapeDifferences"); + this.upstreamCanarySelections = requireNonNegative( + upstreamCanarySelections, "upstreamCanarySelections"); + this.localFallbackDivergences = requireNonNegative( + localFallbackDivergences, "localFallbackDivergences"); + this.localFallbackFailures = requireNonNegative( + localFallbackFailures, "localFallbackFailures"); + this.startedAtEpochMillis = requireNonNegative(startedAtEpochMillis, "startedAtEpochMillis"); + EnumMap copy = + new EnumMap<>(Rs2PlannerShadowContext.Coverage.class); + copy.putAll(coverage); + this.coverage = Collections.unmodifiableMap(copy); + EnumMap executorCopy = + new EnumMap<>(Rs2TransportExecutor.class); + executorCopy.putAll(transportExecutors); + this.transportExecutors = Collections.unmodifiableMap(executorCopy); + EnumMap typeCopy = + new EnumMap<>(Rs2TransportType.class); + typeCopy.putAll(transportTypes); + this.transportTypes = Collections.unmodifiableMap(typeCopy); + this.execution = java.util.Objects.requireNonNull(execution, "execution"); + this.canaryPerformance = java.util.Objects.requireNonNull( + canaryPerformance, "canaryPerformance"); + if (matches + divergences + failures != completed) + { + throw new IllegalArgumentException( + "completed comparisons must equal matches, divergences and failures"); + } + } + + private static long requireNonNegative(long value, String name) + { + if (value < 0) + { + throw new IllegalArgumentException(name + " must be non-negative"); + } + return value; + } + + public long getSubmitted() { return submitted; } + public long getCompleted() { return completed; } + public long getMatches() { return matches; } + public long getDivergences() { return divergences; } + public long getFailures() { return failures; } + public long getStaleResults() { return staleResults; } + public long getDiscarded() { return discarded; } + public long getRouteShapeDifferences() { return routeShapeDifferences; } + public long getUpstreamCanarySelections() { return upstreamCanarySelections; } + public long getLocalFallbackDivergences() { return localFallbackDivergences; } + public long getLocalFallbackFailures() { return localFallbackFailures; } + public long getStartedAtEpochMillis() { return startedAtEpochMillis; } + public Map getCoverage() + { + return coverage; + } + public Map getTransportExecutors() + { + return transportExecutors; + } + public Map getTransportTypes() + { + return transportTypes; + } + public Rs2WalkerShadowExecutionStats getExecution() { return execution; } + public Rs2PlannerCanaryPerformanceStats getCanaryPerformance() { return canaryPerformance; } + + public long getPending() + { + return Math.max(0L, submitted - completed - discarded); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlanningSnapshot.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlanningSnapshot.java new file mode 100644 index 00000000000..baca095994e --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlanningSnapshot.java @@ -0,0 +1,141 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.function.IntPredicate; + +/** + * Immutable engine-neutral graph inputs captured after Microbot has resolved runtime admission. + * + *

The static collision archive is pinned separately and shared by both engines. This value owns the + * per-search overlays and already-filtered exact transport catalog, so an engine never reads mutable + * Microbot plugin state while searching.

+ */ +public final class Rs2PlanningSnapshot +{ + @FunctionalInterface + public interface CollisionOverride + { + /** Known north/east edge value, or {@code null} to fall back to pinned static collision. */ + Boolean edge(int x, int y, int plane, int flag); + } + + private static final int DANGEROUS_TILE_PENALTY = 100; + private static final CollisionOverride NO_COLLISION_OVERRIDE = (x, y, plane, flag) -> null; + + private final Rs2RoutePolicy policy; + private final List admittedTransports; + private final CollisionOverride collisionOverride; + private final Set blockedWalkingEdges; + private final IntPredicate dangerousTilePredicate; + + Rs2PlanningSnapshot( + Rs2RoutePolicy policy, + List admittedTransports, + CollisionOverride collisionOverride, + Set blockedWalkingEdges, + IntPredicate dangerousTilePredicate) + { + this.policy = Objects.requireNonNull(policy, "policy"); + this.admittedTransports = List.copyOf(admittedTransports); + this.collisionOverride = collisionOverride == null + ? NO_COLLISION_OVERRIDE : collisionOverride; + this.blockedWalkingEdges = Collections.unmodifiableSet( + new LinkedHashSet<>(blockedWalkingEdges)); + this.dangerousTilePredicate = Objects.requireNonNull( + dangerousTilePredicate, "dangerousTilePredicate"); + } + + public Rs2RoutePolicy getPolicy() + { + return policy; + } + + public List getAdmittedTransports() + { + return admittedTransports; + } + + public Boolean collisionOverride(int x, int y, int plane, int flag) + { + return collisionOverride.edge(x, y, plane, flag); + } + + public boolean isWalkingEdgeBlocked(int originPacked, int destinationPacked) + { + if (blockedWalkingEdges.isEmpty()) + { + return false; + } + if (blockedWalkingEdges.contains(edgeKey(originPacked, destinationPacked))) + { + return true; + } + int ox = unpackX(originPacked); + int oy = unpackY(originPacked); + int oz = unpackPlane(originPacked); + int dx = Integer.signum(unpackX(destinationPacked) - ox); + int dy = Integer.signum(unpackY(destinationPacked) - oy); + if (unpackPlane(destinationPacked) != oz || dx == 0 || dy == 0) + { + return false; + } + int xThenY = pack(ox + dx, oy, oz); + int yThenX = pack(ox, oy + dy, oz); + return blockedWalkingEdges.contains(edgeKey(originPacked, xThenY)) + || blockedWalkingEdges.contains(edgeKey(xThenY, destinationPacked)) + || blockedWalkingEdges.contains(edgeKey(originPacked, yThenX)) + || blockedWalkingEdges.contains(edgeKey(yThenX, destinationPacked)); + } + + public int getAdditionalWalkingCost(int packedDestination, Set targets) + { + if (!policy.isAvoidDangerousNpcs() || containsPacked(targets, packedDestination)) + { + return 0; + } + return dangerousTilePredicate.test(packedDestination) ? DANGEROUS_TILE_PENALTY : 0; + } + + private static boolean containsPacked(Set points, int packed) + { + for (WorldPoint point : points) + { + if (pack(point.getX(), point.getY(), point.getPlane()) == packed) + { + return true; + } + } + return false; + } + + private static long edgeKey(int originPacked, int destinationPacked) + { + return ((long) originPacked << 32) ^ (destinationPacked & 0xffffffffL); + } + + private static int pack(int x, int y, int plane) + { + return (x & 0x7FFF) | ((y & 0x7FFF) << 15) | ((plane & 0x3) << 30); + } + + private static int unpackX(int packed) + { + return packed & 0x7FFF; + } + + private static int unpackY(int packed) + { + return (packed >> 15) & 0x7FFF; + } + + private static int unpackPlane(int packed) + { + return (packed >> 30) & 0x3; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RouteMetrics.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RouteMetrics.java new file mode 100644 index 00000000000..ef63e28a844 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RouteMetrics.java @@ -0,0 +1,105 @@ +package net.runelite.client.plugins.microbot.util.walker; + +/** + * Immutable, planner-independent measurements for one route calculation. + * + *

A value of {@link #UNAVAILABLE} means an engine cannot expose that measurement without + * changing its search contract. Keeping that distinction explicit prevents the comparison harness + * from silently treating missing data as zero.

+ */ +public final class Rs2RouteMetrics +{ + public static final long UNAVAILABLE = -1L; + + private final long searchNanos; + private final long pathCost; + private final long nodesChecked; + private final long transportsChecked; + private final long liveCollisionEdgesChecked; + + Rs2RouteMetrics( + long searchNanos, + long pathCost, + long nodesChecked, + long transportsChecked) + { + this(searchNanos, pathCost, nodesChecked, transportsChecked, UNAVAILABLE); + } + + Rs2RouteMetrics( + long searchNanos, + long pathCost, + long nodesChecked, + long transportsChecked, + long liveCollisionEdgesChecked) + { + validateOptionalMetric("searchNanos", searchNanos); + validateOptionalMetric("pathCost", pathCost); + validateOptionalMetric("nodesChecked", nodesChecked); + validateOptionalMetric("transportsChecked", transportsChecked); + validateOptionalMetric("liveCollisionEdgesChecked", liveCollisionEdgesChecked); + this.searchNanos = searchNanos; + this.pathCost = pathCost; + this.nodesChecked = nodesChecked; + this.transportsChecked = transportsChecked; + this.liveCollisionEdgesChecked = liveCollisionEdgesChecked; + } + + private static void validateOptionalMetric(String name, long value) + { + if (value < 0 && value != UNAVAILABLE) + { + throw new IllegalArgumentException(name + " must be non-negative or UNAVAILABLE"); + } + } + + public long getSearchNanos() + { + return searchNanos; + } + + public boolean hasSearchNanos() + { + return searchNanos != UNAVAILABLE; + } + + public long getPathCost() + { + return pathCost; + } + + public boolean hasPathCost() + { + return pathCost != UNAVAILABLE; + } + + public long getNodesChecked() + { + return nodesChecked; + } + + public boolean hasNodesChecked() + { + return nodesChecked != UNAVAILABLE; + } + + public long getTransportsChecked() + { + return transportsChecked; + } + + public boolean hasTransportsChecked() + { + return transportsChecked != UNAVAILABLE; + } + + public long getLiveCollisionEdgesChecked() + { + return liveCollisionEdgesChecked; + } + + public boolean hasLiveCollisionEdgesChecked() + { + return liveCollisionEdgesChecked != UNAVAILABLE; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RoutePlanner.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RoutePlanner.java new file mode 100644 index 00000000000..1fe39222da6 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RoutePlanner.java @@ -0,0 +1,14 @@ +package net.runelite.client.plugins.microbot.util.walker; + +/** Interchangeable route-engine boundary used after Microbot resolves an immutable request policy. */ +public interface Rs2RoutePlanner +{ + /** Stable diagnostic id such as {@code microbot-local} or a pinned upstream revision. */ + String getEngineId(); + + /** + * Calculate one route. Implementations must reject requests without a resolved policy rather than + * consulting mutable Microbot or plugin globals. + */ + Rs2RouteResult plan(Rs2RouteRequest request, Rs2PlanningSnapshot snapshot); +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RoutePolicy.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RoutePolicy.java new file mode 100644 index 00000000000..379886f3b0a --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RoutePolicy.java @@ -0,0 +1,108 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; + +/** + * Immutable, engine-neutral policy resolved for one route calculation. + * + *

Graph data and the engine's static collision representation remain engine inputs; every mutable + * Microbot routing choice that can change admission or search behavior is copied here before dispatch. + * An upstream adapter must consume this value rather than reading {@code ShortestPathPlugin} globals.

+ */ +public final class Rs2RoutePolicy +{ + public enum TeleportationItemMode + { + NONE, + INVENTORY, + INVENTORY_NON_CONSUMABLE + } + + private final boolean useBankItems; + private final boolean avoidWilderness; + private final boolean avoidDangerousNpcs; + private final boolean ignoreTeleportAndItems; + private final boolean teleportsDisabled; + private final boolean membersWorld; + private final boolean liveCollisionEnabled; + private final long calculationCutoffMillis; + private final int distanceBeforeUsingTeleport; + private final TeleportationItemMode teleportationItemMode; + private final Set enabledTransportTypes; + private final Set restrictedPoints; + + public Rs2RoutePolicy( + boolean useBankItems, + boolean avoidWilderness, + boolean avoidDangerousNpcs, + boolean ignoreTeleportAndItems, + boolean teleportsDisabled, + boolean membersWorld, + boolean liveCollisionEnabled, + long calculationCutoffMillis, + int distanceBeforeUsingTeleport, + TeleportationItemMode teleportationItemMode, + Set enabledTransportTypes, + Set restrictedPoints) + { + if (calculationCutoffMillis <= 0) + { + throw new IllegalArgumentException("calculationCutoffMillis must be positive"); + } + if (distanceBeforeUsingTeleport < 0) + { + throw new IllegalArgumentException("distanceBeforeUsingTeleport must be non-negative"); + } + this.useBankItems = useBankItems; + this.avoidWilderness = avoidWilderness; + this.avoidDangerousNpcs = avoidDangerousNpcs; + this.ignoreTeleportAndItems = ignoreTeleportAndItems; + this.teleportsDisabled = teleportsDisabled; + this.membersWorld = membersWorld; + this.liveCollisionEnabled = liveCollisionEnabled; + this.calculationCutoffMillis = calculationCutoffMillis; + this.distanceBeforeUsingTeleport = distanceBeforeUsingTeleport; + this.teleportationItemMode = Objects.requireNonNull( + teleportationItemMode, "teleportationItemMode"); + Objects.requireNonNull(enabledTransportTypes, "enabledTransportTypes"); + EnumSet enabledCopy = enabledTransportTypes.isEmpty() + ? EnumSet.noneOf(Rs2TransportType.class) + : EnumSet.copyOf(enabledTransportTypes); + this.enabledTransportTypes = Collections.unmodifiableSet(enabledCopy); + Objects.requireNonNull(restrictedPoints, "restrictedPoints"); + LinkedHashSet restrictionCopy = new LinkedHashSet<>(); + for (WorldPoint point : restrictedPoints) + { + restrictionCopy.add(Objects.requireNonNull(point, "restricted point")); + } + this.restrictedPoints = Collections.unmodifiableSet(restrictionCopy); + } + + public Rs2RoutePolicy withUseBankItems(boolean enabled) + { + return new Rs2RoutePolicy( + enabled, avoidWilderness, avoidDangerousNpcs, ignoreTeleportAndItems, + teleportsDisabled, membersWorld, liveCollisionEnabled, calculationCutoffMillis, + distanceBeforeUsingTeleport, teleportationItemMode, enabledTransportTypes, + restrictedPoints); + } + + public boolean isUseBankItems() { return useBankItems; } + public boolean isAvoidWilderness() { return avoidWilderness; } + public boolean isAvoidDangerousNpcs() { return avoidDangerousNpcs; } + public boolean isIgnoreTeleportAndItems() { return ignoreTeleportAndItems; } + public boolean isTeleportsDisabled() { return teleportsDisabled; } + public boolean isMembersWorld() { return membersWorld; } + public boolean isLiveCollisionEnabled() { return liveCollisionEnabled; } + public long getCalculationCutoffMillis() { return calculationCutoffMillis; } + public int getDistanceBeforeUsingTeleport() { return distanceBeforeUsingTeleport; } + public TeleportationItemMode getTeleportationItemMode() { return teleportationItemMode; } + public Set getEnabledTransportTypes() { return enabledTransportTypes; } + public Set getRestrictedPoints() { return restrictedPoints; } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RouteRequest.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RouteRequest.java new file mode 100644 index 00000000000..d50f7d08e5f --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RouteRequest.java @@ -0,0 +1,159 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * Immutable Microbot-owned input for a synchronous route calculation. + * + *

The request deliberately describes policy without exposing {@code PathfinderConfig}. New + * synchronous planning consumers should use this value through {@link Rs2PathApi#plan(Rs2RouteRequest)} + * instead of constructing a shortest-path {@code Pathfinder} directly.

+ */ +public final class Rs2RouteRequest +{ + /** Caller-owned workflow intent used only to classify planner evidence. */ + public enum Purpose + { + GENERAL, + BANK_ROUTE_DIRECT, + BANK_ROUTE_TO_BANK, + BANK_ROUTE_FROM_BANK + } + + public enum RefreshPolicy + { + NEVER, + IF_TRANSPORTS_EMPTY, + ALWAYS + } + + private final WorldPoint start; + private final Set targets; + private final RefreshPolicy refreshPolicy; + private final WorldPoint refreshTarget; + private final Boolean useBankItems; + private final Rs2RoutePolicy policy; + private final Purpose purpose; + + private Rs2RouteRequest( + WorldPoint start, + Set targets, + RefreshPolicy refreshPolicy, + WorldPoint refreshTarget, + Boolean useBankItems, + Rs2RoutePolicy policy, + Purpose purpose) + { + this.start = Objects.requireNonNull(start, "start"); + Objects.requireNonNull(targets, "targets"); + if (targets.isEmpty()) + { + throw new IllegalArgumentException("targets must not be empty"); + } + LinkedHashSet targetCopy = new LinkedHashSet<>(); + for (WorldPoint target : targets) + { + targetCopy.add(Objects.requireNonNull(target, "target")); + } + this.targets = Collections.unmodifiableSet(targetCopy); + this.refreshPolicy = Objects.requireNonNull(refreshPolicy, "refreshPolicy"); + this.refreshTarget = refreshTarget; + this.useBankItems = useBankItems; + this.policy = policy; + this.purpose = Objects.requireNonNull(purpose, "purpose"); + } + + public static Rs2RouteRequest to(WorldPoint start, WorldPoint target) + { + return toAny(start, Collections.singleton(target)); + } + + public static Rs2RouteRequest toAny(WorldPoint start, Set targets) + { + return new Rs2RouteRequest( + start, targets, RefreshPolicy.IF_TRANSPORTS_EMPTY, null, null, null, + Purpose.GENERAL); + } + + public Rs2RouteRequest withRefreshPolicy(RefreshPolicy policy) + { + return new Rs2RouteRequest( + start, targets, policy, refreshTarget, useBankItems, this.policy, purpose); + } + + public Rs2RouteRequest withRefreshTarget(WorldPoint target) + { + return new Rs2RouteRequest( + start, targets, refreshPolicy, target, useBankItems, policy, purpose); + } + + /** + * Include or exclude bank contents while evaluating transport requirements. Planning with this + * temporary policy always refreshes the transport snapshot and restores the previous policy after + * the search. + */ + public Rs2RouteRequest withBankItems(boolean enabled) + { + return new Rs2RouteRequest( + start, targets, RefreshPolicy.ALWAYS, refreshTarget, enabled, + policy == null ? null : policy.withUseBankItems(enabled), purpose); + } + + /** Classify this request without changing planning behavior. */ + public Rs2RouteRequest withPurpose(Purpose purpose) + { + return new Rs2RouteRequest( + start, targets, refreshPolicy, refreshTarget, useBankItems, policy, + Objects.requireNonNull(purpose, "purpose")); + } + + /** Attach the fully resolved policy passed to an engine implementation. */ + public Rs2RouteRequest withPolicy(Rs2RoutePolicy resolvedPolicy) + { + Rs2RoutePolicy nonNullPolicy = Objects.requireNonNull(resolvedPolicy, "resolvedPolicy"); + return new Rs2RouteRequest( + start, targets, refreshPolicy, refreshTarget, + nonNullPolicy.isUseBankItems(), nonNullPolicy, purpose); + } + + public WorldPoint getStart() + { + return start; + } + + public Set getTargets() + { + return targets; + } + + public RefreshPolicy getRefreshPolicy() + { + return refreshPolicy; + } + + public WorldPoint getRefreshTarget() + { + return refreshTarget; + } + + public Boolean getUseBankItems() + { + return useBankItems; + } + + public Optional getPolicy() + { + return Optional.ofNullable(policy); + } + + public Purpose getPurpose() + { + return purpose; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RouteResult.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RouteResult.java new file mode 100644 index 00000000000..cfd2f82e29b --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RouteResult.java @@ -0,0 +1,185 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** Immutable result of a synchronous Microbot route calculation. */ +public final class Rs2RouteResult +{ + private final WorldPoint start; + private final Set targets; + private final List path; + private final List steps; + private final Map> transportEdgesByEndpoints; + private final Rs2RouteTermination terminationReason; + private final Rs2RouteMetrics metrics; + + Rs2RouteResult( + WorldPoint start, + Set targets, + List path, + List steps, + Rs2RouteTermination terminationReason, + Rs2RouteMetrics metrics) + { + this.start = start; + this.targets = Collections.unmodifiableSet(new LinkedHashSet<>(targets)); + this.path = path == null ? Collections.emptyList() : List.copyOf(path); + this.steps = steps == null ? Collections.emptyList() : List.copyOf(steps); + validateSteps(this.path, this.steps); + this.transportEdgesByEndpoints = indexTransportEdges(this.steps); + this.terminationReason = Objects.requireNonNull(terminationReason, "terminationReason"); + this.metrics = Objects.requireNonNull(metrics, "metrics"); + } + + public WorldPoint getStart() + { + return start; + } + + public Set getTargets() + { + return targets; + } + + public List getPath() + { + return path; + } + + public List getSteps() + { + return steps; + } + + public List getTransportSteps() + { + List transportSteps = new ArrayList<>(); + for (Rs2RouteStep step : steps) + { + if (step.isTransport()) + { + transportSteps.add(step); + } + } + return List.copyOf(transportSteps); + } + + /** Exact selected transport for a directed route edge, if that edge is a transport. */ + public Optional getTransportEdge(WorldPoint from, WorldPoint to) + { + if (from == null || to == null) + { + return Optional.empty(); + } + Map byDestination = transportEdgesByEndpoints.get(from); + return byDestination == null + ? Optional.empty() + : Optional.ofNullable(byDestination.get(to)); + } + + /** Whether the search ended normally rather than through cancellation or an internal failure. */ + public boolean isSearchCompleted() + { + return terminationReason != Rs2RouteTermination.CANCELLED + && terminationReason != Rs2RouteTermination.FAILED; + } + + public Rs2RouteTermination getTerminationReason() + { + return terminationReason; + } + + public long getSearchNanos() + { + return metrics.getSearchNanos(); + } + + public boolean hasSearchNanos() + { + return metrics.hasSearchNanos(); + } + + public Rs2RouteMetrics getMetrics() + { + return metrics; + } + + public Optional getEndpoint() + { + return path.isEmpty() ? Optional.empty() : Optional.of(path.get(path.size() - 1)); + } + + public Optional getReachedTarget(int tolerance) + { + if (tolerance < 0) + { + throw new IllegalArgumentException("tolerance must be non-negative"); + } + Optional endpoint = getEndpoint(); + if (endpoint.isEmpty()) + { + return Optional.empty(); + } + WorldPoint end = endpoint.get(); + return targets.stream() + .filter(target -> target.getPlane() == end.getPlane()) + .filter(target -> target.distanceTo2D(end) <= tolerance) + .findFirst(); + } + + public boolean isTargetReached(int tolerance) + { + return getReachedTarget(tolerance).isPresent(); + } + + private static void validateSteps(List path, List steps) + { + int expectedSteps = Math.max(0, path.size() - 1); + if (steps.size() != expectedSteps) + { + throw new IllegalArgumentException( + "route steps must describe every path edge: expected " + expectedSteps + + ", got " + steps.size()); + } + for (int i = 0; i < steps.size(); i++) + { + Rs2RouteStep step = steps.get(i); + if (!path.get(i).equals(step.getFrom()) || !path.get(i + 1).equals(step.getTo())) + { + throw new IllegalArgumentException("route step " + i + " is not contiguous with path"); + } + } + } + + private static Map> indexTransportEdges( + List steps) + { + Map> mutable = new LinkedHashMap<>(); + for (Rs2RouteStep step : steps) + { + if (!step.isTransport()) + { + continue; + } + Rs2TransportEdge edge = step.getTransport().orElseThrow(IllegalStateException::new); + mutable.computeIfAbsent(step.getFrom(), ignored -> new LinkedHashMap<>()) + .putIfAbsent(step.getTo(), edge); + } + Map> immutable = new LinkedHashMap<>(); + for (Map.Entry> entry : mutable.entrySet()) + { + immutable.put(entry.getKey(), Collections.unmodifiableMap(entry.getValue())); + } + return Collections.unmodifiableMap(immutable); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RouteStep.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RouteStep.java new file mode 100644 index 00000000000..55a166caef8 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RouteStep.java @@ -0,0 +1,53 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; + +import java.util.Objects; +import java.util.Optional; + +/** One immutable, planner-independent edge in a route. */ +public final class Rs2RouteStep +{ + public enum Kind + { + WALK, + TRANSPORT + } + + private final WorldPoint from; + private final WorldPoint to; + private final Kind kind; + private final Rs2TransportEdge transport; + + private Rs2RouteStep(WorldPoint from, WorldPoint to, Kind kind, Rs2TransportEdge transport) + { + this.from = Objects.requireNonNull(from, "from"); + this.to = Objects.requireNonNull(to, "to"); + this.kind = Objects.requireNonNull(kind, "kind"); + this.transport = transport; + if ((kind == Kind.TRANSPORT) != (transport != null)) + { + throw new IllegalArgumentException("transport metadata must match route-step kind"); + } + if (transport != null && !to.equals(transport.getDestination())) + { + throw new IllegalArgumentException("transport destination must match route-step destination"); + } + } + + public static Rs2RouteStep walk(WorldPoint from, WorldPoint to) + { + return new Rs2RouteStep(from, to, Kind.WALK, null); + } + + public static Rs2RouteStep transport(WorldPoint from, WorldPoint to, Rs2TransportEdge transport) + { + return new Rs2RouteStep(from, to, Kind.TRANSPORT, transport); + } + + public WorldPoint getFrom() { return from; } + public WorldPoint getTo() { return to; } + public Kind getKind() { return kind; } + public Optional getTransport() { return Optional.ofNullable(transport); } + public boolean isTransport() { return kind == Kind.TRANSPORT; } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RouteTermination.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RouteTermination.java new file mode 100644 index 00000000000..dd982ae5329 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2RouteTermination.java @@ -0,0 +1,11 @@ +package net.runelite.client.plugins.microbot.util.walker; + +/** Stable Microbot-owned reason why route planning stopped. */ +public enum Rs2RouteTermination +{ + TARGET_REACHED, + SEARCH_EXHAUSTED, + CUTOFF_REACHED, + CANCELLED, + FAILED +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TerminalTravelMode.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TerminalTravelMode.java new file mode 100644 index 00000000000..5b6a70b0249 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TerminalTravelMode.java @@ -0,0 +1,9 @@ +package net.runelite.client.plugins.microbot.util.walker; + +/** Planner-independent interaction sequence for a terminal SHIP, NPC or BOAT edge. */ +public enum Rs2TerminalTravelMode +{ + DIRECT, + DIALOGUE_DESTINATION, + UNSUPPORTED +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportEdge.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportEdge.java new file mode 100644 index 00000000000..3a647784432 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportEdge.java @@ -0,0 +1,133 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** Immutable execution-facing description of the exact transport selected for a route edge. */ +public final class Rs2TransportEdge +{ + private final WorldPoint origin; + private final WorldPoint destination; + private final Rs2TransportType type; + private final Rs2TransportExecutor executor; + private final Rs2TerminalTravelMode terminalTravelMode; + private final String displayInfo; + private final String action; + private final String target; + private final int objectId; + private final int duration; + private final boolean teleport; + private final boolean consumable; + private final boolean members; + private final boolean skillGated; + private final boolean questGated; + private final boolean stateGated; + private final int maxWildernessLevel; + private final String currencyName; + private final int currencyAmount; + private final List itemRequirements; + /** Opaque local-engine identity; never exposed by the public planner-independent contract. */ + private final Object sourceIdentity; + + public Rs2TransportEdge( + WorldPoint origin, + WorldPoint destination, + Rs2TransportType type, + Rs2TransportExecutor executor, + Rs2TerminalTravelMode terminalTravelMode, + String displayInfo, + String action, + String target, + int objectId, + int duration, + boolean teleport, + boolean consumable, + boolean members, + int maxWildernessLevel, + String currencyName, + int currencyAmount, + List itemRequirements) + { + this(origin, destination, type, executor, terminalTravelMode, displayInfo, action, target, + objectId, duration, teleport, consumable, members, maxWildernessLevel, currencyName, + currencyAmount, itemRequirements, false, false, false, null); + } + + Rs2TransportEdge( + WorldPoint origin, + WorldPoint destination, + Rs2TransportType type, + Rs2TransportExecutor executor, + Rs2TerminalTravelMode terminalTravelMode, + String displayInfo, + String action, + String target, + int objectId, + int duration, + boolean teleport, + boolean consumable, + boolean members, + int maxWildernessLevel, + String currencyName, + int currencyAmount, + List itemRequirements, + boolean skillGated, + boolean questGated, + boolean stateGated, + Object sourceIdentity) + { + this.origin = origin; + this.destination = Objects.requireNonNull(destination, "destination"); + this.type = Objects.requireNonNull(type, "type"); + this.executor = Objects.requireNonNull(executor, "executor"); + this.terminalTravelMode = Objects.requireNonNull(terminalTravelMode, "terminalTravelMode"); + if ((executor == Rs2TransportExecutor.TERMINAL_TRAVEL) + != (terminalTravelMode != Rs2TerminalTravelMode.UNSUPPORTED)) + { + throw new IllegalArgumentException("terminal travel executor and mode must agree"); + } + this.displayInfo = displayInfo; + this.action = action; + this.target = target; + this.objectId = objectId; + this.duration = duration; + this.teleport = teleport; + this.consumable = consumable; + this.members = members; + this.skillGated = skillGated; + this.questGated = questGated; + this.stateGated = stateGated; + this.maxWildernessLevel = maxWildernessLevel; + this.currencyName = currencyName == null ? "" : currencyName; + this.currencyAmount = currencyAmount; + this.itemRequirements = itemRequirements == null + ? Collections.emptyList() + : List.copyOf(itemRequirements); + this.sourceIdentity = sourceIdentity; + } + + public WorldPoint getOrigin() { return origin; } + public WorldPoint getDestination() { return destination; } + public Rs2TransportType getType() { return type; } + public Rs2TransportExecutor getExecutor() { return executor; } + public Rs2TerminalTravelMode getTerminalTravelMode() { return terminalTravelMode; } + public String getDisplayInfo() { return displayInfo; } + public String getAction() { return action; } + public String getTarget() { return target; } + public int getObjectId() { return objectId; } + public int getDuration() { return duration; } + public boolean isTeleport() { return teleport; } + public boolean isConsumable() { return consumable; } + public boolean isMembers() { return members; } + public boolean isSkillGated() { return skillGated; } + public boolean isQuestGated() { return questGated; } + public boolean isStateGated() { return stateGated; } + public int getMaxWildernessLevel() { return maxWildernessLevel; } + public String getCurrencyName() { return currencyName; } + public int getCurrencyAmount() { return currencyAmount; } + public List getItemRequirements() { return itemRequirements; } + Object getSourceIdentity() { return sourceIdentity; } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportExecutor.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportExecutor.java new file mode 100644 index 00000000000..71f9b6d0f5a --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportExecutor.java @@ -0,0 +1,25 @@ +package net.runelite.client.plugins.microbot.util.walker; + +/** Planner-independent Microbot runtime capability selected for a transport edge. */ +public enum Rs2TransportExecutor +{ + BARROWS_DIG, + CANOE, + CHARTER_SHIP, + FAIRY_RING, + GNOME_GLIDER, + HOT_AIR_BALLOON, + ITEM_TELEPORT, + MAGIC_CARPET, + MAGIC_MUSHTREE, + MINIGAME_TELEPORT, + OBJECT, + POH, + QUETZAL, + SEASONAL, + SPELL_TELEPORT, + SPIRIT_TREE, + TERMINAL_TRAVEL, + WILDERNESS_OBELISK, + UNSUPPORTED +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportItemRequirement.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportItemRequirement.java new file mode 100644 index 00000000000..b9844c73e71 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportItemRequirement.java @@ -0,0 +1,224 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.IntPredicate; +import java.util.function.IntUnaryOperator; + +/** One immutable AND-clause whose item/quantity alternatives are OR-ed. */ +public final class Rs2TransportItemRequirement +{ + private final Map alternatives; + private final Set staffAlternatives; + private final Set offhandAlternatives; + private final boolean runeOnly; + + public Rs2TransportItemRequirement(Map alternatives) + { + this(alternatives, Collections.emptySet(), Collections.emptySet(), false); + } + + public Rs2TransportItemRequirement( + Map alternatives, + Set staffAlternatives, + Set offhandAlternatives, + boolean runeOnly) + { + if (alternatives == null || alternatives.isEmpty()) + { + throw new IllegalArgumentException("item requirement must contain an alternative"); + } + Map copy = new LinkedHashMap<>(); + for (Map.Entry alternative : alternatives.entrySet()) + { + Integer itemId = Objects.requireNonNull(alternative.getKey(), "itemId"); + Integer quantity = Objects.requireNonNull(alternative.getValue(), "quantity"); + if (itemId <= 0 || quantity < 0) + { + throw new IllegalArgumentException("invalid item requirement: " + alternative); + } + copy.put(itemId, quantity); + } + this.alternatives = Collections.unmodifiableMap(copy); + this.staffAlternatives = immutablePositiveIds(staffAlternatives, "staff"); + this.offhandAlternatives = immutablePositiveIds(offhandAlternatives, "offhand"); + this.runeOnly = runeOnly; + } + + private static Set immutablePositiveIds(Set itemIds, String label) + { + if (itemIds == null || itemIds.isEmpty()) + { + return Collections.emptySet(); + } + LinkedHashSet copy = new LinkedHashSet<>(); + for (Integer itemId : itemIds) + { + if (itemId == null || itemId <= 0) + { + throw new IllegalArgumentException(label + " item id must be positive: " + itemId); + } + copy.add(itemId); + } + return Collections.unmodifiableSet(copy); + } + + public Map getAlternatives() + { + return alternatives; + } + + public Set getStaffAlternatives() { return staffAlternatives; } + public Set getOffhandAlternatives() { return offhandAlternatives; } + public boolean isRuneOnly() { return runeOnly; } + + public boolean isSatisfiedBy(IntUnaryOperator availableQuantity) + { + Objects.requireNonNull(availableQuantity, "availableQuantity"); + return alternatives.entrySet().stream().anyMatch(alternative -> + { + int required = alternative.getValue(); + int available = Math.max(0, availableQuantity.applyAsInt(alternative.getKey())); + return (required == 0 && available == 0) || (required > 0 && available >= required); + }); + } + + private boolean isSatisfiedBy(IntUnaryOperator availableQuantity, int staffItemId, int offhandItemId) + { + return isSatisfiedBy(availableQuantity) + || staffAlternatives.contains(staffItemId) + || offhandAlternatives.contains(offhandItemId); + } + + public static Optional selectProviders( + List requirements, + IntUnaryOperator availableQuantity, + IntPredicate staffAvailable, + IntPredicate offhandAvailable) + { + return selectProviders( + requirements, availableQuantity, staffAvailable, offhandAvailable, false); + } + + /** + * Select equipment for provider-bearing clauses while deferring ordinary missing items to the + * withdrawal/purchase planner. This prevents an unbanked purchasable pass from rejecting the + * equipment phase before its fare fallback can be evaluated. + */ + public static Optional selectEquipmentProviders( + List requirements, + IntUnaryOperator availableQuantity, + IntPredicate staffAvailable, + IntPredicate offhandAvailable) + { + return selectProviders( + requirements, availableQuantity, staffAvailable, offhandAvailable, true); + } + + private static Optional selectProviders( + List requirements, + IntUnaryOperator availableQuantity, + IntPredicate staffAvailable, + IntPredicate offhandAvailable, + boolean deferOrdinaryRequirements) + { + if (requirements == null || requirements.isEmpty()) + { + return Optional.of(ProviderSelection.NONE); + } + TreeSet staffs = new TreeSet<>(); + TreeSet offhands = new TreeSet<>(); + for (Rs2TransportItemRequirement requirement : requirements) + { + requirement.staffAlternatives.stream().filter(staffAvailable::test).forEach(staffs::add); + requirement.offhandAlternatives.stream().filter(offhandAvailable::test).forEach(offhands::add); + } + List staffCandidates = new ArrayList<>(); + staffCandidates.add(ProviderSelection.NO_ITEM); + staffCandidates.addAll(staffs); + List offhandCandidates = new ArrayList<>(); + offhandCandidates.add(ProviderSelection.NO_ITEM); + offhandCandidates.addAll(offhands); + for (Integer staff : staffCandidates) + { + for (Integer offhand : offhandCandidates) + { + boolean satisfied = true; + for (Rs2TransportItemRequirement requirement : requirements) + { + if (deferOrdinaryRequirements + && requirement.staffAlternatives.isEmpty() + && requirement.offhandAlternatives.isEmpty()) + { + continue; + } + if (!requirement.isSatisfiedBy(availableQuantity, staff, offhand)) + { + satisfied = false; + break; + } + } + if (satisfied) + { + return Optional.of(new ProviderSelection(staff, offhand)); + } + } + } + return Optional.empty(); + } + + public static final class ProviderSelection + { + private static final int NO_ITEM = -1; + private static final ProviderSelection NONE = new ProviderSelection(NO_ITEM, NO_ITEM); + + private final int staffItemId; + private final int offhandItemId; + + private ProviderSelection(int staffItemId, int offhandItemId) + { + this.staffItemId = staffItemId; + this.offhandItemId = offhandItemId; + } + + public int getStaffItemId() { return staffItemId; } + public int getOffhandItemId() { return offhandItemId; } + public boolean hasStaff() { return staffItemId > 0; } + public boolean hasOffhand() { return offhandItemId > 0; } + } + + @Override + public boolean equals(Object other) + { + if (this == other) + { + return true; + } + if (!(other instanceof Rs2TransportItemRequirement)) + { + return false; + } + Rs2TransportItemRequirement that = (Rs2TransportItemRequirement) other; + return alternatives.equals(that.alternatives) + && staffAlternatives.equals(that.staffAlternatives) + && offhandAlternatives.equals(that.offhandAlternatives) + && runeOnly == that.runeOnly; + } + + @Override + public int hashCode() + { + int result = alternatives.hashCode(); + result = 31 * result + staffAlternatives.hashCode(); + result = 31 * result + offhandAlternatives.hashCode(); + return 31 * result + Boolean.hashCode(runeOnly); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportLoadout.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportLoadout.java new file mode 100644 index 00000000000..bfe494468ce --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportLoadout.java @@ -0,0 +1,78 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Immutable bank preparation required by the exact transport edges selected for a route. + * + *

Withdrawals and equipment changes are one contract: a route that depends on a banked staff or + * tome is not prepared until the item is both withdrawn and equipped. An unsatisfiable loadout is + * distinct from an empty loadout so callers cannot mistake a missing bank item for "nothing to do".

+ */ +public final class Rs2TransportLoadout +{ + private static final Rs2TransportLoadout EMPTY = + new Rs2TransportLoadout(Collections.emptyMap(), Collections.emptyList(), true); + private static final Rs2TransportLoadout UNAVAILABLE = + new Rs2TransportLoadout(Collections.emptyMap(), Collections.emptyList(), false); + + private final Map withdrawals; + private final List equipmentItemIds; + private final boolean satisfiable; + + public Rs2TransportLoadout( + Map withdrawals, + List equipmentItemIds, + boolean satisfiable) + { + LinkedHashMap withdrawalCopy = new LinkedHashMap<>(); + if (withdrawals != null) + { + for (Map.Entry withdrawal : withdrawals.entrySet()) + { + Integer itemId = withdrawal.getKey(); + Integer quantity = withdrawal.getValue(); + if (itemId == null || itemId <= 0 || quantity == null || quantity <= 0) + { + throw new IllegalArgumentException("invalid transport withdrawal: " + withdrawal); + } + withdrawalCopy.merge(itemId, quantity, Integer::sum); + } + } + this.withdrawals = Collections.unmodifiableMap(withdrawalCopy); + if (equipmentItemIds == null) + { + this.equipmentItemIds = Collections.emptyList(); + } + else + { + for (Integer itemId : equipmentItemIds) + { + if (itemId == null || itemId <= 0) + { + throw new IllegalArgumentException("invalid equipment item id: " + itemId); + } + } + this.equipmentItemIds = List.copyOf(equipmentItemIds); + } + this.satisfiable = satisfiable; + } + + public static Rs2TransportLoadout empty() + { + return EMPTY; + } + + public static Rs2TransportLoadout unavailable() + { + return UNAVAILABLE; + } + + public Map getWithdrawals() { return withdrawals; } + public List getEquipmentItemIds() { return equipmentItemIds; } + public boolean isSatisfiable() { return satisfiable; } + public boolean isEmpty() { return withdrawals.isEmpty() && equipmentItemIds.isEmpty(); } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportPlanningPolicy.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportPlanningPolicy.java new file mode 100644 index 00000000000..792b3c778fd --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportPlanningPolicy.java @@ -0,0 +1,28 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.TransportExecutionRegistry; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.TransportPlanningPolicy; + +/** Microbot executor capabilities projected into the local planner's catalog-admission seam. */ +public final class Rs2TransportPlanningPolicy implements TransportPlanningPolicy +{ + public static final Rs2TransportPlanningPolicy INSTANCE = new Rs2TransportPlanningPolicy(); + + private Rs2TransportPlanningPolicy() + { + } + + @Override + public boolean isAdmitted(Transport transport) + { + return TransportExecutionRegistry.canExecute(transport); + } + + @Override + public boolean isZeroRuneSpell(Transport transport) + { + return transport != null + && TransportExecutionRegistry.homeTeleportFor(transport.getDisplayInfo()).isPresent(); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportType.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportType.java new file mode 100644 index 00000000000..2b363812c0c --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportType.java @@ -0,0 +1,40 @@ +package net.runelite.client.plugins.microbot.util.walker; + +/** + * Planner-independent transport categories understood by the Microbot walker boundary. + * + *

The superset includes names used by both the local planner and the tracked upstream planner so + * an adapter can normalize either engine without exposing its enum.

+ */ +public enum Rs2TransportType +{ + TRANSPORT, + AGILITY_SHORTCUT, + GRAPPLE_SHORTCUT, + BOAT, + CANOE, + CHARTER_SHIP, + SHIP, + FAIRY_RING, + QUETZAL, + QUETZAL_WHISTLE, + GNOME_GLIDER, + MINECART, + POH, + SPIRIT_TREE, + TELEPORTATION_BOX, + TELEPORTATION_LEVER, + TELEPORTATION_PORTAL, + TELEPORTATION_PORTAL_POH, + TELEPORTATION_MINIGAME, + TELEPORTATION_ITEM, + TELEPORTATION_SPELL, + TELEPORTATION_SPELL_HOME, + WILDERNESS_OBELISK, + MAGIC_CARPET, + HOT_AIR_BALLOON, + MAGIC_MUSHTREE, + SEASONAL_TRANSPORT, + NPC, + UNKNOWN +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java index 0871e8a71a6..5a8efd7896d 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java @@ -19,9 +19,6 @@ import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.globval.enums.InterfaceTab; import net.runelite.client.plugins.microbot.shortestpath.*; -import net.runelite.client.plugins.microbot.shortestpath.pathfinder.CollisionMap; -import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; -import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathfinderConfig; import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; import net.runelite.client.plugins.microbot.util.bank.enums.BankLocation; import net.runelite.client.plugins.microbot.util.camera.Rs2Camera; @@ -50,6 +47,7 @@ import net.runelite.client.plugins.microbot.util.logging.Rs2LogRateLimit; import java.util.function.BooleanSupplier; import java.util.function.Predicate; +import java.util.function.Supplier; import org.slf4j.event.Level; import net.runelite.client.plugins.microbot.util.poh.PohTeleports; import net.runelite.client.plugins.microbot.util.poh.PohTransport; @@ -88,7 +86,7 @@ import java.util.*; import java.util.List; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Future; +import java.util.concurrent.TimeoutException; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.concurrent.TimeUnit; @@ -133,6 +131,8 @@ public static WorldPoint getCurrentTarget() { // interim-target state migrated to WalkerRouteState (see routeState) private static final long PARTIAL_TRANS_RECAL_COOLDOWN_MS = 3500L; + /** A single delayed client tick must not turn an otherwise healthy blocking walk into EXIT. */ + private static final int CLIENT_THREAD_TIMEOUT_RETRIES = 2; private static final int INTERIM_CLOSE_TILES = 5; @@ -234,7 +234,7 @@ public static WorldPoint getCurrentTarget() { private static final int ROUTE_PROGRESS_FORWARD_SEARCH_TILES = 40; /** - * How long to wait for {@code Rs2PathApi.getPathfinder()} to become non-null at route start. + * How long to wait for an active route to be published at route start. *

* The pathfinder is only published after {@code PathfinderConfig.refresh()} completes * (see {@code Rs2WalkerLifecycleRuntime.restartPathfinding}), and a cache-missing refresh has been @@ -308,6 +308,7 @@ public static WorldPoint getCurrentTarget() { /** After scene-object transport {@link #handleObject} — landing poll timeout + matching warn (cf. {@link #SHIP_NPC_BOAT_LANDING_WAIT_MS}). */ private static final int POST_HANDLE_OBJECT_LANDING_WAIT_MS = 5_000; private static final int POST_HANDLE_OBJECT_FAILED_SETTLE_MS = 800; + private static final int AL_KHARID_TOLL_INTERACTION_START_WAIT_MS = 2_500; /** Teleport “already near destination” skip in path loop — same semantics as prior {@code distanceTo2D < 3}. */ private static final int TELEPORT_NEAR_SKIP_CHEBYSHEV = 3; @@ -345,9 +346,16 @@ private static String compactWorldPoint(WorldPoint wp) { } private static void markWalkSessionStart(WorldPoint target) { + testRecoveryReplanRequests.set(0); + WalkEvidenceContext evidence = walkEvidenceContext.get(); + if (evidence != null) + { + evidence.started = true; + } routeState.walkSessionStartedAtMs = System.currentTimeMillis(); routeState.firstMovementClickMarked = false; startupPhasesLogged.clear(); + TERMINAL_TRAVEL_ATTEMPTED_EDGES.clear(); routeState.lastTransportHandledAtLocation = null; routeState.lastTransportOriginLocation = null; routeState.lastTransportDestinationLocation = null; @@ -698,8 +706,8 @@ private static void waitUntilIdleAfterSceneWalk(WorldPoint cancelGoal, int timeo * the nearest reachable tile and then reported {@code partial-retries-exhausted}, which reads * as a walker fault rather than a bad coordinate. * - *

Permissive by design. An unmapped region reads as fully blocked (see - * {@link CollisionMap#hasRegion}), so every ambiguous case returns {@code true} and lets the + *

Permissive by design. An unmapped collision region reads as fully blocked, so every + * ambiguous case returns {@code true} and lets the * pathfinder decide. Only a target sitting in mapped, wholly blocked terrain is rejected — * otherwise this would refuse instances and any region missing from the collision map. */ @@ -713,53 +721,7 @@ private static void waitUntilIdleAfterSceneWalk(WorldPoint cancelGoal, int timeo * Cove staircase approach at (2531,2834) reads walkable in the scene and blocked here. */ public static boolean isWalkableInCollisionMap(WorldPoint tile) { - PathfinderConfig config = Rs2PathApi.getPathfinderConfig(); - return hasWalkableTileWithin(config != null ? config.getMap() : null, tile, 0); - } - - static boolean hasWalkableTileWithin(CollisionMap map, WorldPoint target, int distance) { - if (map == null || target == null) { - return true; - } - if (!map.hasRegion(target.getX(), target.getY())) { - return true; - } - int radius = Math.max(0, distance); - for (int dx = -radius; dx <= radius; dx++) { - for (int dy = -radius; dy <= radius; dy++) { - int x = target.getX() + dx; - int y = target.getY() + dy; - if (!map.hasRegion(x, y)) { - return true; - } - if (!map.isBlocked(x, y, target.getPlane())) { - return true; - } - } - } - return false; - } - - /** Nearest walkable tile to {@code target} within {@code maxRadius}, or null. Diagnostics only. */ - static WorldPoint nearestWalkableTile(CollisionMap map, WorldPoint target, int maxRadius) { - if (map == null || target == null) { - return null; - } - for (int r = 1; r <= maxRadius; r++) { - for (int dx = -r; dx <= r; dx++) { - for (int dy = -r; dy <= r; dy++) { - if (Math.max(Math.abs(dx), Math.abs(dy)) != r) { - continue; // ring only; inner rings already scanned - } - int x = target.getX() + dx; - int y = target.getY() + dy; - if (map.hasRegion(x, y) && !map.isBlocked(x, y, target.getPlane())) { - return new WorldPoint(x, y, target.getPlane()); - } - } - } - } - return null; + return Rs2PathApi.hasWalkableTileWithin(tile, 0); } /** Door / gate from main path loop vs {@link #handleNearbyRawPathSceneObjects} raw-path scan (same nudge UX). */ @@ -957,6 +919,51 @@ private static void logRouteClear(String reason) { * context and therefore retain their exact behaviour.

*/ private static final ThreadLocal walkCompletionContext = new ThreadLocal<>(); + private static final ThreadLocal walkEvidenceContext = new ThreadLocal<>(); + private static final AtomicInteger testRecoveryReplanRequests = new AtomicInteger(); + + private static final class WalkEvidenceContext + { + private boolean started; + private boolean comparisonEligible; + private boolean recoveryTriggered; + } + + private static void captureActiveRouteComparisonEligibility(long routeGeneration) + { + WalkEvidenceContext evidence = walkEvidenceContext.get(); + if (evidence != null && !evidence.comparisonEligible + && Rs2PathApi.isActiveRouteComparisonEligible(routeGeneration)) + { + evidence.comparisonEligible = true; + } + } + + private static WalkerState withShadowExecutionEvidence(Supplier action) + { + WalkEvidenceContext existing = walkEvidenceContext.get(); + if (existing != null) + { + return action.get(); + } + WalkEvidenceContext evidence = new WalkEvidenceContext(); + walkEvidenceContext.set(evidence); + try + { + WalkerState result = Objects.requireNonNull(action.get(), "walker result"); + if (evidence.started) + { + Rs2PathApi.recordShadowWalkerOutcome( + result, evidence.recoveryTriggered, evidence.comparisonEligible); + } + return result; + } + finally + { + testRecoveryReplanRequests.set(0); + walkEvidenceContext.remove(); + } + } private static final class WalkCompletionContext { private final WorldPoint target; @@ -978,6 +985,8 @@ private WalkCompletionContext(WorldPoint target, BooleanSupplier condition) { private static final Set SEASONAL_HANDLER_MISS_LOGGED = ConcurrentHashMap.newKeySet(); private static final AtomicInteger SEASONAL_HANDLER_MISS_LOGGED_COUNT = new AtomicInteger(0); private static final int SEASONAL_HANDLER_MISS_LOG_CAP = 128; + /** Terminal NPC edges already clicked during the current top-level walk invocation. */ + private static final Set TERMINAL_TRAVEL_ATTEMPTED_EDGES = ConcurrentHashMap.newKeySet(); /** * One-shot DEBUG when {@link WorldMapPointManager} is null during route clear (shutdown race). * Later races same JVM stay silent — intentional noise cap. @@ -990,7 +999,9 @@ static void clearWalkerDedupeForTesting() SEASONAL_HANDLER_MISS_LOGGED.clear(); SEASONAL_HANDLER_MISS_LOGGED_COUNT.set(0); WORLD_MAP_REMOVE_NULL_LOGGED.set(false); + testRecoveryReplanRequests.set(0); recentCurrentTileTransportByEdge.clear(); + TERMINAL_TRAVEL_ATTEMPTED_EDGES.clear(); clearRecentTransportContext(); resetRouteProgress(); } @@ -1121,14 +1132,18 @@ public static void recordPartialRetry(int attempt, int finalDist) { public static void recordUnreachable(String cause, WorldPoint player, WorldPoint target, WorldPoint pathEndpoint, int pathSize, int distanceThreshold, - Pathfinder pathfinder) { + Rs2RouteMetrics routeMetrics) { unreachableCount.incrementAndGet(); lastReason = "unreachable:" + cause; lastEventAtMs.set(System.currentTimeMillis()); int distToTarget = (pathEndpoint != null && target != null) ? pathEndpoint.distanceTo(target) : -1; - Pathfinder.PathfinderStats pfStats = (pathfinder != null) ? pathfinder.getStats() : null; - String stats = (pfStats != null) ? pfStats.toString() : "null"; - log.warn("[WalkerTelemetry] UNREACHABLE cause={} player={} target={} pathEndpoint={} pathSize={} endpointToTarget={} threshold={} pathfinderStats={} totalUnreachable={}", + String stats = routeMetrics == null ? "null" : String.format( + "RouteMetrics(nodes=%d,transports=%d,time=%dms,cost=%d)", + routeMetrics.getNodesChecked(), + routeMetrics.getTransportsChecked(), + routeMetrics.hasSearchNanos() ? routeMetrics.getSearchNanos() / 1_000_000L : -1L, + routeMetrics.getPathCost()); + log.warn("[WalkerTelemetry] UNREACHABLE cause={} player={} target={} pathEndpoint={} pathSize={} endpointToTarget={} threshold={} routeMetrics={} totalUnreachable={}", cause, player, target, pathEndpoint, pathSize, distToTarget, distanceThreshold, stats, unreachableCount.get()); } @@ -1300,11 +1315,9 @@ public static WalkerState walkWithState(WorldPoint target, int distance) { } } try { - if (config.walkWithBankedTransports()) { - return walkWithBankedTransportsAndState(target, distance, false); - } else { - return walkWithStateInternal(target, distance); - } + return withShadowExecutionEvidence(() -> config.walkWithBankedTransports() + ? walkWithBankedTransportsAndStateLocked(target, distance, false) + : walkWithStateInternal(target, distance)); } finally { walkerLock.unlock(); } @@ -1363,11 +1376,9 @@ public static WalkerState walkWithStateTry(WorldPoint target, int distance, long } try { - if (config.walkWithBankedTransports()) - { - return walkWithBankedTransportsAndStateLocked(target, distance, false); - } - return walkWithStateInternal(target, distance); + return withShadowExecutionEvidence(() -> config.walkWithBankedTransports() + ? walkWithBankedTransportsAndStateLocked(target, distance, false) + : walkWithStateInternal(target, distance)); } finally { @@ -1400,13 +1411,12 @@ private static WalkerState walkWithStateInternal(WorldPoint target, int distance return WalkerState.ARRIVED; } - final Pathfinder pathfinder = Rs2PathApi.getPathfinder(); - if (pathfinder != null && !pathfinder.isDone()) { + final Rs2ActiveRouteStatus routeStatus = Rs2PathApi.getActiveRouteStatus(); + if (routeStatus.isCalculating()) { return WalkerState.MOVING; } - boolean hasCurrentPath = pathfinder != null - && pathfinder.isDone() - && pathfinder.getTargets().contains(target); + boolean hasCurrentPath = routeStatus.isReady() + && routeStatus.getTargets().contains(target); if (!hasCurrentPath) { setTarget(target); } else { @@ -1495,8 +1505,8 @@ public static WalkerState walkStep(WorldPoint target, int distance) { setTarget(target); return WalkerState.MOVING; } - Pathfinder pathfinder = Rs2PathApi.getPathfinder(); - if (pathfinder == null || !pathfinder.isDone()) { + Rs2ActiveRouteStatus routeStatus = Rs2PathApi.getActiveRouteStatus(); + if (!routeStatus.isReady()) { return WalkerState.MOVING; // path still computing — wait, don't reset it } @@ -1505,8 +1515,8 @@ public static WalkerState walkStep(WorldPoint target, int distance) { return WalkerState.MOVING; } - final List rawPath = pathfinder.getPath(); - final List path = pathfinder.getWalkablePath(); + final List rawPath = routeStatus.getRawPath(); + final List path = routeStatus.getWalkablePath(); if (!walkStepPathReachesTarget(path, target, distance)) { setTarget(null, "rs2walker:walkStep:no-walkable-path"); return WalkerState.UNREACHABLE; @@ -1606,10 +1616,8 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part } // Pre-flight: a destination with no walkable tile within the arrival distance can never be // reached, so reject it here rather than after a full route ending at the nearest wall. - PathfinderConfig preflightConfig = Rs2PathApi.getPathfinderConfig(); - CollisionMap preflightMap = preflightConfig != null ? preflightConfig.getMap() : null; - if (!hasWalkableTileWithin(preflightMap, target, distance)) { - WorldPoint nearestWalkable = nearestWalkableTile(preflightMap, target, 48); + if (!Rs2PathApi.hasWalkableTileWithin(target, distance)) { + WorldPoint nearestWalkable = Rs2PathApi.nearestWalkableTile(target, 48); log.warn("[Walker] walk rejected: target {} has no walkable tile within {} in the collision map" + " (nearest walkable {}); check the destination coordinate", target, distance, @@ -1620,6 +1628,7 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part return WalkerState.UNREACHABLE; } int partialRetriesWorking = partialRetries; + int clientThreadTimeoutRetries = 0; // When the last partial retry was spent, so route progress made after it can refill the // budget. Without this the counter is monotonic for the entire walk. long lastPartialRetryAtMs = 0L; @@ -1650,15 +1659,19 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part return WalkerState.EXIT; } - Pathfinder pathfinder = Rs2PathApi.getPathfinder(); - if (pathfinder == null) { + Rs2ActiveRouteStatus routeStatus = Rs2PathApi.getActiveRouteStatus(); + if (!routeStatus.isPresent()) { markStartupPhase("pf_wait_enter", target, "reason=pathfinder_null"); walkerDiag("pathfinder null; waiting up to %dms", PATHFINDER_NULL_WAIT_MS); - pathfinder = sleepUntilNotNull(Rs2PathApi::getPathfinder, PATHFINDER_NULL_WAIT_MS); + Rs2WalkerRuntimeAwaits.awaitCondition( + () -> Rs2PathApi.getActiveRouteStatus().isPresent(), + 100, + PATHFINDER_NULL_WAIT_MS); + routeStatus = Rs2PathApi.getActiveRouteStatus(); if (walkCancelledDiag(target, "processWalk:after-wait-pathfinder", processWalkTail)) { return WalkerState.EXIT; } - if (pathfinder == null) { + if (!routeStatus.isPresent()) { if (currentTarget != null && currentTarget.equals(target)) { walkerDiag("pathfinder null but target still set; recalculating"); recalculatePath(); @@ -1671,17 +1684,24 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part markStartupPhase("pf_ready", target, "source=pathfinder_not_null"); } - if (!pathfinder.isDone()) { + if (routeStatus.isCalculating()) { + long observedGeneration = routeStatus.getGeneration(); markStartupPhase("pf_wait_retry", target, "slice=" + PATHFINDER_DONE_POLL_WAIT_MS); if (pathfinderPendingSinceMs == 0L) { pathfinderPendingSinceMs = System.currentTimeMillis(); } walkerDiag("pathfinder not done; short-poll max %dms", PATHFINDER_DONE_POLL_WAIT_MS); - boolean isDone = Rs2WalkerRuntimeAwaits.awaitPathfinderDone(pathfinder, PATHFINDER_DONE_POLL_WAIT_MS); + Rs2WalkerRuntimeAwaits.awaitCondition(() -> { + Rs2ActiveRouteStatus current = Rs2PathApi.getActiveRouteStatus(); + return !current.isPresent() + || current.getGeneration() != observedGeneration + || current.isReady(); + }, 100, PATHFINDER_DONE_POLL_WAIT_MS); + routeStatus = Rs2PathApi.getActiveRouteStatus(); if (walkCancelledDiag(target, "processWalk:after-wait-done", processWalkTail)) { return WalkerState.EXIT; } - if (!isDone) { + if (!routeStatus.isReady()) { if (System.currentTimeMillis() - pathfinderPendingSinceMs > 10_000L) { traceProcessWalkExit("pathfinder-timeout-not-done", target, processWalkTail); setTarget(null, "rs2walker:processWalk:pathfinder-timeout-not-done"); @@ -1696,13 +1716,21 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part markStartupPhase("pf_ready", target, "source=pathfinder_done"); } pathfinderPendingSinceMs = 0L; + captureActiveRouteComparisonEligibility(routeStatus.getGeneration()); + + if (consumeRecoveryReplanForTest()) + { + WebWalkLog.spDebug("test_recovery_replan | target={}", target); + recalculatePathForRecovery(); + continue; + } if (Rs2PathApi.getMarker() == null) { restoreTargetMarker(target); } - final List rawPath = pathfinder.getPath(); - final List path = pathfinder.getWalkablePath(); + final List rawPath = routeStatus.getRawPath(); + final List path = routeStatus.getWalkablePath(); final int[] smoothedToRaw = mapSmoothedToRaw(path, rawPath); int rawSize = rawPath == null ? -1 : rawPath.size(); int walkSize = path == null ? -1 : path.size(); @@ -1722,7 +1750,8 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part partialPath = true; } else { Telemetry.recordUnreachable("no-walkable-path", walkLoop.playerLoc, - target, dst, path == null ? 0 : path.size(), distance, pathfinder); + target, dst, path == null ? 0 : path.size(), distance, + routeStatus.getMetrics().orElse(null)); setTarget(null, "rs2walker:processWalk:no-walkable-path"); return WalkerState.UNREACHABLE; } @@ -1811,11 +1840,11 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part if (immediateRouteTransportPending) { WebWalkLog.spDebug("stall_recovery_suppressed | reason=immediate-route-transport idx={}", earlyRouteStartIdx); } else if (!Rs2Player.isMoving() && !Rs2Player.isAnimating() && !Rs2Player.isInteracting()) { - setTarget(target); + recalculatePathForRecovery(); tryIssueRouteRecoveryClick(rawPath, path, target, distance, "stall recovery click"); continue; } else { - setTarget(target); + recalculatePathForRecovery(); continue; } } @@ -2102,7 +2131,7 @@ && shouldYieldForActiveRouteInterim(activeInterimPlayer, path, activeInterimNowM if (config.cancelInstead()) { setTarget(null, "rs2walker:processWalk:off-path-cancel-instead"); } else { - recalculatePath(); + recalculatePathForRecovery(); } exitReason = "not-near-path"; break; @@ -2521,11 +2550,8 @@ && handleTransportsInRawSegment(rawPath, rawEdgeStart, rawEdgeEnd, true)) { int recoverIdx = findForwardReachableRecoveryIndex(path, i, playerLoc, recoveryMinimapReach); if (recoverIdx < 0) { - recoverIdx = RouteRecovery.findFurthestClickableIndex(path, i, playerLoc, - wp -> { - Set ts = Rs2PathApi.getTransports().get(wp); - return ts != null && !ts.isEmpty(); - }, + recoverIdx = RouteRecovery.findFurthestClickableIndex(path, i, playerLoc, + Rs2PathApi::hasCatalogTransportOrigin, recoveryMinimapReach); } int minRecoveryIdx = Math.max(indexOfStartPoint, i); @@ -2544,12 +2570,10 @@ && handleTransportsInRawSegment(rawPath, rawEdgeStart, rawEdgeEnd, true)) { // (e.g. an undead tree). The planner avoids those via avoidDangerousNpcs, // but this runtime fallback would otherwise strand us in melee. Step the // target back along the path to the nearest non-hazard tile. - PathfinderConfig dangerCfg = Rs2PathApi.getPathfinderConfig(); - if (dangerCfg != null && dangerCfg.isAvoidDangerousNpcs() && recoverTarget != null - && dangerCfg.isDangerousAdjacentTile(WorldPointUtil.packWorldPoint(recoverTarget))) { + if (Rs2PathApi.shouldAvoidDangerousTile(recoverTarget)) { int safeIdx = recoverIdx; while (safeIdx > minRecoveryIdx - && dangerCfg.isDangerousAdjacentTile(WorldPointUtil.packWorldPoint(path.get(safeIdx)))) { + && Rs2PathApi.shouldAvoidDangerousTile(path.get(safeIdx))) { safeIdx--; } recoverIdx = safeIdx; @@ -2564,9 +2588,7 @@ && handleTransportsInRawSegment(rawPath, rawEdgeStart, rawEdgeEnd, true)) { Rs2Walker::isKnownWalkableOrUnloaded); if (rawRecoveryTarget != null && !rawRecoveryTarget.equals(playerLoc) - && (dangerCfg == null - || !dangerCfg.isAvoidDangerousNpcs() - || !dangerCfg.isDangerousAdjacentTile(WorldPointUtil.packWorldPoint(rawRecoveryTarget)))) { + && !Rs2PathApi.shouldAvoidDangerousTile(rawRecoveryTarget)) { recoverTarget = rawRecoveryTarget; } // Prefer walking onto the reachable transport / agility-shortcut origin the unified @@ -2605,7 +2627,7 @@ && isMovementWalkerOwned(System.currentTimeMillis(), lastAttemptedMinimapClickAt routeState.lastWalledRecoveryReplanAtMs = System.currentTimeMillis(); WebWalkLog.spInfo("recovery_target_walled | to={} player={} replanning", compactWorldPoint(recoverTarget), compactWorldPoint(playerLoc)); - recalculatePath(); + recalculatePathForRecovery(); exitReason = "recovery-target-walled-replan"; break; } @@ -2767,11 +2789,8 @@ && walkFastCanvas(recoverTarget)) { routeState.interimLastRetargetAtMs = 0L; } - int targetIdx = RouteRecovery.findFurthestForwardClickableIndex(path, i, playerLoc, - wp -> { - Set ts = Rs2PathApi.getTransports().get(wp); - return ts != null && !ts.isEmpty(); - }, + int targetIdx = RouteRecovery.findFurthestForwardClickableIndex(path, i, playerLoc, + Rs2PathApi::hasCatalogTransportOrigin, MINIMAP_REACH_EUCLIDEAN); WorldPoint targetWp = path.get(targetIdx); // If the forward waypoint is outside minimap reach, interpolate a @@ -3141,7 +3160,8 @@ && walkFastCanvas(recoverTarget)) { // than the actual shortfall. WorldPoint unreachableEndpoint = path.isEmpty() ? null : path.get(path.size() - 1); Telemetry.recordUnreachable("partial-retries-exhausted", Rs2Player.getWorldLocation(), - target, unreachableEndpoint, path.size(), distance, Rs2PathApi.getPathfinder()); + target, unreachableEndpoint, path.size(), distance, + Rs2PathApi.getActiveRouteStatus().getMetrics().orElse(null)); setTarget(null, "rs2walker:processWalk:partial-retries-exhausted"); return WalkerState.UNREACHABLE; } else { @@ -3209,6 +3229,16 @@ && walkFastCanvas(recoverTarget)) { setTarget(null, "rs2walker:processWalk:interrupted-exception"); return WalkerState.EXIT; } + if (isClientThreadReadTimeout(ex) + && clientThreadTimeoutRetries < CLIENT_THREAD_TIMEOUT_RETRIES + && Objects.equals(currentTarget, target) + && !Thread.currentThread().isInterrupted()) { + int nextRetry = ++clientThreadTimeoutRetries; + WebWalkLog.spInfo("client_thread_timeout_retry | attempt={}/{} target={}", + nextRetry, CLIENT_THREAD_TIMEOUT_RETRIES, target); + processWalkTail--; + continue; + } log.error("Exception in Rs2Walker:", ex); WebWalkLog.interruptedExit("walker exception exit (403)"); traceProcessWalkExit("exception-" + ex.getClass().getSimpleName(), target, MAX_PROCESS_WALK_TAIL_ITERATIONS - 1); @@ -3992,11 +4022,8 @@ private static boolean tryIssueRouteMovementClick(List rawPath, // pathfinder makes the idle nudge issue the first click instead of the main loop). WorldPoint clickTarget = selectRouteClickTarget(rawPath, playerLoc, maxEuclidean, rawAnchorIndex); if (clickTarget == null) { - int clickableIdx = RouteRecovery.findFurthestForwardClickableIndex(path, startIdx, playerLoc, - wp -> { - Set ts = Rs2PathApi.getTransports().get(wp); - return ts != null && !ts.isEmpty(); - }, + int clickableIdx = RouteRecovery.findFurthestForwardClickableIndex(path, startIdx, playerLoc, + Rs2PathApi::hasCatalogTransportOrigin, maxEuclidean); clickableIdx = Math.max(startIdx, Math.min(clickableIdx, path.size() - 1)); clickTarget = path.get(clickableIdx); @@ -4227,12 +4254,8 @@ public static WorldPoint walkCanvas(WorldPoint worldPoint) { * @return total amount of tiles */ public static int getTotalTiles(WorldPoint start, WorldPoint destination) { - if (Rs2PathApi.getPathfinderConfig().getTransports().isEmpty()) { - Rs2PathApi.getPathfinderConfig().refresh(); - } - Pathfinder pathfinder = new Pathfinder(Rs2PathApi.getPathfinderConfig(), start, destination); - pathfinder.run(); - List path = pathfinder.getPath(); + Rs2RouteResult route = Rs2PathApi.plan(Rs2RouteRequest.to(start, destination)); + List path = route.getPath(); if (path.isEmpty() || path.get(path.size() - 1).getPlane() != destination.getPlane()) return Integer.MAX_VALUE; // Create a WorldArea centered on the worldPoint by calculating the south-west corner WorldPoint pathPoint_SW = new WorldPoint( @@ -4302,7 +4325,6 @@ public static int getTotalTiles(WorldPoint destination) { // takes an avg 200-300 ms // Used mainly for agility, might have to tweak this for other stuff public static boolean canReach(WorldPoint worldPoint, int sizeX, int sizeY, int pathSizeX, int pathSizeY,boolean useBankedItems) { - boolean originalUseBankItems = Rs2PathApi.getPathfinderConfig().isUseBankItems(); WorldArea pathArea = null; // Create centered WorldArea for the object instead of corner-based @@ -4314,16 +4336,14 @@ public static boolean canReach(WorldPoint worldPoint, int sizeX, int sizeY, int WorldArea objectArea = new WorldArea(objectSouthWest, sizeX + 2, sizeY + 2); try { - Rs2PathApi.getPathfinderConfig().setUseBankItems(useBankedItems); - Rs2PathApi.getPathfinderConfig().refresh(worldPoint); - if (Rs2PathApi.getPathfinderConfig().getTransports().isEmpty()) { - Rs2PathApi.getPathfinderConfig().refresh(worldPoint); - } - Pathfinder pathfinder = new Pathfinder(Rs2PathApi.getPathfinderConfig(), Rs2Player.getWorldLocation(), worldPoint); - pathfinder.run(); + Rs2RouteResult route = Rs2PathApi.plan( + Rs2RouteRequest.to(Rs2Player.getWorldLocation(), worldPoint) + .withRefreshTarget(worldPoint) + .withBankItems(useBankedItems)); // Create centered WorldArea for the path endpoint instead of corner-based - WorldPoint pathEndpoint = pathfinder.getPath().get(pathfinder.getPath().size() - 1); + WorldPoint pathEndpoint = route.getEndpoint().orElseThrow( + () -> new IllegalStateException("planner returned no endpoint")); WorldPoint pathSouthWest = new WorldPoint( pathEndpoint.getX() - pathSizeX / 2, pathEndpoint.getY() - pathSizeY / 2, @@ -4333,9 +4353,6 @@ public static boolean canReach(WorldPoint worldPoint, int sizeX, int sizeY, int } catch (Exception e) { log.trace("Exception in canReach: {} - ", e.getMessage(), e); return false; - } finally { - Rs2PathApi.getPathfinderConfig().setUseBankItems(originalUseBankItems); - Rs2PathApi.getPathfinderConfig().refresh(worldPoint); } return pathArea != null ? pathArea.intersectsWith2D(objectArea) : false; } @@ -4377,16 +4394,17 @@ public static boolean canReach(WorldPoint worldPoint, int sizeX, int sizeY, bool */ public static List getWalkPath(WorldPoint start, WorldPoint target) { long startTime = System.nanoTime(); - Rs2PathApi.getPathfinderConfig().refresh(target); - long pathfinderStartTime = System.nanoTime(); - Pathfinder pathfinderLocal = new Pathfinder(Rs2PathApi.getPathfinderConfig(), start, target); - pathfinderLocal.run(); - List path = pathfinderLocal.getPath(); - long pathfinderEndTime = System.nanoTime(); + Rs2RouteResult route = Rs2PathApi.plan( + Rs2RouteRequest.to(start, target) + .withRefreshTarget(target) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.ALWAYS)); + List path = route.getPath(); long totalEndTime = System.nanoTime(); - double configTimeMs = (pathfinderStartTime - startTime) / 1_000_000.0; - double pathfinderTimeMs = (pathfinderEndTime - pathfinderStartTime) / 1_000_000.0; + double pathfinderTimeMs = route.hasSearchNanos() + ? route.getSearchNanos() / 1_000_000.0 + : 0.0; double totalTimeMs = (totalEndTime - startTime) / 1_000_000.0; + double configTimeMs = Math.max(0.0, totalTimeMs - pathfinderTimeMs); StringBuilder performanceLog = new StringBuilder(); performanceLog.append("getWalkPath Performance: ") @@ -4586,40 +4604,7 @@ private static Map buildPathFirstIndex(List pat * @return The filtered and processed list of transports */ private static List applyTransportFiltering(List transports) { - return transports.stream() - .filter(t -> t.getType() == TransportType.TELEPORTATION_ITEM || t.getType() == TransportType.FAIRY_RING || - t.getType() == TransportType.TELEPORTATION_SPELL || t.getType() == TransportType.CANOE || - t.getType() == TransportType.BOAT || t.getType() == TransportType.CHARTER_SHIP || - t.getType() == TransportType.SHIP || t.getType() == TransportType.MINECART || - t.getType() == TransportType.MAGIC_CARPET || t.getType() == TransportType.SPIRIT_TREE || - (t.getType() == TransportType.TRANSPORT && t.getCurrencyAmount() > 0) || - (t.getType() == TransportType.SEASONAL_TRANSPORT - && Rs2LeaguesTransport.isLeaguesActive() - && t.getDisplayInfo() != null - && t.getDisplayInfo().toLowerCase().startsWith("leagues area:"))) - .peek(t -> { - // Set fairy ring requirements if not already set - if (t.getType() == TransportType.FAIRY_RING && - ((t.getItemIdRequirements() == null || t.getItemIdRequirements().isEmpty()) ) && Microbot.getVarbitValue(VarbitID.LUMBRIDGE_DIARY_ELITE_COMPLETE) != 1) { - t.setItemIdRequirements(Set.of(Set.of( - ItemID.DRAMEN_STAFF, - ItemID.LUNAR_MOONCLAN_LIMINAL_STAFF - ))); - } - - // Set currency requirements for currency-based transports - if (isCurrencyBasedTransport(t.getType()) && - (t.getItemIdRequirements() == null || t.getItemIdRequirements().isEmpty()) && - t.getCurrencyName() != null && !t.getCurrencyName().isEmpty() && t.getCurrencyAmount() > 0) { - int currencyItemId = getCurrencyItemId(t.getCurrencyName()); - if (currencyItemId != -1) { - t.setItemIdRequirements(Set.of(Set.of(currencyItemId))); - log.debug("Set currency requirement for {}: {} x{} (ID: {})", - t.getType(), t.getCurrencyName(), t.getCurrencyAmount(), currencyItemId); - } - } - }) - .collect(Collectors.toList()); + return Rs2WalkerBankingPlanner.applyTransportFiltering(transports); } @@ -4752,9 +4737,9 @@ && isRawTransportOriginNearPlayer(rawPath, ri, playerLoc, RAW_TRANSPORT_DISPATCH } // (3) Reachable transport / agility-shortcut origin ahead: wide forward-window scan. - WorldPoint shortcutOrigin = RouteRecovery.findReachableTransportOriginAhead( - rawPath, playerRawIdx, playerLoc, - reachableTilesCache.keySet(), Rs2PathApi.getTransports(), + WorldPoint shortcutOrigin = RouteRecovery.findReachableTransportOriginAhead( + rawPath, playerRawIdx, playerLoc, + reachableTilesCache.keySet(), Rs2PathApi::hasCatalogTransportOrigin, recoveryMinimapReach - 1, ROUTE_PROGRESS_FORWARD_SEARCH_TILES); if (shortcutOrigin != null && !shortcutOrigin.equals(playerLoc)) { return ObstacleResolution.walkToOrigin(shortcutOrigin); @@ -5030,7 +5015,7 @@ private static boolean hasPendingDoorLikeSceneObjectBeforeDirectClick(List radius && to.distanceTo2D(playerLoc) > radius) { break; } - if (isCatalogBackedTransportSegment(route, i) && !isDoorLikeCatalogTransportSegment(route, i)) { + if (shouldDeferDoorHandlingToTransport(route, i)) { continue; } if (hasDoorLikeSceneObjectOnSegment(from, to, playerLoc, radius)) { @@ -5076,7 +5061,7 @@ private static boolean handlePendingDoorBeforeRouteClick(List rawPat if (a.distanceTo2D(playerLoc) > HANDLER_RANGE && b.distanceTo2D(playerLoc) > HANDLER_RANGE) { continue; } - if (isCatalogBackedTransportSegment(rawPath, ri) && !isDoorLikeCatalogTransportSegment(rawPath, ri)) { + if (shouldDeferDoorHandlingToTransport(rawPath, ri)) { continue; } if (!hasDoorLikeSceneObjectOnSegment(a, b, playerLoc, HANDLER_RANGE)) { @@ -5134,7 +5119,7 @@ private static boolean handlePendingDoorNearRawPath(List rawPath, if (a.distanceTo2D(playerLoc) > HANDLER_RANGE && b.distanceTo2D(playerLoc) > HANDLER_RANGE) { continue; } - if (isCatalogBackedTransportSegment(rawPath, ri) && !isDoorLikeCatalogTransportSegment(rawPath, ri)) { + if (shouldDeferDoorHandlingToTransport(rawPath, ri)) { continue; } if (!hasDoorLikeSceneObjectOnSegment(a, b, playerLoc, HANDLER_RANGE)) { @@ -5173,7 +5158,7 @@ private static boolean handleUnresolvedDoorNearRawPath(List rawPath, if (from.distanceTo2D(playerLoc) > radiusTiles && to.distanceTo2D(playerLoc) > radiusTiles) { continue; } - if (isCatalogBackedTransportSegment(rawPath, ri) && !isDoorLikeCatalogTransportSegment(rawPath, ri)) { + if (shouldDeferDoorHandlingToTransport(rawPath, ri)) { continue; } if (!hasUnresolvedDoorLikeSceneObjectOnSegment(from, to, playerLoc, radiusTiles)) { @@ -5573,7 +5558,7 @@ private static boolean hasDoorCandidateOnRawSegment(List rawPath, in if (rawPath == null || index < 0 || index >= rawPath.size() - 1) { return false; } - if (isCatalogBackedTransportSegment(rawPath, index) && !isDoorLikeCatalogTransportSegment(rawPath, index)) { + if (shouldDeferDoorHandlingToTransport(rawPath, index)) { return false; } boolean isInstance = Microbot.getClient() @@ -5642,32 +5627,26 @@ private static boolean handleCurrentTileTransportTowardPath(List raw return false; } - // Snappy proximity: consider usable transports whose origin is reachable within a few tiles - // of the player, not just the one on the exact player tile. NPC/"Follow" transports (e.g. Elkoy - // in the Tree Gnome Village maze) roam and sit a tile off the planned path, so exact-tile - // matching never sees them. The destination-on-forward-route gate below keeps this safe against - // off-path loops, and getTransports() is already the usable (config/quest/level-filtered) set, - // so we never grab a transport the pathfinder excluded. + // Snappy proximity: consider exact planned transports whose origin is reachable within a few + // tiles of the player, not just one on the exact player tile. NPC/"Follow" transports (e.g. + // Elkoy in the Tree Gnome Village maze) roam and sit a tile off the planned path. The old code + // rescanned every usable catalog row and inferred selection from destination membership; that is + // ambiguous when multiple transports share an edge. The completed route now supplies both order + // and exact identity. final int NEARBY_TRANSPORT_REACH = 5; - Map> transportsByOrigin = Rs2PathApi.getTransports(); - Set transports = new HashSet<>(); - Set transportsOnPlayerTile = transportsByOrigin.get(playerLoc); - if (transportsOnPlayerTile != null) { - transports.addAll(transportsOnPlayerTile); - } - for (WorldPoint reachableTile : Rs2Tile.getReachableTilesFromTile(playerLoc, NEARBY_TRANSPORT_REACH).keySet()) { - Set ts = transportsByOrigin.get(reachableTile); - if (ts != null) { - transports.addAll(ts); - } - } - if (transports.isEmpty()) { + Set reachableOrigins = new HashSet<>( + Rs2Tile.getReachableTilesFromTile(playerLoc, NEARBY_TRANSPORT_REACH).keySet()); + reachableOrigins.add(playerLoc); + List plannedSelections = + Rs2PathApi.getActiveTransportSelections(rawPath); + if (plannedSelections.isEmpty()) { return false; } Map forwardIndex = new HashMap<>(); addForwardPathIndices(forwardIndex, rawPath, playerLoc); addForwardPathIndices(forwardIndex, path, playerLoc); + int rawClosestIndex = Math.max(0, getClosestTileIndex(rawPath, playerLoc)); WorldPoint priorOrigin = routeState.lastTransportOriginLocation; // Trust the pathfinder: only take a nearby transport whose destination is on the @@ -5678,21 +5657,29 @@ private static boolean handleCurrentTileTransportTowardPath(List raw // the pathfinder never chose: it looped forever on the Mor Ul Rek cave entrance/exit and // stalled clicking the Fossil Island rowboat. The pathfinder already routed every transport // it wants onto the path, so on-route membership is the correct, region-safe admission test. - List candidates = transports.stream() - .filter(t -> t.getDestination() != null) + List candidates = plannedSelections.stream() + // One-edge backtrack permits standing just past an interaction origin while preventing + // a repeated destination later in the route from reviving an already-passed transport. + .filter(selection -> selection.getPathIndex() >= Math.max(0, rawClosestIndex - 1)) + .filter(selection -> { + Transport transport = selection.getLocalExecutionTransport(); + WorldPoint origin = transport.getOrigin(); + return origin == null || reachableOrigins.contains(origin); + }) // Local adjacent same-plane edges (doors/gates) are handled by segment door/object // logic; current-tile transport probing can bounce on these and create loops. - .filter(t -> !isAdjacentSamePlaneTransport(t)) - .filter(t -> priorOrigin == null - || !t.getDestination().equals(priorOrigin)) - .filter(t -> target == null + .filter(selection -> !isAdjacentSamePlaneTransport(selection.getLocalExecutionTransport())) + .filter(selection -> priorOrigin == null + || !selection.getEdge().getDestination().equals(priorOrigin)) + .filter(selection -> target == null || playerLoc.getPlane() != target.getPlane() - || t.getDestination().getPlane() == target.getPlane()) - .filter(t -> forwardIndex.containsKey(t.getDestination())) - .sorted(Comparator.comparingInt(t -> forwardIndex.get(t.getDestination()))) + || selection.getEdge().getDestination().getPlane() == target.getPlane()) + .filter(selection -> forwardIndex.containsKey(selection.getEdge().getDestination())) + .sorted(Comparator.comparingInt(Rs2PathApi.ActiveTransportSelection::getPathIndex)) .collect(Collectors.toList()); - for (Transport transport : candidates) { + for (Rs2PathApi.ActiveTransportSelection selection : candidates) { + Transport transport = selection.getLocalExecutionTransport(); WorldPoint origin = transport.getOrigin() != null ? transport.getOrigin() : playerLoc; if (shouldThrottleCurrentTileTransportAttempt(origin, transport.getDestination())) { continue; @@ -5702,7 +5689,7 @@ private static boolean handleCurrentTileTransportTowardPath(List raw // Pass the transport's own origin so handleTransports walks the short hop to it before // interacting (NPC dispatch already auto-walks via canWalkTo + interact); object/door // interactions that can't be reached from here simply return false and we fall through. - if (handleTransports(Arrays.asList(origin, transport.getDestination()), 0)) { + if (handleSelectedTransport(Arrays.asList(origin, transport.getDestination()), 0, selection)) { if (didCurrentTileTransportProgress(before, transport.getDestination(), target)) { log.info("[Walker] Nearby transport handler resolved obstacle: origin={} dest={} (player {})", origin, transport.getDestination(), playerLoc); @@ -5851,7 +5838,7 @@ private static boolean handleDoors(List path, int index) { } private static boolean handleDoors(List path, int index, boolean allowSegmentProbe) { - if (Rs2PathApi.getPathfinder() == null || index >= path.size() - 1) return false; + if (!Rs2PathApi.getActiveRouteStatus().isPresent() || index >= path.size() - 1) return false; // Skip any door whose tile was blacklisted after a prior quest-lock detection — // avoid re-triggering the same failed interact loop this session. @@ -5895,7 +5882,7 @@ private static boolean handleDoors(List path, int index, boolean all return false; } - if (isCatalogBackedTransportSegment(path, index) && !isDoorLikeCatalogTransportSegment(path, index)) { + if (shouldDeferDoorHandlingToTransport(path, index)) { return false; } @@ -6053,9 +6040,7 @@ private static boolean handleDoors(List path, int index, boolean all probe, name, action, dialogue); sessionBlacklistedDoors.add(probe); Rs2Dialogue.clickContinue(); - if (Rs2PathApi.getPathfinderConfig() != null) { - Rs2PathApi.getPathfinderConfig().refresh(); - } + Rs2PathApi.refreshPlanningConfiguration(); recalculatePath(); // Resolved by rerouting; return before the wrong-traversal branch so a // quest/skill-locked door is never learned as a blocked edge (it unlocks when the @@ -6071,10 +6056,8 @@ private static boolean handleDoors(List path, int index, boolean all // so persist it as a learned block that survives restarts and reroutes future paths. // (Quest/skill-locked doors take the isQuestLockedDoorDialogue() branch above and are // deliberately NOT learned — they unlock when the requirement is met.) - if (Rs2PathApi.getPathfinderConfig() != null) { - Rs2PathApi.getPathfinderConfig().learnBlockedEdge(fromWp, toWp, - "wrong-traversal door @ " + compactWorldPoint(probe)); - } + Rs2PathApi.learnBlockedEdge(fromWp, toWp, + "wrong-traversal door @ " + compactWorldPoint(probe)); } if (doorStillHasAction(probe, fromWp, toWp, doorActions, action)) { log.debug("[Walker] Door interaction did not traverse; action still present at {} ({} -> {})", @@ -6200,9 +6183,7 @@ private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, probe, name, action, dialogue); sessionBlacklistedDoors.add(probe); Rs2Dialogue.clickContinue(); - if (Rs2PathApi.getPathfinderConfig() != null) { - Rs2PathApi.getPathfinderConfig().refresh(); - } + Rs2PathApi.refreshPlanningConfiguration(); recalculatePath(); return true; } @@ -6922,16 +6903,12 @@ private static boolean wasStationaryDoorOpenedRecently(WorldPoint doorTile) { return true; } - /** - * Strict catalog step: path tile equals transport origin in {@link Rs2PathApi#getTransports()} - * and next tile equals that row's destination. Used where the walker must dispatch {@code handleTransports} - * from the path index (origin keyed in the TSV-fed multimap). - */ + /** Exact selected transport step retained by the completed active route. */ private static boolean hasExplicitTransportStep(List path, int index) { if (path == null || index < 0 || index >= path.size() - 1) { return false; } - return matchesDirectedTransportCatalogEdge(path.get(index), path.get(index + 1)); + return Rs2PathApi.getActiveTransportEdge(path.get(index), path.get(index + 1)).isPresent(); } /** @@ -7036,27 +7013,41 @@ private static boolean isDoorLikeCatalogTransportSegment(WorldPoint from, WorldP || hasDoorLikeAdjacentOriginShortTransportHop(to, from); } - private static boolean matchesDirectedTransportCatalogEdge(WorldPoint origin, WorldPoint dest) { - if (origin == null || dest == null) { - return false; - } - Set transports = Rs2PathApi.getTransports().get(origin); - if (transports == null || transports.isEmpty()) { + /** + * Catalog transports normally bypass generic door probing so the exact selected edge keeps + * execution ownership. Door-like rows are the compatibility exception because many ordinary + * Open/Pass rows still rely on the door cascade. The Al Kharid toll gate has an explicit + * executor with dialogue and exact-landing semantics, so allowing the generic scanner to take + * it first creates two conflicting completion contracts. + */ + private static boolean shouldDeferDoorHandlingToTransport(List path, int index) { + if (!isCatalogBackedTransportSegment(path, index)) { return false; } - return transports.stream().anyMatch(t -> Objects.equals(t.getDestination(), dest)); + return !isDoorLikeCatalogTransportSegment(path, index) + || isAlKharidTollGateSegment(path.get(index), path.get(index + 1)); } + static boolean isAlKharidTollGateSegment(WorldPoint from, WorldPoint to) { + return from != null + && to != null + && AL_KHARID_TOLL_GATE_POINTS.contains(from) + && AL_KHARID_TOLL_GATE_POINTS.contains(to) + && from.getPlane() == to.getPlane() + && Math.abs(from.getX() - to.getX()) == 1 + && from.getY() == to.getY(); + } + + private static boolean matchesDirectedTransportCatalogEdge(WorldPoint origin, WorldPoint dest) { + return Rs2PathApi.hasCatalogTransportEdge(origin, dest); + } + private static boolean hasDoorLikeDirectedCatalogTransport(WorldPoint origin, WorldPoint dest) { if (origin == null || dest == null) { return false; } - Set transports = Rs2PathApi.getTransports().get(origin); - if (transports == null || transports.isEmpty()) { - return false; - } - return transports.stream() - .anyMatch(t -> Objects.equals(t.getDestination(), dest) && Rs2DoorProbe.isDoorLikeCatalogTransport(t)); + return Rs2PathApi.getCatalogTransportEdges(origin).stream() + .anyMatch(t -> Objects.equals(t.getDestination(), dest) && Rs2DoorProbe.isDoorLikeCatalogTransport(t)); } /** @@ -7073,12 +7064,8 @@ private static boolean matchesAdjacentOriginShortTransportHop(WorldPoint from, W continue; } WorldPoint catalogOrigin = new WorldPoint(from.getX() + dx, from.getY() + dy, from.getPlane()); - Set transports = Rs2PathApi.getTransports().get(catalogOrigin); - if (transports == null || transports.isEmpty()) { - continue; - } - for (Transport t : transports) { - if (Objects.equals(t.getDestination(), to) && isAdjacentSamePlaneTransport(t)) { + for (Rs2TransportEdge t : Rs2PathApi.getCatalogTransportEdges(catalogOrigin)) { + if (Objects.equals(t.getDestination(), to) && isAdjacentSamePlaneTransport(t)) { return true; } } @@ -7097,11 +7084,7 @@ private static boolean hasDoorLikeAdjacentOriginShortTransportHop(WorldPoint fro continue; } WorldPoint catalogOrigin = new WorldPoint(from.getX() + dx, from.getY() + dy, from.getPlane()); - Set transports = Rs2PathApi.getTransports().get(catalogOrigin); - if (transports == null || transports.isEmpty()) { - continue; - } - for (Transport t : transports) { + for (Rs2TransportEdge t : Rs2PathApi.getCatalogTransportEdges(catalogOrigin)) { if (Objects.equals(t.getDestination(), to) && isAdjacentSamePlaneTransport(t) && Rs2DoorProbe.isDoorLikeCatalogTransport(t)) { @@ -7293,7 +7276,7 @@ private static boolean hasUnresolvedDoorLikeObjectNearRawPath(List r if (from.distanceTo2D(playerLoc) > radiusTiles && to.distanceTo2D(playerLoc) > radiusTiles) { continue; } - if (isCatalogBackedTransportSegment(rawPath, ri) && !isDoorLikeCatalogTransportSegment(rawPath, ri)) { + if (shouldDeferDoorHandlingToTransport(rawPath, ri)) { continue; } if (hasUnresolvedDoorLikeSceneObjectOnSegment(from, to, playerLoc, radiusTiles)) { @@ -8358,16 +8341,60 @@ private static HashMap getClosestIndexReachableTiles(WorldP if (playerLoc == null) { return new HashMap<>(); } - HashMap tiles = Rs2Tile.getReachableTilesFromTile(playerLoc, CLOSEST_INDEX_REACHABLE_STEP_BUDGET); + HashMap tiles; + try { + tiles = Rs2Tile.getReachableTilesFromTile( + playerLoc, CLOSEST_INDEX_REACHABLE_STEP_BUDGET); + } catch (RuntimeException failure) { + if (!isClientThreadReadTimeout(failure)) { + throw failure; + } + WebWalkLog.spInfo("client_thread_timeout_fallback | op=closest_route_index"); + return nearbyTilesIgnoringCollision( + playerLoc, CLOSEST_INDEX_REACHABLE_STEP_BUDGET); + } // If an animation/shortcut puts the player on a collision-odd tile, keep route progress // anchored by distance instead of repeatedly recalculating an empty reachable set. if (tiles.isEmpty()) { - tiles = Rs2Tile.getReachableTilesFromTileIgnoreCollision(playerLoc, CLOSEST_INDEX_REACHABLE_STEP_BUDGET); + tiles = nearbyTilesIgnoringCollision( + playerLoc, CLOSEST_INDEX_REACHABLE_STEP_BUDGET); } return tiles; } + static boolean isClientThreadReadTimeout(Throwable failure) { + Throwable current = failure; + while (current != null) { + if (current instanceof TimeoutException) { + return true; + } + current = current.getCause(); + } + return false; + } + + static HashMap nearbyTilesIgnoringCollision( + WorldPoint origin, int radius) { + HashMap result = new HashMap<>(); + if (origin == null || radius < 0) { + return result; + } + int boundedRadius = Math.min(radius, CLOSEST_INDEX_REACHABLE_STEP_BUDGET); + for (int dx = -boundedRadius; dx <= boundedRadius; dx++) { + for (int dy = -boundedRadius; dy <= boundedRadius; dy++) { + int distance = Math.max(Math.abs(dx), Math.abs(dy)); + if (distance <= boundedRadius) { + result.put(new WorldPoint( + origin.getX() + dx, + origin.getY() + dy, + origin.getPlane()), distance); + } + } + } + return result; + } + static int stabilizeRouteProgressIndex(List path, int closestIdx, WorldPoint target, WorldPoint playerLoc) { if (path == null || path.isEmpty() || closestIdx < 0 || closestIdx >= path.size()) { return closestIdx; @@ -8544,13 +8571,53 @@ private static boolean isRecentTransportEdgeCandidate(WorldPoint objectLoc, Worl * Force the walker to recalculate path */ public static void recalculatePath() { + recalculatePath(Rs2PlannerShadowContext.Invocation.ACTIVE_REPLAN); + } + + /** + * Queue one deterministic recovery replan for the active live-test walk. + * + *

The request is consumed by {@code processWalk} on the walker thread so the normal recovery evidence + * context is updated. Production callers cannot enable this hook: it is inert unless the test runner set + * {@code microbot.test.mode=true} and an active target exists.

+ */ + public static boolean requestRecoveryReplanForTest() + { + if (!Boolean.getBoolean("microbot.test.mode") || currentTarget == null) + { + return false; + } + testRecoveryReplanRequests.incrementAndGet(); + return true; + } + + static boolean consumeRecoveryReplanForTest() + { + if (!Boolean.getBoolean("microbot.test.mode")) + { + testRecoveryReplanRequests.set(0); + return false; + } + return testRecoveryReplanRequests.getAndUpdate(value -> Math.max(0, value - 1)) > 0; + } + + private static void recalculatePathForRecovery() { + WalkEvidenceContext evidence = walkEvidenceContext.get(); + if (evidence != null) + { + evidence.recoveryTriggered = true; + } + recalculatePath(Rs2PlannerShadowContext.Invocation.RECOVERY_REPLAN); + } + + private static void recalculatePath(Rs2PlannerShadowContext.Invocation invocation) { WorldPoint goal = currentTarget; if (goal == null) { return; } // Must not call setTarget(null)+setTarget(goal): that briefly clears {@link #currentTarget}, // and processWalk on another thread treats null as cancel (isWalkCancelled). - Rs2WalkerLifecycleRuntime.applyWalkerDestination(goal); + Rs2WalkerLifecycleRuntime.applyWalkerDestination(goal, invocation); } /** @@ -8600,18 +8667,7 @@ public static void setTarget(WorldPoint target, String clearReasonWhenNull) { clearRecentTransportContext(); resetRouteProgress(); logRouteClear(clearReasonWhenNull); - synchronized (Rs2PathApi.getPathfinderMutex()) { - final Pathfinder pathfinder = Rs2PathApi.getPathfinder(); - if (pathfinder != null) { - pathfinder.cancel(); - } - Future pathfinderFuture = Rs2PathApi.getPathfinderFuture(); - if (pathfinderFuture != null && !pathfinderFuture.isDone()) { - pathfinderFuture.cancel(true); - } - Rs2PathApi.setPathfinderFuture(null); - Rs2PathApi.setPathfinder(null); - } + Rs2PathApi.cancelAndClearActiveRoute(); WorldMapPointManager wmm = Microbot.getWorldMapPointManager(); if (wmm != null) { @@ -8688,18 +8744,45 @@ public static Tile getTile(WorldPoint point) { * @return */ private static boolean handleTransports(List path, int indexOfStartPoint) { - if (path != null && indexOfStartPoint >= 0 && indexOfStartPoint < path.size() - 1 - && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(indexOfStartPoint + 1))) { + Optional selection = + Rs2PathApi.getActiveTransportSelection(path, indexOfStartPoint); + if (selection.isEmpty()) { + return false; + } + return handleSelectedTransport(path, indexOfStartPoint, selection.get()); + } + + /** + * Executes the exact transport retained by the active route through its registered Microbot executor. + * Candidate discovery must happen through immutable route steps, never by rescanning the mutable + * transport catalog. The local transport payload is isolated here because POH execution still carries + * subtype behavior that is not part of the planner-independent edge value. + */ + private static boolean handleSelectedTransport(List path, + int indexOfStartPoint, + Rs2PathApi.ActiveTransportSelection selection) { + if (selection == null || !selection.isExecutable()) { + if (selection != null) { + WebWalkLog.spWarn("selected transport has no executor | type={} origin={} dest={}", + selection.getEdge().getType(), + compactWorldPoint(selection.getEdge().getOrigin()), + compactWorldPoint(selection.getEdge().getDestination())); + } + return false; + } + Transport selectedTransport = selection.getLocalExecutionTransport(); + Rs2TerminalTravelMode terminalTravelMode = selection.getEdge().getTerminalTravelMode(); + if (path == null || selectedTransport == null + || indexOfStartPoint < 0 || indexOfStartPoint >= path.size()) { return false; } - Set transports = Rs2PathApi.getTransports().get(path.get(indexOfStartPoint)); - if (transports == null || transports.isEmpty()) { + if (path != null && indexOfStartPoint >= 0 && indexOfStartPoint < path.size() - 1 + && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(indexOfStartPoint + 1))) { return false; } if (log.isDebugEnabled()) { - log.debug("[Walker] handleTransports at {}: {} candidates — {}", path.get(indexOfStartPoint), - transports.size(), - transports.stream().map(Transport::getDisplayInfo).collect(Collectors.joining(", "))); + log.debug("[Walker] handleTransports at {}: exact planned candidate — {} executor={}", + path.get(indexOfStartPoint), selectedTransport.getDisplayInfo(), selection.getExecutor()); } // When the player is inside a POH instance, the player's raw world-location plane is // the instance-template plane and has no relationship to the POH-transport origin plane. @@ -8713,10 +8796,7 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i pathFirstIndex.putIfAbsent(path.get(idx), idx); } - List orderedTransports = new ArrayList<>(transports); - orderedTransports.sort(Comparator.comparingInt(Rs2Walker::transportHandlingPreference)); - - for (Transport transport : orderedTransports) { + for (Transport transport : Collections.singletonList(selectedTransport)) { Collection worldPointCollections; //in some cases the getOrigin is null, for teleports that start the player location if (transport.getOrigin() == null) { @@ -8732,6 +8812,7 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i } log.debug("[Walker] Considering transport: {} (type={}, origin={}, wpCount={})", transport.getDisplayInfo(), transport.getType(), transport.getOrigin(), worldPointCollections.size()); + originLoop: for (WorldPoint origin : worldPointCollections) { WorldPoint plOriginLoop = Rs2Player.getWorldLocation(); if (!inPohInstance && transport.getOrigin() != null && plOriginLoop != null @@ -8795,68 +8876,155 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i } if (path.get(i).equals(origin)) { - if (transport.getType() == TransportType.SHIP || transport.getType() == TransportType.NPC || transport.getType() == TransportType.BOAT) { - - Rs2NpcModel npc = Rs2Npc.getNpc(transport.getName()); + if (selection.getExecutor() == Rs2TransportExecutor.BARROWS_DIG) { + WorldPoint digOrigin = transport.getOrigin(); + WorldPoint playerAtMound = Rs2Player.getWorldLocation(); + if (digOrigin == null || playerAtMound == null || !playerAtMound.equals(digOrigin)) { + // Digging is tile-sensitive. Let the ordinary path click finish the + // approach instead of firing the spade from an adjacent mound tile. + return false; + } + boolean dug = attemptObserved(transport, + () -> Rs2Inventory.interact(ItemID.SPADE, "Dig")); + if (!dug) { + return false; + } + boolean enteredCrypt = Rs2WalkerRuntimeAwaits.awaitCondition( + () -> isPlayerWithinChebyshevOf( + transport.getDestination(), TRANSPORT_NEAR_LANDING_CHEBYSHEV), + TRANSPORT_LANDING_WAIT_POLL_MS, + TRANSPORT_LANDING_WAIT_TIMEOUT_MS); + if (enteredCrypt) { + return finishHandledTransport(transport); + } + WebWalkLog.spWarn( + "Barrows dig post-travel wait timed out ({}ms) dest={} at={}", + TRANSPORT_LANDING_WAIT_TIMEOUT_MS, + compactWorldPoint(transport.getDestination()), + compactWorldPoint(Rs2Player.getWorldLocation())); + return false; + } - // Wrap with observation so Leagues blocked-region chat can attribute this attempt. - if (attemptObserved(transport, () -> npc != null && Rs2Npc.canWalkTo(npc, 20) && Rs2Npc.interact(npc, transport.getAction()))) { - Rs2Player.waitForWalking(); - sleepUntil(Rs2Dialogue::isInDialogue,600*2); - - if (Objects.equals(transport.getName(), "Veos") && Objects.equals(transport.getAction(), "Talk-to")) { - sleepUntil(() -> !Rs2Dialogue.hasContinue(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); - Rs2Dialogue.clickOption("Can you take me somewhere?"); - sleepUntil(() -> !Rs2Dialogue.hasContinue() && !Rs2Dialogue.hasSelectAnOption(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); - Rs2Dialogue.clickOption(transport.getDisplayInfo()); - sleepUntil(() -> !Rs2Dialogue.hasContinue() && !Rs2Dialogue.hasSelectAnOption(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); - } + if (isTerminalTravelTransport(transport.getType())) { + if (terminalTravelMode == Rs2TerminalTravelMode.UNSUPPORTED) { + WebWalkLog.spWarn( + "selected terminal travel has no supported interaction mode | type={} origin={} dest={}", + transport.getType(), compactWorldPoint(transport.getOrigin()), + compactWorldPoint(transport.getDestination())); + break originLoop; + } - if (Objects.equals(transport.getName(), "Captain Magoro") && Objects.equals(transport.getAction(), "Talk-to")) { - sleepUntil(() -> !Rs2Dialogue.hasContinue(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); - Rs2Dialogue.clickOption(transport.getDisplayInfo()); - sleepUntil(() -> !Rs2Dialogue.hasContinue() && !Rs2Dialogue.hasSelectAnOption(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); + Rs2NpcModel npc = Rs2Npc.getNpc(transport.getName()); + if (npc != null && Rs2Npc.canWalkTo(npc, 20)) { + String npcAction = resolveTerminalNpcInteractionAction( + npc, transport); + if (npcAction.isEmpty()) { + WebWalkLog.spWarn( + "terminal NPC has no supported interaction action name={} configured={} dest={}", + transport.getName(), transport.getAction(), transport.getDisplayInfo()); + break originLoop; } - - if (Rs2Dialogue.clickOption("I'm just going to Pirates' cove")){ - sleepTickJitter(2); - Rs2Dialogue.clickContinue(); - } else if (Objects.equals(transport.getName(), "Mountain Guide")) { - Rs2Dialogue.clickOption(transport.getDisplayInfo()); + if (!markTerminalTravelAttempt(transport)) { + log.debug("[Walker] terminal travel edge already attempted this walk: {}", + transport.getDisplayInfo()); + break originLoop; } - sleepUntil(() -> !Rs2Player.isAnimating()); - boolean shipNearDest = sleepUntil( - () -> isPlayerWithinChebyshevOf(transport.getDestination(), TRANSPORT_NEAR_LANDING_CHEBYSHEV), - SHIP_NPC_BOAT_LANDING_WAIT_MS); - if (!shipNearDest) { - WebWalkLog.spWarn( - "ship/npc/boat post-travel wait timed out ({}ms) dest={} at={}", - SHIP_NPC_BOAT_LANDING_WAIT_MS, - compactWorldPoint(transport.getDestination()), - compactWorldPoint(Rs2Player.getWorldLocation())); + if (!npcAction.equalsIgnoreCase(transport.getAction())) { + WebWalkLog.spInfo( + "terminal NPC action fallback name={} configured={} selected={} dest={}", + transport.getName(), transport.getAction(), npcAction, + transport.getDisplayInfo()); } - boolean reachedDestination = shipNearDest; - sleepTickJitter(6); - if (reachedDestination) { - return finishHandledTransport(transport); + + // Wrap with observation so Leagues blocked-region chat can attribute this attempt. + if (attemptObserved(transport, () -> Rs2Npc.interact(npc, npcAction))) { + Rs2Player.waitForWalking(); + sleepUntil(Rs2Dialogue::isInDialogue, 600 * 2); + + if (Objects.equals(transport.getName(), "Veos") && Objects.equals(transport.getAction(), "Talk-to")) { + sleepUntil(() -> !Rs2Dialogue.hasContinue(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); + Rs2Dialogue.clickOption("Can you take me somewhere?"); + sleepUntil(() -> !Rs2Dialogue.hasContinue() && !Rs2Dialogue.hasSelectAnOption(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); + Rs2Dialogue.clickOption(transport.getDisplayInfo()); + sleepUntil(() -> !Rs2Dialogue.hasContinue() && !Rs2Dialogue.hasSelectAnOption(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); + } + + if (Objects.equals(transport.getName(), "Captain Magoro") && Objects.equals(transport.getAction(), "Talk-to")) { + sleepUntil(() -> !Rs2Dialogue.hasContinue(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); + Rs2Dialogue.clickOption(transport.getDisplayInfo()); + sleepUntil(() -> !Rs2Dialogue.hasContinue() && !Rs2Dialogue.hasSelectAnOption(), Rs2Dialogue::clickContinue, 5000, Rs2Random.between(600, 800)); + } + + if (Rs2Dialogue.clickOption("I'm just going to Pirates' cove")) { + sleepTickJitter(2); + Rs2Dialogue.clickContinue(); + } + if (!selectTerminalTravelDialogueDestination( + transport, terminalTravelMode)) { + break originLoop; + } + final int terminalDestinationIndex = precomputedIndexOfDest; + if (awaitTerminalTravelLanding( + transport, path, terminalDestinationIndex)) { + return finishHandledTransport(transport); + } } } else { - WorldPoint originTile = path.get(i); - boolean clicked = Rs2Walker.walkFastCanvas(originTile); - if (!clicked) { - WorldPoint playerLoc = Rs2Player.getWorldLocation(); - if (playerLoc != null) { - clicked = walkMiniMapToward(originTile, playerLoc, 13); + TileObject terminalObject = findTerminalTravelObject(transport); + if (terminalObject != null) { + String objectAction = resolveTransportObjectAction( + terminalObject, + Collections.singletonList(transport.getAction())) + .orElse(""); + if (objectAction.isEmpty()) { + WebWalkLog.spWarn( + "terminal object has no supported interaction action name={} configured={} dest={}", + transport.getName(), transport.getAction(), transport.getDisplayInfo()); + break originLoop; } + if (!markTerminalTravelAttempt(transport)) { + log.debug("[Walker] terminal travel edge already attempted this walk: {}", + transport.getDisplayInfo()); + break originLoop; + } + prepareTransportObjectForInteraction(terminalObject); + final TileObject selectedTerminalObject = terminalObject; + if (attemptObserved(transport, () -> Rs2GameObject.interact( + selectedTerminalObject, objectAction))) { + if (!selectTerminalTravelDialogueDestination( + transport, terminalTravelMode)) { + break originLoop; + } + final int terminalDestinationIndex = precomputedIndexOfDest; + if (awaitTerminalTravelLanding( + transport, path, terminalDestinationIndex)) { + return finishHandledTransport(transport); + } + } + } else { + WorldPoint originTile = path.get(i); + boolean clicked = Rs2Walker.walkFastCanvas(originTile); + if (!clicked) { + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + if (playerLoc != null) { + clicked = walkMiniMapToward(originTile, playerLoc, 13); + } + } + if (!clicked) { + clicked = Rs2Walker.walkMiniMap(originTile); + } + if (!clicked) { + log.debug("[Walker] terminal travel fallback click failed for {}", originTile); + } + sleep(1200, 1600); } - if (!clicked) { - clicked = Rs2Walker.walkMiniMap(originTile); - } - if (!clicked) { - log.debug("[Walker] ship/npc/boat fallback click failed for {}", originTile); - } - sleep(1200, 1600); } + + // Terminal travel is terminal for this transport scan. The exact edge can be + // clicked at most once in one top-level walk invocation; callers can start + // a fresh walk after a surfaced failure, but this invocation never spams the + // target for later path indices or another local-instance copy of the origin. + break originLoop; } if (transport.getType() == TransportType.CHARTER_SHIP) { @@ -8909,8 +9077,29 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i } } + if (transport.getType() == TransportType.HOT_AIR_BALLOON) { + if (attemptObserved(transport, () -> Rs2HotAirBalloon.handle(selection.getEdge()))) { + boolean balloonLanded = Rs2WalkerRuntimeAwaits.awaitCondition( + () -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), + TRANSPORT_LANDING_WAIT_POLL_MS, + TRANSPORT_LANDING_WAIT_TIMEOUT_MS); + if (balloonLanded) { + sleepTickJitter(2); + return finishHandledTransport(transport); + } + WebWalkLog.spWarn( + "hot-air balloon post-travel wait timed out ({}ms) dest={} at={}", + TRANSPORT_LANDING_WAIT_TIMEOUT_MS, + compactWorldPoint(transport.getDestination()), + compactWorldPoint(Rs2Player.getWorldLocation())); + } + // This is a specialized map interaction. Do not fall through to the generic + // object handler and click the same basket again during this walker tick. + return false; + } + if (transport.getType() == TransportType.SPIRIT_TREE) { - if (!Rs2PathApi.getPathfinderConfig().isUseSpiritTrees()) { + if (!Rs2PathApi.isSpiritTreeTravelEnabled()) { log.debug("[Walker] skip spirit tree transport — setting is off"); continue; } @@ -9049,13 +9238,24 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i // were queued, so the next run distinguishes "one slow scan" from "many scans". long objectScanStartedAt = System.currentTimeMillis(); final Integer legacyClosedId = OPEN_TO_CLOSED_MAPPINGS.get(transportObjectId); - // Id-only first: these are plain field reads, no composition resolution. - List matched = Rs2GameObject.getAll(o -> { - int id = o.getId(); - if (id == transportObjectId) return true; - if (allowAlKharidTollGateVariant && isAlKharidTollGateObjectId(id)) return true; - return legacyClosedId != null && id == legacyClosedId; - }, transport.getOrigin(), 10); + // Most catalog transports can use their stable object id. The Al Kharid gate cannot: + // its historical catalog ids collide with unrelated live objects in newer injected-client + // revisions. Select that edge by its transformed live composition and route geometry instead. + // This deliberately has no id fallback: clicking an unrelated object is worse than failing + // closed and replanning. + List matched; + if (allowAlKharidTollGateVariant) { + matched = Rs2GameObject.getAll( + o -> isAlKharidTollGateSceneCandidate(transport, o), + transport.getOrigin(), 3); + } else { + // Id-only first: these are plain field reads, no composition resolution. + matched = Rs2GameObject.getAll(o -> { + int id = o.getId(); + if (id == transportObjectId) return true; + return legacyClosedId != null && id == legacyClosedId; + }, transport.getOrigin(), 10); + } if (matched.isEmpty() && allowClosedVariant) { // Only now pay for compositions, and only on the transport's own tile: a closed // variant (trapdoor/manhole/grate/hatch) sits where the transport is, never ten @@ -9083,7 +9283,7 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i long objectScanMs = System.currentTimeMillis() - objectScanStartedAt; if (objectScanMs >= TRANSPORT_OBJECT_SCAN_SLOW_MS) { WebWalkLog.spInfo("transport_object_scan | slow scanMs={} objectId={} candidatesAtTile={} matches={} origin={}", - objectScanMs, transportObjectId, orderedTransports.size(), objects.size(), + objectScanMs, transportObjectId, 1, objects.size(), compactWorldPoint(transport.getOrigin())); } TileObject object = objects.stream().findFirst().orElse(null); @@ -9296,11 +9496,13 @@ private static List getTransportActionOptions(String action) { } private static Optional resolveTransportObjectAction(TileObject object, List actionOptions) { - ObjectComposition comp = Rs2GameObject.convertToObjectComposition(object); - if (comp == null || comp.getActions() == null) { - return Optional.empty(); - } - return resolveTransportObjectAction(comp.getActions(), actionOptions); + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(object); + if (comp == null || comp.getActions() == null) { + return Optional.empty(); + } + return resolveTransportObjectAction(comp.getActions(), actionOptions); + }).orElse(Optional.empty()); } private static Optional resolveTransportObjectAction(String[] objectActions, List actionOptions) { @@ -9365,6 +9567,13 @@ private static boolean handleObject(Transport transport, TileObject tileObject, ensureRequiredItemBeforeTransport(transport); WorldPoint before = Rs2Player.getWorldLocation(); Rs2GameObject.interact(tileObject, action); + // Unlike the other exception handlers, a toll-gate interaction is not complete merely + // because the menu action was issued: it may first server-walk from several tiles away and + // then present a confirmation dialogue. Bubble an unobserved crossing back to the caller so + // it cannot emit a transport handoff for a player who is still west/east of the gate. + if (isAlKharidTollGateTransport(transport) && isPayTollAction(transport.getAction())) { + return handleAlKharidTollGate(transport); + } if (handleObjectExceptions(transport, tileObject)) return true; WorldPoint tdObj = transport.getDestination(); WorldPoint plObj = Rs2Player.getWorldLocation(); @@ -9459,13 +9668,21 @@ private static boolean handleObject(Transport transport, TileObject tileObject, } } - private static boolean isAdjacentSamePlaneTransport(Transport transport) { - return transport != null + private static boolean isAdjacentSamePlaneTransport(Transport transport) { + return transport != null && transport.getOrigin() != null && transport.getDestination() != null && transport.getOrigin().getPlane() == transport.getDestination().getPlane() - && transport.getOrigin().distanceTo(transport.getDestination()) <= 1; - } + && transport.getOrigin().distanceTo(transport.getDestination()) <= 1; + } + + private static boolean isAdjacentSamePlaneTransport(Rs2TransportEdge transport) { + return transport != null + && transport.getOrigin() != null + && transport.getDestination() != null + && transport.getOrigin().getPlane() == transport.getDestination().getPlane() + && transport.getOrigin().distanceTo(transport.getDestination()) <= 1; + } private static int[] mapSmoothedToRaw(List smoothed, List raw) { if (smoothed == null || raw == null || smoothed.isEmpty() || raw.isEmpty()) { @@ -9669,14 +9886,9 @@ private static boolean isObjectInteractionTransportStep(List rawPath if (rawPath == null || index < 0 || index >= rawPath.size() - 1) { return false; } - Set transports = Rs2PathApi.getTransports().get(rawPath.get(index)); - if (transports == null || transports.isEmpty()) { - return false; - } - WorldPoint next = rawPath.get(index + 1); - return transports.stream() - .filter(t -> t != null && Objects.equals(t.getDestination(), next)) - .anyMatch(t -> t.getType() == TransportType.TRANSPORT); + return Rs2PathApi.getActiveTransportEdge(rawPath.get(index), rawPath.get(index + 1)) + .map(edge -> edge.getType() == Rs2TransportType.TRANSPORT) + .orElse(false); } /** Config kill switch for ranged transport dispatch; on when the config is unavailable. */ @@ -9873,11 +10085,11 @@ private static boolean hasPrecomputedContinuationFromTransport(Transport transpo if (transport == null || transport.getDestination() == null) { return false; } - Pathfinder pathfinder = Rs2PathApi.getPathfinder(); - if (pathfinder == null || !pathfinder.isDone()) { + Rs2ActiveRouteStatus routeStatus = Rs2PathApi.getActiveRouteStatus(); + if (!routeStatus.isReady()) { return false; } - List walkPath = pathfinder.getWalkablePath(); + List walkPath = routeStatus.getWalkablePath(); if (walkPath == null || walkPath.size() < 2) { return false; } @@ -9930,11 +10142,196 @@ static Set adjacentSamePlaneTransportSuppressionPoints(Transport tra return points; } - private static int transportHandlingPreference(Transport transport) { - if (isAlKharidTollGateTransport(transport) && transport.getCurrencyAmount() > 0) { - return 1; + static boolean isTerminalTravelTransport(TransportType transportType) { + return transportType == TransportType.SHIP + || transportType == TransportType.NPC + || transportType == TransportType.BOAT; + } + + private static boolean selectTerminalTravelDialogueDestination( + Transport transport, Rs2TerminalTravelMode mode) { + if (mode == Rs2TerminalTravelMode.DIRECT) { + return true; + } + if (mode != Rs2TerminalTravelMode.DIALOGUE_DESTINATION + || transport == null + || transport.getDisplayInfo() == null + || transport.getDisplayInfo().isBlank()) { + return false; + } + if (!sleepUntil(Rs2Dialogue::hasSelectAnOption, 5000)) { + WebWalkLog.spWarn( + "terminal travel destination dialogue did not appear name={} dest={}", + transport.getName(), transport.getDisplayInfo()); + return false; + } + if (!Rs2Dialogue.clickOption(transport.getDisplayInfo())) { + WebWalkLog.spWarn( + "terminal travel destination option missing name={} dest={}", + transport.getName(), transport.getDisplayInfo()); + return false; + } + return true; + } + + private static TileObject findTerminalTravelObject(Transport transport) { + if (transport == null || transport.getOrigin() == null) { + return null; + } + TileObject object = Rs2GameObject.getAll( + candidate -> isTerminalTravelObjectSceneCandidate(transport, candidate), + transport.getOrigin(), 3).stream().findFirst().orElse(null); + if (object != null) { + WebWalkLog.spInfo( + "terminal travel object selected type={} name={} action={} origin={} dest={}", + transport.getType(), transport.getName(), transport.getAction(), + compactWorldPoint(transport.getOrigin()), + compactWorldPoint(transport.getDestination())); + } + return object; + } + + private static boolean isTerminalTravelObjectSceneCandidate(Transport transport, + TileObject object) { + if (object == null) { + return false; + } + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + ObjectComposition composition = Rs2DoorDetection.resolveCompositionForDoorProbe(object); + return composition != null + && isTerminalTravelObjectCompositionCandidate( + transport, + object.getWorldLocation(), + composition.getName(), + composition.getActions()); + }).orElse(false); + } + + static boolean isTerminalTravelObjectCompositionCandidate(Transport transport, + WorldPoint objectLocation, + String objectName, + String[] objectActions) { + if (transport == null + || !isTerminalTravelTransport(transport.getType()) + || transport.getOrigin() == null + || objectLocation == null + || objectName == null + || transport.getName() == null + || transport.getAction() == null + || objectLocation.getPlane() != transport.getOrigin().getPlane() + || objectLocation.distanceTo2D(transport.getOrigin()) > 3 + || !Rs2UiHelper.stripColTags(objectName).trim().equalsIgnoreCase( + Rs2UiHelper.stripColTags(transport.getName()).trim())) { + return false; + } + return resolveTransportObjectAction( + objectActions, + Collections.singletonList(transport.getAction())).isPresent(); + } + + private static boolean awaitTerminalTravelLanding(Transport transport, + List path, + int destinationIndex) { + boolean landed = sleepUntil( + () -> hasReachedTerminalTravelLanding( + transport, path, destinationIndex, Rs2Player.getWorldLocation()), + SHIP_NPC_BOAT_LANDING_WAIT_MS); + if (!landed) { + WebWalkLog.spWarn( + "ship/npc/boat post-travel wait timed out ({}ms) dest={} at={}", + SHIP_NPC_BOAT_LANDING_WAIT_MS, + compactWorldPoint(transport.getDestination()), + compactWorldPoint(Rs2Player.getWorldLocation())); + } + return landed; + } + + /** + * Returns interaction actions in executor preference order. Some legacy ship rows encode their + * destination label as the direct NPC menu action. The current Port Sarim NPCs instead expose + * {@code Travel}; keep the configured label first for compatible clients, then use that observed + * live fallback. Explicit dialogue and quick-travel actions must never be replaced implicitly. + */ + static List terminalNpcInteractionCandidates(TransportType transportType, + String configuredAction) { + LinkedHashSet candidates = new LinkedHashSet<>(); + if (configuredAction != null && !configuredAction.isBlank()) { + candidates.add(configuredAction); + } + if (transportType == TransportType.SHIP + && !isExplicitShipMenuAction(configuredAction)) { + candidates.add("Travel"); + } + return List.copyOf(candidates); + } + + private static boolean isExplicitShipMenuAction(String action) { + return action != null + && (action.equalsIgnoreCase("Travel") + || action.equalsIgnoreCase("Talk-to") + || action.equalsIgnoreCase("Quick-Travel") + || action.equalsIgnoreCase("Take-boat")); + } + + private static String resolveTerminalNpcInteractionAction(Rs2NpcModel npc, Transport transport) { + if (npc == null || transport == null) { + return ""; + } + for (String candidate : terminalNpcInteractionCandidates( + transport.getType(), transport.getAction())) { + // Query one candidate at a time: Rs2Npc#getAvailableAction otherwise returns NPC-menu + // order, which commonly places Talk-to before the exact configured action. + String available = Rs2Npc.getAvailableAction(npc, Collections.singletonList(candidate)); + if (!available.isEmpty()) { + return available; + } + } + return ""; + } + + static boolean markTerminalTravelAttempt(Transport transport) { + if (transport == null || transport.getOrigin() == null || transport.getDestination() == null) { + return false; + } + String key = transport.getType() + + "|" + rangedTransportEdgeKey(transport.getOrigin(), transport.getDestination()) + + "|" + transport.getObjectId() + + "|" + Objects.toString(transport.getName(), "") + + "|" + Objects.toString(transport.getAction(), ""); + return TERMINAL_TRAVEL_ATTEMPTED_EDGES.add(key); + } + + /** + * Accepts the exact catalogued landing or the immediately following path point. The latter covers + * modern ship travel that skips an obsolete deck tile and completes the next gangplank step in one + * server action. It deliberately does not scan arbitrary later route points, which could report a + * false landing when a route loops near its origin. + */ + static boolean hasReachedTerminalTravelLanding(Transport transport, + List path, + int destinationIndex, + WorldPoint playerLocation) { + if (transport == null || playerLocation == null || transport.getDestination() == null) { + return false; + } + WorldPoint origin = transport.getOrigin(); + if (origin != null + && origin.getPlane() == playerLocation.getPlane() + && origin.distanceTo2D(playerLocation) <= 1) { + return false; + } + if (isNearSamePlane(playerLocation, transport.getDestination(), + TRANSPORT_NEAR_LANDING_CHEBYSHEV)) { + return true; } - return 0; + if (path == null || destinationIndex < 0 || destinationIndex + 1 >= path.size()) { + return false; + } + WorldPoint immediateContinuation = path.get(destinationIndex + 1); + return immediateContinuation != null + && !immediateContinuation.equals(transport.getDestination()) + && isNearSamePlane(playerLocation, immediateContinuation, + TRANSPORT_NEAR_LANDING_CHEBYSHEV); } private static boolean isAlKharidTollGateTransport(Transport transport) { @@ -9952,31 +10349,75 @@ private static boolean isPayTollAction(String action) { return action != null && action.toLowerCase(Locale.ROOT).startsWith("pay-toll"); } + private static boolean isAlKharidTollGateSceneCandidate(Transport transport, TileObject object) { + if (!(object instanceof WallObject) && !(object instanceof GameObject)) { + return false; + } + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + ObjectComposition comp = Rs2DoorDetection.resolveCompositionForDoorProbe(object); + return comp != null + && isAlKharidTollGateCompositionCandidate( + transport, object.getWorldLocation(), comp.getName(), comp.getActions()) + && Rs2DoorGeometry.isDoorOnSegment( + object, transport.getOrigin(), transport.getDestination()); + }).orElse(false); + } + + static boolean isAlKharidTollGateCompositionCandidate(Transport transport, + WorldPoint objectLocation, + String objectName, + String[] objectActions) { + if (!isAlKharidTollGateTransport(transport) + || objectLocation == null + || !AL_KHARID_TOLL_GATE_POINTS.contains(objectLocation) + || objectName == null + || !objectName.toLowerCase(Locale.ROOT).contains("gate")) { + return false; + } + return resolveTransportObjectAction( + objectActions, getTransportActionOptions(transport.getAction())).isPresent(); + } + + static boolean hasReachedAlKharidTollDestination(Transport transport, WorldPoint playerLocation) { + return isAlKharidTollGateTransport(transport) + && playerLocation != null + && playerLocation.equals(transport.getDestination()); + } + private static boolean handleAlKharidTollGate(Transport transport) { - if (Rs2Player.isMoving()) { + // Object interaction can begin out of range. Wait for server-walking, the confirmation + // dialogue, or the crossing itself instead of sampling isMoving() immediately after click. + sleepUntil(() -> Rs2Player.isMoving() + || Rs2Dialogue.hasSelectAnOption() + || hasReachedAlKharidTollDestination(transport, Rs2Player.getWorldLocation()), + AL_KHARID_TOLL_INTERACTION_START_WAIT_MS); + + if (Rs2Player.isMoving() + && !hasReachedAlKharidTollDestination(transport, Rs2Player.getWorldLocation())) { Rs2Player.waitForWalking(); } boolean confirmed = false; - if (sleepUntil(Rs2Dialogue::hasSelectAnOption, 2500)) { + if (!hasReachedAlKharidTollDestination(transport, Rs2Player.getWorldLocation()) + && (Rs2Dialogue.hasSelectAnOption() + || sleepUntil(Rs2Dialogue::hasSelectAnOption, + AL_KHARID_TOLL_INTERACTION_START_WAIT_MS))) { confirmed = Rs2Dialogue.clickOption("Yes, okay", "Yes"); } - boolean reachedDestination = sleepUntil(() -> { - WorldPoint now = Rs2Player.getWorldLocation(); - WorldPoint destination = transport.getDestination(); - return now != null - && destination != null - && now.getPlane() == destination.getPlane() - && now.distanceTo2D(destination) <= 1; - }, POST_HANDLE_OBJECT_LANDING_WAIT_MS); - if (!confirmed && !reachedDestination) { + boolean reachedDestination = hasReachedAlKharidTollDestination( + transport, Rs2Player.getWorldLocation()) + || sleepUntil(() -> hasReachedAlKharidTollDestination( + transport, Rs2Player.getWorldLocation()), + POST_HANDLE_OBJECT_LANDING_WAIT_MS); + if (!reachedDestination) { WebWalkLog.spWarn( - "Al Kharid toll gate confirmation unresolved dest={} at={}", + "Al Kharid toll gate crossing unresolved confirmed={} dest={} at={}", + confirmed, compactWorldPoint(transport.getDestination()), compactWorldPoint(Rs2Player.getWorldLocation())); } - return true; + return reachedDestination; } private static boolean handleObjectExceptions(Transport transport, TileObject tileObject) { @@ -10010,10 +10451,6 @@ private static boolean handleObjectExceptions(Transport transport, TileObject ti } } - if (isAlKharidTollGateTransport(transport) && isPayTollAction(transport.getAction())) { - return handleAlKharidTollGate(transport); - } - //Al kharid broken wall will animate once and then stop and then animate again if (tileObject.getId() == ObjectID.KHARID_POSHWALL_TOPLESS || tileObject.getId() == ObjectID.KHARID_BIGWINDOW) { Rs2Player.waitForAnimation(); @@ -10179,7 +10616,9 @@ private static boolean handleWildernessObelisk(Transport transport) { } private static boolean handleTeleportSpell(Transport transport) { - if (Rs2Pvp.isInWilderness() && (Rs2Pvp.getWildernessLevelFrom(Rs2Player.getWorldLocation()) > (transport.getMaxWildernessLevel() + 1))) return false; + if (Rs2Pvp.isInWilderness() && !isTeleportAllowedAtWildernessLevel( + Rs2Pvp.getWildernessLevelFrom(Rs2Player.getWorldLocation()), transport.getMaxWildernessLevel())) return false; + if (!prepareTeleportSpellProviders(transport)) return false; boolean hasMultipleDestination = transport.getDisplayInfo().contains(":"); String spellName = hasMultipleDestination @@ -10194,16 +10633,84 @@ private static boolean handleTeleportSpell(Transport transport) { ? 2 : 1; + Optional homeTeleport = + TransportExecutionRegistry.homeTeleportFor(transport.getDisplayInfo()); + if (homeTeleport.isPresent()) { + return Rs2Magic.quickCast(homeTeleport.get().getDisplayName()); + } + MagicAction magicSpell = Arrays.stream(MagicAction.values()).filter(x -> x.getName().toLowerCase().contains(spellName)).findFirst().orElse(null); if (magicSpell != null) { - if (magicSpell == MagicAction.LUMBRIDGE_HOME_TELEPORT) { - return Rs2Magic.quickCast(magicSpell); - } return Rs2Magic.cast(magicSpell, option, identifier); } return false; } + /** + * Equip any inventory staff/tome selected by a source-aware upstream spell requirement before + * casting. An item merely present in the inventory never acts as an infinite rune provider. + */ + private static boolean prepareTeleportSpellProviders(Transport transport) { + List requirements = transport.getItemRequirements(); + if (requirements == null || requirements.isEmpty()) { + return true; + } + + Map runeQuantities = new HashMap<>(); + Rs2Magic.getRunes().forEach((rune, quantity) -> + runeQuantities.put(rune.getItemId(), quantity)); + java.util.function.IntUnaryOperator currentQuantity = itemId -> { + Runes rune = Runes.byItemId(itemId); + if (rune != null) { + return runeQuantities.getOrDefault(itemId, 0); + } + int quantity = Rs2Inventory.itemQuantity(itemId); + Rs2ItemModel equipped = Rs2Equipment.get(itemId); + return equipped == null ? quantity : quantity + Math.max(1, equipped.getQuantity()); + }; + + TransportItemRequirement.ProviderSelection providers = + TransportItemRequirement.selectProviders( + requirements, + currentQuantity, + itemId -> Rs2Equipment.isWearing(itemId) || Rs2Inventory.hasItem(itemId), + itemId -> Rs2Equipment.isWearing(itemId) || Rs2Inventory.hasItem(itemId)) + .orElse(null); + if (providers == null) { + return false; + } + if (!equipTransportProvider(providers.getStaffItemId()) + || !equipTransportProvider(providers.getOffhandItemId())) { + return false; + } + + Map verifiedRuneQuantities = new HashMap<>(); + Rs2Magic.getRunes().forEach((rune, quantity) -> + verifiedRuneQuantities.put(rune.getItemId(), quantity)); + return TransportItemRequirement.selectProviders( + requirements, + itemId -> { + Runes rune = Runes.byItemId(itemId); + if (rune != null) { + return verifiedRuneQuantities.getOrDefault(itemId, 0); + } + int quantity = Rs2Inventory.itemQuantity(itemId); + Rs2ItemModel equipped = Rs2Equipment.get(itemId); + return equipped == null ? quantity : quantity + Math.max(1, equipped.getQuantity()); + }, + Rs2Equipment::isWearing, + Rs2Equipment::isWearing).isPresent(); + } + + private static boolean equipTransportProvider(int itemId) { + if (itemId <= 0 || Rs2Equipment.isWearing(itemId)) { + return true; + } + return Rs2Inventory.hasItem(itemId) + && Rs2Inventory.wield(itemId) + && sleepUntil(() -> Rs2Equipment.isWearing(itemId), 3000); + } + private static boolean isLumbridgeHomeTeleport(Transport transport) { return transport.getDisplayInfo() != null && transport.getDisplayInfo().toLowerCase().startsWith("lumbridge home teleport"); @@ -10212,7 +10719,8 @@ private static boolean isLumbridgeHomeTeleport(Transport transport) { private static boolean handleTeleportItem(Transport transport) { WorldPoint plWild = Rs2Player.getWorldLocation(); if (Rs2Pvp.isInWilderness() && plWild != null - && Rs2Pvp.getWildernessLevelFrom(plWild) > (transport.getMaxWildernessLevel() + 1)) { + && !isTeleportAllowedAtWildernessLevel( + Rs2Pvp.getWildernessLevelFrom(plWild), transport.getMaxWildernessLevel())) { return false; } boolean succesfullAction = false; @@ -10246,11 +10754,9 @@ private static boolean handleInventoryTeleports(Transport transport, int itemId) // Return true when the item does not use a generic keyword to teleport to its destination boolean hasParsableDestination = transport.getDisplayInfo().contains(":"); - String destination = hasParsableDestination - ? transport.getDisplayInfo().split(":")[1].trim().toLowerCase() - : transport.getDisplayInfo().trim().toLowerCase(); + String destination = teleportItemLeafAction(transport.getDisplayInfo()); - boolean wildernessTransport = PathfinderConfig.isInWilderness(WorldPointUtil.packWorldPoint(transport.getDestination())); + boolean wildernessTransport = Rs2PathApi.isInWilderness(transport.getDestination()); log.debug("Trying to find action for destination={}", destination); // Check if item has destination as direct action @@ -10322,8 +10828,7 @@ private static boolean handleWearableTeleports(Transport transport, int itemId) Rs2ItemModel rs2Item = Rs2Equipment.get(itemId); if (rs2Item == null) return false; if (transport.getDisplayInfo().contains(":")) { - String[] values = transport.getDisplayInfo().split(":"); - String destination = values[1].trim().toLowerCase(); + String destination = teleportItemLeafAction(transport.getDisplayInfo()); if (transport.getDisplayInfo().toLowerCase().contains("slayer ring")) { Rs2Equipment.invokeMenu(rs2Item, "teleport"); @@ -10342,6 +10847,23 @@ private static boolean handleWearableTeleports(Transport transport, int itemId) return false; } + /** + * Returns the executable leaf from a display hierarchy. Upstream labels may describe nested + * categories (for example {@code Max cape: POH Portals: Rimmington}); RuneLite item sub-ops are + * looked up by their leaf action, not by the intermediate display category. + */ + static String teleportItemLeafAction(String displayInfo) { + if (displayInfo == null) { + return ""; + } + String[] segments = displayInfo.split(":"); + return segments[segments.length - 1].trim().toLowerCase(Locale.ROOT); + } + + static boolean isTeleportAllowedAtWildernessLevel(int currentLevel, int maximumLevel) { + return currentLevel <= maximumLevel; + } + /** * Checks if the teleport item requires dialogue-based destination selection. * These are items that, when rubbed/activated, show a dialogue menu to choose destination. @@ -10406,10 +10928,9 @@ public static boolean isInArea(WorldPoint centerOfArea, int range) { } public static boolean isNear() { - final Pathfinder pathfinder = Rs2PathApi.getPathfinder(); - if (pathfinder == null) return false; // idk are we near if we don't have a path? - final List path = pathfinder.getPath(); - if (path == null) return false; + final Rs2ActiveRouteStatus routeStatus = Rs2PathApi.getActiveRouteStatus(); + if (!routeStatus.isPresent()) return false; // idk are we near if we don't have a path? + final List path = routeStatus.getRawPath(); WorldPoint playerLocation = Rs2Player.getWorldLocation(); if (playerLocation == null) { @@ -10435,11 +10956,11 @@ public static boolean isNear(WorldPoint target) { } public static boolean isNearPath() { - final Pathfinder pathfinder = Rs2PathApi.getPathfinder(); - if (pathfinder == null) return true; + final Rs2ActiveRouteStatus routeStatus = Rs2PathApi.getActiveRouteStatus(); + if (!routeStatus.isPresent()) return true; - final List path = pathfinder.getWalkablePath(); - if (path == null || path.isEmpty()) return true; + final List path = routeStatus.getWalkablePath(); + if (path.isEmpty()) return true; final WorldPoint loc = Rs2Player.getWorldLocation(); if (loc == null) return true; @@ -10669,12 +11190,12 @@ private static void checkIfStuck() { private static final long MINIMAP_CLICK_STALL_GRACE_MS = 12_000L; private static boolean interactingActorNearWalkablePath() { - Pathfinder pf = Rs2PathApi.getPathfinder(); - if (pf == null) { + Rs2ActiveRouteStatus routeStatus = Rs2PathApi.getActiveRouteStatus(); + if (!routeStatus.isPresent()) { return false; } - List path = pf.getWalkablePath(); - if (path == null || path.isEmpty()) { + List path = routeStatus.getWalkablePath(); + if (path.isEmpty()) { return false; } Actor actor = Rs2Player.getInteracting(); @@ -10728,11 +11249,10 @@ private static boolean isStuckTooLong() { * @param start */ public void setStart(WorldPoint start) { - Pathfinder pathfinder = Rs2PathApi.getPathfinder(); - if (pathfinder == null) { + Set targets = Rs2PathApi.getActiveRouteTargets(); + if (targets.isEmpty()) { return; } - Set targets = pathfinder.getTargets(); Rs2PathApi.setStartPointSet(true); if (isClientThread()) { Microbot.getClientThread().runOnSeperateThread(() -> restartPathfinding(start, targets)); @@ -10768,15 +11288,10 @@ public static WorldPoint nearestReachable(WorldPoint start, Collection path = pathfinder.getPath(); - if (path == null || path.isEmpty()) { - return null; - } // A partial path ends somewhere that is NOT a target; only trust an endpoint we asked for. - WorldPoint endpoint = path.get(path.size() - 1); - return targets.contains(endpoint) ? endpoint : null; + return Rs2PathApi.plan(Rs2RouteRequest.toAny(start, targets)) + .getReachedTarget(0) + .orElse(null); } /** @@ -10787,10 +11302,7 @@ public static WorldPoint nearestReachable(WorldPoint start, Collection ends = Set.of(endpoint); - Pathfinder pathfinder = new Pathfinder(Rs2PathApi.getPathfinderConfig(), startpoint, ends); - pathfinder.run(); - return pathfinder.getPath().size(); + return Rs2PathApi.plan(Rs2RouteRequest.to(startpoint, endpoint)).getPath().size(); } /** @@ -11071,6 +11583,26 @@ private static boolean handleMinigameTeleport(Transport transport) { return sleepUntilTrue(() -> !Rs2Player.isAnimating() && teleportGraphics.stream().noneMatch(Rs2Player::hasSpotAnimation), 100, 20000); } + static int canoeMapMainComponentId(int stationObjectId) { + if (stationObjectId >= 60845 && stationObjectId <= 60849) { + return InterfaceID.CanoeMapDougne.MAIN_MAP; + } + if ((stationObjectId >= 12163 && stationObjectId <= 12166) || stationObjectId == 39638) { + return InterfaceID.CanoeMapLum.MAIN_MAP; + } + return -1; + } + + static int canoeMapDestinationsComponentId(int stationObjectId) { + if (stationObjectId >= 60845 && stationObjectId <= 60849) { + return InterfaceID.CanoeMapDougne.DESTINATIONS; + } + if ((stationObjectId >= 12163 && stationObjectId <= 12166) || stationObjectId == 39638) { + return InterfaceID.CanoeMapLum.DESTINATIONS; + } + return -1; + } + private static boolean handleCanoe(Transport transport) { String displayInfo = transport.getDisplayInfo(); if (displayInfo == null || displayInfo.isEmpty()) return false; @@ -11144,6 +11676,12 @@ private static boolean handleCanoe(Transport transport) { return Arrays.stream(composition.getActions()).filter(Objects::nonNull).noneMatch(currentAction::equals) && !Rs2Player.isAnimating(); }, 300, 10000); case "Paddle Canoe": + int canoeMapMain = canoeMapMainComponentId(transport.getObjectId()); + int canoeMapDestinations = canoeMapDestinationsComponentId(transport.getObjectId()); + if (canoeMapMain < 0 || canoeMapDestinations < 0) { + log.error("Unsupported canoe station object id: {}", transport.getObjectId()); + return false; + } if (!Rs2GameObject.interact(transport.getObjectId(), "Paddle Canoe")) { log.error("Failed to interact with canoe station"); return false; @@ -11155,18 +11693,17 @@ private static boolean handleCanoe(Transport transport) { sleepUntil(Rs2Player::isMoving, 2000); sleepUntilTrue(() -> !Rs2Player.isMoving(), 100, 30000); - // OSRS update moved the canoe destination map from group 647 to - // CanoeMapLum (953) for the river Lum chain. CanoeMapDougne (952) - // is for a different chain not currently used by canoes.tsv. + // OSRS uses separate interfaces for the River Lum and River Dougne chains. boolean isDestinationMapVisible = sleepUntilTrue( - () -> Rs2Widget.isWidgetVisible(InterfaceID.CanoeMapLum.MAIN_MAP), + () -> Rs2Widget.isWidgetVisible(canoeMapMain), 100, 10000); if (!isDestinationMapVisible) { - log.error("Canoe destination map (CanoeMapLum) not visible within timeout period"); + log.error("Canoe destination map not visible within timeout period for station {}", + transport.getObjectId()); return false; } - Widget destinationListWidget = Rs2Widget.getWidget(InterfaceID.CanoeMapLum.DESTINATIONS); + Widget destinationListWidget = Rs2Widget.getWidget(canoeMapDestinations); if (destinationListWidget == null) return false; Widget destination = Rs2Widget.findWidget("Travel to " + displayInfo, List.of(destinationListWidget), false); if (destination == null) { @@ -11208,16 +11745,16 @@ private static String pickQuetzalWhistleInventoryMenuAction(Rs2ItemModel rs2Item /** * Labels match {@code quetzals.tsv} destination rows (map icon text). */ - private static String quetzalMapLabelForDestination(WorldPoint dest) { + static String quetzalMapLabelForDestination(WorldPoint dest) { assert dest != null; final int[][] coords = { - {1389, 2901, 0}, {1697, 3140, 0}, {1585, 3053, 0}, {1510, 3221, 0}, {1548, 2995, 0}, + {1389, 2901, 0}, {1697, 3140, 0}, {1585, 3053, 0}, {1510, 3222, 0}, {1548, 2995, 0}, {1437, 3171, 0}, {1779, 3111, 0}, {1700, 3037, 0}, {1670, 2933, 0}, {1446, 3108, 0}, {1613, 3300, 0}, {1226, 3091, 0}, {1344, 3022, 0}, {1411, 3361, 0}, }; final String[] labels = { "Aldarin", "Civitas illa Fortis", "Hunter Guild", "Quetzacalli Gorge", "Sunset Coast", - "The Teomat", "Fortis Colosseum", "Outer Fortis", "Colossal Wyrm Remains", "Cam Torum Entrance", + "The Teomat", "Fortis Colosseum", "Outer Fortis", "Colossal Wyrm Remains", "Cam Torum", "Salvager Overlook", "Tal Teklan", "Kastori", "Auburnvale", }; assert coords.length == labels.length; @@ -11857,24 +12394,10 @@ private static int getDesiredRotation(char letter) { * @return true if the item is a teleportation item, false otherwise */ public static boolean isTeleportItem(int itemId) { - if (Rs2PathApi.getPathfinderConfig().getAllTransports().isEmpty()) { - Rs2PathApi.getPathfinderConfig().refresh(); - } - - Set teleportItemIds = Rs2PathApi.getPathfinderConfig().getAllTransports().values() - .stream() - .flatMap(Set::stream) - .filter(t -> TransportType.isTeleport(t.getType(), t.getOrigin())) - .map(Transport::getItemIdRequirements) - .flatMap(Set::stream) - .flatMap(Set::stream) - .collect(Collectors.toSet()); - - // Items that are not included in transports - teleportItemIds.add(ItemID.DRAMEN_STAFF); - teleportItemIds.add(ItemID.LUNAR_MOONCLAN_LIMINAL_STAFF); - - return teleportItemIds.contains(itemId); + return Rs2PathApi.isTeleportItem( + itemId, + ItemID.DRAMEN_STAFF, + ItemID.LUNAR_MOONCLAN_LIMINAL_STAFF); } @@ -11901,47 +12424,28 @@ public static int findNearestAccessibleTarget(WorldPoint startPoint, List targetSet = new HashSet<>(targets); - - // Store original configuration to restore later - boolean originalUseBankItems = Rs2PathApi.getPathfinderConfig().isUseBankItems(); - try { - Rs2PathApi.getPathfinderConfig().setUseBankItems(useBankItems); - // Configure pathfinder - Rs2PathApi.getPathfinderConfig().refresh(); - // Run pathfinder - Pathfinder pf = new Pathfinder(Rs2PathApi.getPathfinderConfig(), startPoint, targetSet); - pf.run(); - - List path = pf.getPath(); - if (path.isEmpty()) { - log.debug("Unable to find path to any target from starting point: " + startPoint); - return -1; - } - - // Find which target corresponds to the end of the path - WorldPoint nearestTile = path.get(path.size() - 1); - WorldArea nearestTileArea = new WorldArea(nearestTile, tolerance, tolerance); - - // Find the target that matches the final path destination - for (int i = 0; i < targets.size(); i++) { - WorldPoint target = targets.get(i); - WorldArea targetArea = new WorldArea(target, tolerance, tolerance); - if (targetArea.intersectsWith2D(nearestTileArea)) { - log.debug("Found nearest accessible target at index " + i + ": " + target + " (path ended at: " + nearestTile + ")"); - return i; - } - } - - log.debug("Path found but no target matched the destination: " + nearestTile); + Rs2RouteResult route = Rs2PathApi.plan( + Rs2RouteRequest.toAny(startPoint, targetSet).withBankItems(useBankItems)); + WorldPoint nearestTile = route.getEndpoint().orElse(null); + if (nearestTile == null) { + log.debug("Unable to find path to any target from starting point: " + startPoint); return -1; + } - } finally { - // Always restore original configuration - Rs2PathApi.getPathfinderConfig().setUseBankItems(originalUseBankItems); - Rs2PathApi.getPathfinderConfig().refresh(); + WorldArea nearestTileArea = new WorldArea(nearestTile, tolerance, tolerance); + for (int i = 0; i < targets.size(); i++) { + WorldPoint target = targets.get(i); + WorldArea targetArea = new WorldArea(target, tolerance, tolerance); + if (targetArea.intersectsWith2D(nearestTileArea)) { + log.debug("Found nearest accessible target at index " + i + ": " + target + " (path ended at: " + nearestTile + ")"); + return i; + } } + + log.debug("Path found but no target matched the destination: " + nearestTile); + return -1; } /** @@ -11991,6 +12495,16 @@ public static List getTransportsForDestination(WorldPoint destination return getTransportsForDestination(destination, useBankItems, TransportType.TELEPORTATION_ITEM); } + /** + * Planner-independent counterpart to {@link #getTransportsForDestination(WorldPoint, boolean)}. + * New banking and execution code must use this exact selected-edge view. + */ + public static List getTransportEdgesForDestination( + WorldPoint destination, boolean useBankItems) + { + return Rs2WalkerBankingPlanner.getTransportEdgesForDestination(destination, useBankItems); + } + /** * Prepares and analyzes required transport items for reaching a destination. * Similar but improved to Rs2Slayer.prepareItemTransports() @@ -12037,6 +12551,12 @@ public static List getMissingTransports(List transports) { return Rs2WalkerBankingPlanner.getMissingTransports(transports); } + public static List getMissingTransportEdges( + List transports) + { + return Rs2WalkerBankingPlanner.getMissingTransportEdges(transports); + } + /** * Extracts item IDs and their required quantities for the given transports that are missing and available in bank. * Enhanced version that uses Rs2Magic and Rs2Spells systems for actual rune quantities on teleportation spells. @@ -12048,6 +12568,18 @@ public static Map getMissingTransportItemIdsWithQuantities(Lis return Rs2WalkerBankingPlanner.getMissingTransportItemIdsWithQuantities(transports); } + public static Map getMissingTransportEdgeItemIdsWithQuantities( + List transports) + { + return Rs2WalkerBankingPlanner.getMissingTransportEdgeItemIdsWithQuantities(transports); + } + + public static Rs2TransportLoadout getMissingTransportEdgeLoadout( + List transports) + { + return Rs2WalkerBankingPlanner.getMissingTransportEdgeLoadout(transports); + } + /** * Extracts item IDs that are missing for the given transports and available in bank. * Legacy method maintained for backward compatibility. @@ -12152,7 +12684,9 @@ public static WalkerState walkWithBankedTransportsAndState(WorldPoint target, in } } try { - return walkWithBankedTransportsAndStateLocked(target, distance, forceBanking); + return withShadowExecutionEvidence( + () -> walkWithBankedTransportsAndStateLocked( + target, distance, forceBanking)); } finally { walkerLock.unlock(); } @@ -12173,8 +12707,8 @@ private static WalkerState walkWithBankedTransportsAndStateLocked(WorldPoint tar if (Rs2Tile.getReachableTilesFromTile(pl, distance).containsKey(target) || nearUnwalkableGoal) { return WalkerState.ARRIVED; } - final Pathfinder pathfinder = Rs2PathApi.getPathfinder(); - if (pathfinder != null && !pathfinder.isDone()) + final Rs2ActiveRouteStatus routeStatus = Rs2PathApi.getActiveRouteStatus(); + if (routeStatus.isCalculating()) return WalkerState.MOVING; boolean bankTripWhenCacheUnavailable = config == null || config.bankTripWhenCacheUnavailable(); @@ -12213,15 +12747,22 @@ private static WalkerState walkWithBankedTransportsAndStateLocked(WorldPoint tar TransportRouteAnalysis comparison = compareRoutes(target); WebWalkLog.tmark("compare_done", System.currentTimeMillis() - compareStartedAt, target, pl, "direct=" + comparison.getDirectDistance() + " bank=" + comparison.getBankingRouteDistance()); - List missingTransports = getMissingTransports(getTransportsForDestination(target, true, TransportType.TELEPORTATION_SPELL)); + List missingTransports = getMissingTransportEdges( + Rs2WalkerBankingPlanner.getRequiredTransportEdgesFromBank(comparison)); - Map missingItemsWithQuantities = getMissingTransportItemIdsWithQuantities(missingTransports); + Rs2TransportLoadout transportLoadout = getMissingTransportEdgeLoadout(missingTransports); + Map missingItemsWithQuantities = transportLoadout.getWithdrawals(); if (!missingTransports.isEmpty()) { - WebWalkLog.bankWalkDebug("missing_items nTrans={} to={} missingKinds={}", - missingTransports.size(), target, missingItemsWithQuantities.size()); + WebWalkLog.bankWalkDebug("missing_items nTrans={} to={} missingKinds={} equipKinds={} satisfiable={}", + missingTransports.size(), target, missingItemsWithQuantities.size(), + transportLoadout.getEquipmentItemIds().size(), transportLoadout.isSatisfiable()); + } + if (!transportLoadout.isSatisfiable()) { + WebWalkLog.spWarn("bank_walk | selected bank route has no executable loadout goal={}", target); + return forceBanking ? WalkerState.EXIT : walkWithStateInternal(target, distance); } // If no missing transport items, go directly - if (missingItemsWithQuantities.isEmpty() && !forceBanking) { + if (transportLoadout.isEmpty() && !forceBanking) { WebWalkLog.spInfo("bank_walk | direct_no_missing_items goal={}", target); WalkerState state = walkWithStateInternal(target, distance); if (state == WalkerState.ARRIVED) { @@ -12248,7 +12789,8 @@ private static WalkerState walkWithBankedTransportsAndStateLocked(WorldPoint tar log.info("\n\tUsing banking route: \n\t\tStart: {} -> Bank: {} -> Target: {}", Rs2Player.getWorldLocation(), comparison.getBankLocation(), target); // Handle the complete banking workflow using legacy walkTo approach - return walkWithBankingState(comparison.getBankLocation(), missingItemsWithQuantities, target, distance); + return walkWithBankingState( + comparison.getBankLocation(), transportLoadout, target, distance); } else { log.warn("\n\tBanking route requested but no accessible bank found, trying direct route"); return walkWithStateInternal(target, distance); @@ -12329,31 +12871,20 @@ private static WalkerState bootstrapBankMirrorForBankedPathing(int distance) { /** - * Handles the complete banking workflow using legacy walkTo: walk to bank, open, withdraw items, close, continue to target. - * Enhanced version that accepts a map of item IDs with their required quantities and returns boolean. - * - * @param bankLocation The bank location to visit - * @param missingItemsWithQuantities Map of item IDs and their required quantities - * @param finalTarget The final destination after banking - * @return true if the banking workflow was successful, false otherwise - */ - private static boolean walkWithBanking(WorldPoint bankLocation, Map missingItemsWithQuantities, WorldPoint finalTarget) { - return walkWithBankingState(bankLocation, missingItemsWithQuantities, finalTarget, 10)== WalkerState.ARRIVED; - } - - /** - * Handles the complete banking workflow using walkWithState: walk to bank, open, withdraw items, close, continue to target. - * Enhanced version that accepts a map of item IDs with their required quantities and returns WalkerState. + * Handles the complete banking workflow using the immutable preparation selected for the exact + * bank-to-target route: walk to bank, withdraw, equip, close, refresh inventory-only policy and + * continue to the target. * - * @param missingItemsWithQuantities Map of item IDs and their required quantities + * @param transportLoadout Withdrawals and equipment changes required by the selected route * @param finalTarget The final destination after banking * @return WalkerState indicating the result of the banking workflow */ private static WalkerState walkWithBankingState(WorldPoint bankLocation, - Map missingItemsWithQuantities, + Rs2TransportLoadout transportLoadout, WorldPoint finalTarget,int distance) { try { - if (bankLocation == null || finalTarget == null) { + if (bankLocation == null || finalTarget == null || transportLoadout == null + || !transportLoadout.isSatisfiable()) { log.warn("Cannot perform banking workflow with null locations"); return WalkerState.EXIT; } @@ -12376,6 +12907,7 @@ private static WalkerState walkWithBankingState(WorldPoint bankLocation, } // Step 3: Withdraw missing transport items + Map missingItemsWithQuantities = transportLoadout.getWithdrawals(); if (!missingItemsWithQuantities.isEmpty()) { log.debug("Withdrawing transport items with quantities: " + missingItemsWithQuantities); @@ -12389,11 +12921,17 @@ private static WalkerState walkWithBankingState(WorldPoint bankLocation, if (amountToWithdraw > 0) { if (Rs2Bank.hasBankItem(itemId, amountToWithdraw)) { log.debug("Withdrawing {} x {} (item ID: {})", amountToWithdraw, itemId, itemId); - Rs2Bank.withdrawX(itemId, amountToWithdraw); - sleepUntil(() -> Rs2Inventory.count(itemId) >= currentCount + amountToWithdraw, 3000); + if (!Rs2Bank.withdrawX(itemId, amountToWithdraw) + || !sleepUntil(() -> Rs2Inventory.count(itemId) + >= currentCount + amountToWithdraw, 3000)) { + log.warn("Failed to withdraw required transport item {} x{}", + itemId, amountToWithdraw); + return WalkerState.EXIT; + } } else { log.warn("Required transport item {} not found in bank (need {} but bank has less)", itemId, amountToWithdraw); + return WalkerState.EXIT; } } else { log.debug("Already have enough of item {}: {} (need {})", itemId, currentCount, amountNeeded); @@ -12404,6 +12942,18 @@ private static WalkerState walkWithBankingState(WorldPoint bankLocation, sleepTickJitter(1); } + for (Integer equipmentItemId : transportLoadout.getEquipmentItemIds()) { + if (Rs2Equipment.isWearing(equipmentItemId)) { + continue; + } + if (!Rs2Inventory.hasItem(equipmentItemId) + || !Rs2Bank.wearItem(equipmentItemId) + || !sleepUntil(() -> Rs2Equipment.isWearing(equipmentItemId), 3000)) { + log.warn("Failed to equip required transport provider {}", equipmentItemId); + return WalkerState.EXIT; + } + } + // Step 4: Close bank Rs2Bank.closeBank(); sleepUntil(() -> !Rs2Bank.isOpen(), 3000); @@ -12411,8 +12961,10 @@ private static WalkerState walkWithBankingState(WorldPoint bankLocation, log.warn("Failed to close bank after withdrawals"); return WalkerState.EXIT; } - Rs2PathApi.getPathfinderConfig().setUseBankItems(false); - Rs2PathApi.getPathfinderConfig().refresh(finalTarget); + if (!Rs2PathApi.prepareInventoryOnlyRoute(finalTarget)) { + log.warn("Shortest-path configuration unavailable after bank withdrawals"); + return WalkerState.EXIT; + } // Step 5: Continue to final target log.debug("Banking complete, continuing to final target: " + finalTarget); return walkWithStateInternal(finalTarget, distance); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerShadowExecutionStats.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerShadowExecutionStats.java new file mode 100644 index 00000000000..b880a0bc751 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerShadowExecutionStats.java @@ -0,0 +1,50 @@ +package net.runelite.client.plugins.microbot.util.walker; + +/** Coordinate-free terminal outcomes for blocking walks observed while shadow mode was enabled. */ +public final class Rs2WalkerShadowExecutionStats +{ + private final long arrived; + private final long unreachable; + private final long exited; + private final long recoveryArrived; + private final long recoveryUnreachable; + private final long recoveryExited; + + Rs2WalkerShadowExecutionStats( + long arrived, + long unreachable, + long exited, + long recoveryArrived, + long recoveryUnreachable, + long recoveryExited) + { + this.arrived = requireNonNegative(arrived, "arrived"); + this.unreachable = requireNonNegative(unreachable, "unreachable"); + this.exited = requireNonNegative(exited, "exited"); + this.recoveryArrived = requireNonNegative(recoveryArrived, "recoveryArrived"); + this.recoveryUnreachable = requireNonNegative( + recoveryUnreachable, "recoveryUnreachable"); + this.recoveryExited = requireNonNegative(recoveryExited, "recoveryExited"); + } + + private static long requireNonNegative(long value, String name) + { + if (value < 0) + { + throw new IllegalArgumentException(name + " must be non-negative"); + } + return value; + } + + public long getArrived() { return arrived; } + public long getUnreachable() { return unreachable; } + public long getExited() { return exited; } + public long getRecoveryArrived() { return recoveryArrived; } + public long getRecoveryUnreachable() { return recoveryUnreachable; } + public long getRecoveryExited() { return recoveryExited; } + public long getTerminal() { return arrived + unreachable + exited; } + public long getRecoveryTerminal() + { + return recoveryArrived + recoveryUnreachable + recoveryExited; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/TransportRouteAnalysis.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/TransportRouteAnalysis.java index e7d53f334df..1db22e71bb2 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/TransportRouteAnalysis.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/TransportRouteAnalysis.java @@ -20,6 +20,12 @@ public class TransportRouteAnalysis { /** Complete path of WorldPoints representing the direct route to destination */ private final List directPath; + + /** Exact immutable edge sequence selected for the direct route, when captured by the planner */ + private final List directRouteSteps; + + /** Whether {@link #directRouteSteps} is an exact planner result rather than a legacy omission */ + private final boolean directRouteStepsExact; /** Reference to the nearest accessible BankLocation object, null if no bank is accessible */ private final BankLocation nearestBank; @@ -29,10 +35,22 @@ public class TransportRouteAnalysis { /** Path of WorldPoints from starting point to the nearest bank */ private final List pathToBank; + + /** Exact immutable edge sequence selected from the start to the bank */ + private final List routeToBankSteps; + + /** Whether {@link #routeToBankSteps} is an exact planner result */ + private final boolean routeToBankStepsExact; /** Path of WorldPoints from bank to destination, accounting for items available in bank */ private final List pathFromBank; + /** Exact immutable edge sequence selected from the bank to the destination */ + private final List routeFromBankSteps; + + /** Whether {@link #routeFromBankSteps} is an exact planner result */ + private final boolean routeFromBankStepsExact; + /** Explicit direct distance captured at analysis time (tiles), or -1 if unavailable */ private final int directDistance; @@ -57,21 +75,103 @@ public TransportRouteAnalysis(List directPath, List pathFromBank,String analysis) { this(directPath, nearestBank, bankLocation, pathToBank, pathFromBank, analysis, deriveRouteDistance(directPath), - deriveBankingRouteDistance(pathToBank, pathFromBank)); + deriveBankingRouteDistance(pathToBank, pathFromBank), + null, null, null); } public TransportRouteAnalysis(List directPath, BankLocation nearestBank, WorldPoint bankLocation, List pathToBank, List pathFromBank, String analysis, int directDistance, int bankingRouteDistance) { - this.directPath = directPath; + this(directPath, nearestBank, bankLocation, pathToBank, pathFromBank, analysis, + directDistance, bankingRouteDistance, null, null, null); + } + + /** + * Constructs an analysis carrying the exact immutable route steps selected by each search. + * + *

The appended step parameters preserve the two historical constructor descriptors for Hub + * compatibility. New Microbot code must use this form so banking and execution never infer a + * transport later by rescanning mutable catalog endpoints.

+ */ + public TransportRouteAnalysis(List directPath, + BankLocation nearestBank, WorldPoint bankLocation, List pathToBank, + List pathFromBank, String analysis, + int directDistance, int bankingRouteDistance, + List directRouteSteps, + List routeToBankSteps, + List routeFromBankSteps) { + this.directPath = immutablePath(directPath); this.nearestBank = nearestBank; this.bankLocation = bankLocation; - this.pathToBank = pathToBank; - this.pathFromBank = pathFromBank; + this.pathToBank = immutablePath(pathToBank); + this.pathFromBank = immutablePath(pathFromBank); this.analysis = analysis; this.directDistance = directDistance; this.bankingRouteDistance = bankingRouteDistance; + this.directRouteStepsExact = directRouteSteps != null; + this.routeToBankStepsExact = routeToBankSteps != null; + this.routeFromBankStepsExact = routeFromBankSteps != null; + this.directRouteSteps = immutableSteps("direct", this.directPath, directRouteSteps); + this.routeToBankSteps = immutableSteps("to-bank", this.pathToBank, routeToBankSteps); + this.routeFromBankSteps = immutableSteps("from-bank", this.pathFromBank, routeFromBankSteps); + } + + private static List immutablePath(List path) { + return path == null ? List.of() : List.copyOf(path); + } + + private static List immutableSteps( + String label, List path, List steps) { + if (steps == null) { + return List.of(); + } + List immutable = List.copyOf(steps); + int expected = Math.max(0, path.size() - 1); + if (immutable.size() != expected) { + throw new IllegalArgumentException(label + " steps must describe every path edge: expected " + + expected + ", got " + immutable.size()); + } + for (int index = 0; index < immutable.size(); index++) { + Rs2RouteStep step = immutable.get(index); + if (!path.get(index).equals(step.getFrom()) || !path.get(index + 1).equals(step.getTo())) { + throw new IllegalArgumentException(label + " step " + index + " is not contiguous with path"); + } + } + return immutable; + } + + /** Exact selected transport edges for the direct route, in route order. */ + public List getDirectTransportEdges() { + return transportEdges(directRouteSteps); + } + + /** Exact selected transport edges for the start-to-bank leg, in route order. */ + public List getTransportEdgesToBank() { + return transportEdges(routeToBankSteps); + } + + /** Exact selected transport edges for the bank-to-target leg, in route order. */ + public List getTransportEdgesFromBank() { + return transportEdges(routeFromBankSteps); + } + + /** Exact selected transport edges for both banking legs, in route order. */ + public List getBankingTransportEdges() { + List combined = new ArrayList<>(); + combined.addAll(getTransportEdgesToBank()); + combined.addAll(getTransportEdgesFromBank()); + return List.copyOf(combined); + } + + private static List transportEdges(List steps) { + List transports = new ArrayList<>(); + for (Rs2RouteStep step : steps) { + if (step.isTransport()) { + transports.add(step.getTransport().orElseThrow(IllegalStateException::new)); + } + } + return List.copyOf(transports); } /** @@ -161,6 +261,7 @@ public String toString() { * Gets all required transports for the direct path with default parameters. * @return List of required transports for direct path */ + @Deprecated public List getTransportsForDirectPath(){ return getTransportsForDirectPath(0, TransportType.TELEPORTATION_ITEM, true); } @@ -172,6 +273,7 @@ public List getTransportsForDirectPath(){ * @param applyFiltering Whether to apply filtering * @return List of required transports for direct path */ + @Deprecated public List getTransportsForDirectPath(int startIndex, TransportType prefTransportType, boolean applyFiltering){ List transports = Rs2Walker.getTransportsForPath(directPath, startIndex, prefTransportType, applyFiltering); return transports; @@ -181,6 +283,7 @@ public List getTransportsForDirectPath(int startIndex, TransportType * Gets all required transports for the banking route with default parameters. * @return List of required transports for banking route (to and from bank) */ + @Deprecated public List getTransportsForBankingPath(){ return getTransportsForBankingPath(0, TransportType.TELEPORTATION_ITEM, true); } @@ -192,6 +295,7 @@ public List getTransportsForBankingPath(){ * @param applyFiltering Whether to apply filtering * @return List of required transports for banking route (to and from bank) */ + @Deprecated public List getTransportsForBankingPath(int startIndex, TransportType prefTransportType, boolean applyFiltering){ List transportsToTargetToBank = Rs2Walker.getTransportsForPath(pathToBank, startIndex, prefTransportType, applyFiltering); List transportsToTargetFromBank = Rs2Walker.getTransportsForPath(pathFromBank, startIndex, prefTransportType, applyFiltering); @@ -205,6 +309,7 @@ public List getTransportsForBankingPath(int startIndex, TransportType * Gets missing transports for the direct path. * @return List of missing transports for direct path */ + @Deprecated public List getMissingTransportsForDirectPath(){ List missingTransports = Rs2Walker.getMissingTransports(getTransportsForDirectPath()); return missingTransports; @@ -214,6 +319,7 @@ public List getMissingTransportsForDirectPath(){ * Gets missing transport items with their quantities for the direct path. * @return Map of item IDs to their required quantities */ + @Deprecated public Map getMissingTransportsItemsWithQuantitiesForDirectPath(){ List missingTransports = getMissingTransportsForDirectPath(); Map missingItemsWithQuantities = Rs2Walker.getMissingTransportItemIdsWithQuantities(missingTransports); @@ -224,6 +330,7 @@ public Map getMissingTransportsItemsWithQuantitiesForDirectPat * Gets missing transports for the banking route (to and from bank). * @return List of missing transports for the banking route */ + @Deprecated public List getMissingTransportsForBankingRoute(){ List missingTransports = Rs2Walker.getMissingTransports(getTransportsForBankingPath(0, TransportType.TELEPORTATION_ITEM, true)); return missingTransports; @@ -233,6 +340,7 @@ public List getMissingTransportsForBankingRoute(){ * Gets missing transport items with their quantities for the banking route. * @return Map of item IDs to their required quantities */ + @Deprecated public Map getMissingTransportsItemsWithQuantitiesForBankingRoute(){ List missingTransports = getMissingTransportsForBankingRoute(); Map missingItemsWithQuantities = Rs2Walker.getMissingTransportItemIdsWithQuantities(missingTransports); @@ -243,6 +351,7 @@ public Map getMissingTransportsItemsWithQuantitiesForBankingRo * Gets all required transports for the path to bank with default parameters. * @return List of required transports for path to bank */ + @Deprecated public List getTransportsForPathToBank() { return getTransportsForPathToBank(0, TransportType.TELEPORTATION_ITEM, true); } @@ -254,6 +363,7 @@ public List getTransportsForPathToBank() { * @param applyFiltering Whether to apply filtering * @return List of required transports for path to bank */ + @Deprecated public List getTransportsForPathToBank(int startIndex, TransportType prefTransportType, boolean applyFiltering) { return Rs2Walker.getTransportsForPath(pathToBank, startIndex, prefTransportType, applyFiltering); } @@ -262,6 +372,7 @@ public List getTransportsForPathToBank(int startIndex, TransportType * Gets all required transports for the path from bank with default parameters. * @return List of required transports for path from bank */ + @Deprecated public List getTransportsForPathFromBank() { return getTransportsForPathFromBank(0, TransportType.TELEPORTATION_ITEM, true); } @@ -273,6 +384,7 @@ public List getTransportsForPathFromBank() { * @param applyFiltering Whether to apply filtering * @return List of required transports for path from bank */ + @Deprecated public List getTransportsForPathFromBank(int startIndex, TransportType prefTransportType, boolean applyFiltering) { return Rs2Walker.getTransportsForPath(pathFromBank, startIndex, prefTransportType, applyFiltering); } @@ -281,6 +393,7 @@ public List getTransportsForPathFromBank(int startIndex, TransportTyp * Gets missing transports specifically for the path from bank to destination. * @return List of missing transports for path from bank */ + @Deprecated public List getMissingTransportsForPathFromBank() { List missingTransports = Rs2Walker.getMissingTransports(getTransportsForPathFromBank()); return missingTransports; @@ -290,10 +403,10 @@ public List getMissingTransportsForPathFromBank() { * Gets missing transport items with their quantities specifically for the path from bank to destination. * @return Map of item IDs to their required quantities for path from bank */ + @Deprecated public Map getMissingTransportsItemsWithQuantitiesForPathFromBank() { List missingTransports = getMissingTransportsForPathFromBank(); Map missingItemsWithQuantities = Rs2Walker.getMissingTransportItemIdsWithQuantities(missingTransports); return missingItemsWithQuantities; } } - diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/UpstreamRoutePlanner.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/UpstreamRoutePlanner.java new file mode 100644 index 00000000000..f9bdb0d07c6 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/UpstreamRoutePlanner.java @@ -0,0 +1,372 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; +import shortestpath.DestinationRequirements; +import shortestpath.ShortestPathConfig; +import shortestpath.WorldPointUtil; +import shortestpath.pathfinder.CollisionMap; +import shortestpath.pathfinder.PathStep; +import shortestpath.pathfinder.Pathfinder; +import shortestpath.pathfinder.PathfinderConfig; +import shortestpath.pathfinder.PathfinderResult; +import shortestpath.pathfinder.SplitFlagMap; +import shortestpath.pathfinder.TransportAvailability; +import shortestpath.pathfinder.WildernessChecker; +import shortestpath.transport.Transport; +import shortestpath.transport.TransportType; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** Production-packaged adapter for the pinned reviewed upstream planner core. */ +final class UpstreamRoutePlanner implements Rs2RoutePlanner +{ + static final String REVISION = "ff8e961b32120175709df9630ece9468cc11347f"; + private static final ShortestPathConfig EMPTY_CONFIG = new ShortestPathConfig() + { + @Override + public void setBuiltTeleportationBoxes(String content) + { + } + + @Override + public void setBuiltTeleportationPortalsPoh(String content) + { + } + }; + + private static final class StaticMapHolder + { + private static final SplitFlagMap INSTANCE = SplitFlagMap.fromResources(); + } + + @Override + public String getEngineId() + { + return "shortest-path-upstream@" + REVISION; + } + + @Override + public Rs2RouteResult plan(Rs2RouteRequest request, Rs2PlanningSnapshot snapshot) + { + Objects.requireNonNull(request, "request"); + Objects.requireNonNull(snapshot, "snapshot"); + Rs2RoutePolicy policy = request.getPolicy().orElseThrow( + () -> new IllegalArgumentException("upstream planner requires a resolved policy")); + if (snapshot.getPolicy() != policy) + { + throw new IllegalArgumentException("route request and planning snapshot policy differ"); + } + + IdentityHashMap exactEdges = new IdentityHashMap<>(); + TransportAvailability.Builder catalog = new TransportAvailability.Builder( + Math.max(1, snapshot.getAdmittedTransports().size())); + for (Rs2TransportEdge edge : snapshot.getAdmittedTransports()) + { + if ((policy.isIgnoreTeleportAndItems() || policy.isTeleportsDisabled()) + && edge.getOrigin() == null && edge.isTeleport()) + { + continue; + } + Transport converted = convert(edge); + catalog.add(converted); + exactEdges.put(converted, edge); + } + + Set packedTargets = new LinkedHashSet<>(); + for (WorldPoint target : request.getTargets()) + { + packedTargets.add(WorldPointUtil.packWorldPoint(target)); + } + UpstreamConfig config = new UpstreamConfig( + request, snapshot, catalog.build()); + Pathfinder pathfinder = new Pathfinder( + config, WorldPointUtil.packWorldPoint(request.getStart()), packedTargets); + pathfinder.run(); + PathfinderResult result = pathfinder.getResult(); + if (result == null) + { + throw new IllegalStateException("pinned upstream planner did not publish a result"); + } + return toResult(request, snapshot, result, exactEdges); + } + + private static Transport convert(Rs2TransportEdge edge) + { + int origin = edge.getOrigin() == null + ? WorldPointUtil.UNDEFINED : WorldPointUtil.packWorldPoint(edge.getOrigin()); + return new Transport.TransportBuilder() + .origin(origin) + .destination(WorldPointUtil.packWorldPoint(edge.getDestination())) + .type(mapType(edge)) + .duration(edge.getDuration()) + .displayInfo(edge.getDisplayInfo()) + .isConsumable(edge.isConsumable()) + .maxWildernessLevel(edge.getMaxWildernessLevel()) + .build(); + } + + /** + * Project the planner-independent type into the pinned upstream schema. + * + *

Keep this switch exhaustive. A newly introduced Microbot transport category must be reviewed + * before it can enter the upstream catalog; silently flattening it to {@code TRANSPORT} can alter + * teleport admission, wilderness limits, delayed visits, or transport cost.

+ */ + static TransportType mapType(Rs2TransportEdge edge) + { + if (edge.getOrigin() == null) + { + switch (edge.getType()) + { + case QUETZAL_WHISTLE: + return TransportType.QUETZAL_WHISTLE; + case SEASONAL_TRANSPORT: + // Upstream's seasonal category is anchored; originless seasonal rows are + // Microbot teleports and therefore use upstream teleport admission/costing. + return TransportType.TELEPORTATION_ITEM; + case TELEPORTATION_ITEM: + return TransportType.TELEPORTATION_ITEM; + case TELEPORTATION_MINIGAME: + return TransportType.TELEPORTATION_MINIGAME; + case TELEPORTATION_SPELL: + return TransportType.TELEPORTATION_SPELL; + case TELEPORTATION_SPELL_HOME: + return TransportType.TELEPORTATION_SPELL_HOME; + default: + throw unsupportedType(edge, "originless transport category is not an upstream teleport"); + } + } + + switch (edge.getType()) + { + case TRANSPORT: + return TransportType.TRANSPORT; + case AGILITY_SHORTCUT: + return TransportType.AGILITY_SHORTCUT; + case GRAPPLE_SHORTCUT: + return TransportType.GRAPPLE_SHORTCUT; + case BOAT: + return TransportType.BOAT; + case CANOE: + return TransportType.CANOE; + case CHARTER_SHIP: + return TransportType.CHARTER_SHIP; + case SHIP: + return TransportType.SHIP; + case FAIRY_RING: + return TransportType.FAIRY_RING; + case QUETZAL: + return TransportType.QUETZAL; + case QUETZAL_WHISTLE: + return TransportType.QUETZAL_WHISTLE; + case GNOME_GLIDER: + return TransportType.GNOME_GLIDER; + case MINECART: + return TransportType.MINECART; + case POH: + case NPC: + // These Microbot-owned execution families have no upstream type. They remain + // distinct on the exact Rs2TransportEdge retained by the adapter. + return TransportType.TRANSPORT; + case SPIRIT_TREE: + return TransportType.SPIRIT_TREE; + case TELEPORTATION_BOX: + return TransportType.TELEPORTATION_BOX; + case TELEPORTATION_LEVER: + return TransportType.TELEPORTATION_LEVER; + case TELEPORTATION_PORTAL: + return TransportType.TELEPORTATION_PORTAL; + case TELEPORTATION_PORTAL_POH: + return TransportType.TELEPORTATION_PORTAL_POH; + case TELEPORTATION_MINIGAME: + return TransportType.TELEPORTATION_MINIGAME; + case TELEPORTATION_ITEM: + return TransportType.TELEPORTATION_ITEM; + case TELEPORTATION_SPELL: + return TransportType.TELEPORTATION_SPELL; + case TELEPORTATION_SPELL_HOME: + return TransportType.TELEPORTATION_SPELL_HOME; + case WILDERNESS_OBELISK: + return TransportType.WILDERNESS_OBELISK; + case MAGIC_CARPET: + return TransportType.MAGIC_CARPET; + case HOT_AIR_BALLOON: + return TransportType.HOT_AIR_BALLOON; + case MAGIC_MUSHTREE: + return TransportType.MAGIC_MUSHTREE; + case SEASONAL_TRANSPORT: + return TransportType.SEASONAL_TRANSPORTS; + case UNKNOWN: + default: + throw unsupportedType(edge, "transport category has no reviewed upstream projection"); + } + } + + private static IllegalArgumentException unsupportedType(Rs2TransportEdge edge, String reason) + { + return new IllegalArgumentException(reason + ": " + edge.getType()); + } + + private static Rs2RouteResult toResult( + Rs2RouteRequest request, + Rs2PlanningSnapshot snapshot, + PathfinderResult result, + IdentityHashMap exactEdges) + { + List sourcePath = result.getPathSteps() == null + ? Collections.emptyList() : result.getPathSteps(); + List path = new ArrayList<>(sourcePath.size()); + for (PathStep step : sourcePath) + { + path.add(WorldPointUtil.unpackWorldPoint(step.getPackedPosition())); + } + List steps = new ArrayList<>(Math.max(0, path.size() - 1)); + long cost = 0L; + for (int i = 1; i < sourcePath.size(); i++) + { + WorldPoint from = path.get(i - 1); + WorldPoint to = path.get(i); + Transport selected = sourcePath.get(i).getTransport(); + if (selected == null) + { + steps.add(Rs2RouteStep.walk(from, to)); + cost += WorldPointUtil.distanceBetween( + sourcePath.get(i - 1).getPackedPosition(), sourcePath.get(i).getPackedPosition()); + cost += snapshot.getAdditionalWalkingCost( + sourcePath.get(i).getPackedPosition(), request.getTargets()); + } + else + { + Rs2TransportEdge exact = exactEdges.get(selected); + if (exact == null) + { + throw new IllegalStateException( + "upstream selected a transport outside the projected catalog"); + } + steps.add(Rs2RouteStep.transport(from, to, exact)); + cost += selected.getDuration(); + if (selected.getOrigin() == WorldPointUtil.UNDEFINED) + { + cost += snapshot.getPolicy().getDistanceBeforeUsingTeleport(); + } + } + } + return new Rs2RouteResult( + request.getStart(), + request.getTargets(), + path, + steps, + Rs2RouteTermination.valueOf(result.getTerminationReason().name()), + new Rs2RouteMetrics( + result.getElapsedNanos(), + cost, + result.getNodesChecked(), + result.getTransportsChecked())); + } + + private static final class UpstreamConfig extends PathfinderConfig + { + private final Rs2RouteRequest request; + private final Rs2PlanningSnapshot snapshot; + private final TransportAvailability availability; + private final CollisionMap collisionMap; + private final Set restricted; + + private UpstreamConfig( + Rs2RouteRequest request, + Rs2PlanningSnapshot snapshot, + TransportAvailability availability) + { + super(null, EMPTY_CONFIG, StaticMapHolder.INSTANCE, + Collections.emptyMap(), + Collections.>emptyMap(), + Collections.>emptyMap(), + Collections.emptyMap()); + this.request = request; + this.snapshot = snapshot; + this.availability = availability; + this.collisionMap = new CollisionMap( + StaticMapHolder.INSTANCE, snapshot::collisionOverride); + Set packedRestricted = new LinkedHashSet<>(); + for (WorldPoint point : snapshot.getPolicy().getRestrictedPoints()) + { + packedRestricted.add(WorldPointUtil.packWorldPoint(point)); + } + this.restricted = Collections.unmodifiableSet(packedRestricted); + } + + @Override + public CollisionMap getMap() + { + return collisionMap; + } + + @Override + public TransportAvailability getTransportAvailability(boolean bankVisited) + { + return availability; + } + + @Override + public boolean isBankPathEnabled() + { + return false; + } + + @Override + public boolean bankAccessible(int packedPosition) + { + return false; + } + + @Override + public long getCalculationCutoffMillis() + { + return snapshot.getPolicy().getCalculationCutoffMillis(); + } + + @Override + public boolean avoidWilderness( + int packedPosition, int packedNeighborPosition, boolean targetInWilderness) + { + return snapshot.getPolicy().isAvoidWilderness() + && !targetInWilderness + && !WildernessChecker.isInWilderness(packedPosition) + && WildernessChecker.isInWilderness(packedNeighborPosition); + } + + @Override + public boolean avoidBlockedRegion( + int packedPosition, int packedNeighborPosition, boolean targetInBlockedRegion) + { + return restricted.contains(packedNeighborPosition) + || snapshot.isWalkingEdgeBlocked(packedPosition, packedNeighborPosition); + } + + @Override + public int getAdditionalWalkingCost(int packedDestination) + { + return snapshot.getAdditionalWalkingCost( + packedDestination, request.getTargets()); + } + + @Override + public int getAdditionalTransportCost(Transport transport) + { + return transport.getOrigin() == WorldPointUtil.UNDEFINED + ? snapshot.getPolicy().getDistanceBeforeUsingTeleport() : 0; + } + + @Override + public int getDifferentialCost(Transport transport) + { + return 0; + } + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/awaits/Rs2WalkerRuntimeAwaits.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/awaits/Rs2WalkerRuntimeAwaits.java index 635253f34b4..0094d6bd0e7 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/awaits/Rs2WalkerRuntimeAwaits.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/awaits/Rs2WalkerRuntimeAwaits.java @@ -1,6 +1,5 @@ package net.runelite.client.plugins.microbot.util.walker.awaits; -import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; import net.runelite.client.plugins.microbot.util.player.Rs2Player; import java.util.function.BooleanSupplier; @@ -11,13 +10,6 @@ public final class Rs2WalkerRuntimeAwaits { private Rs2WalkerRuntimeAwaits() { } - public static boolean awaitPathfinderDone(Pathfinder pathfinder, int timeoutMs) { - if (pathfinder == null) { - return false; - } - return sleepUntilTrue(pathfinder::isDone, 100, timeoutMs); - } - public static boolean awaitCondition(BooleanSupplier condition, int pollMs, int timeoutMs) { if (condition == null) { return false; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/banking/Rs2WalkerBankingPlanner.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/banking/Rs2WalkerBankingPlanner.java index d392df64339..4f626b79c46 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/banking/Rs2WalkerBankingPlanner.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/banking/Rs2WalkerBankingPlanner.java @@ -5,17 +5,26 @@ import net.runelite.api.gameval.ItemID; import net.runelite.api.gameval.VarbitID; import net.runelite.client.plugins.microbot.Microbot; -import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; import net.runelite.client.plugins.microbot.util.magic.Rs2Magic; import net.runelite.client.plugins.microbot.util.magic.Rs2Spells; +import net.runelite.client.plugins.microbot.util.magic.RuneFilter; import net.runelite.client.plugins.microbot.util.walker.Rs2PathApi; +import net.runelite.client.plugins.microbot.util.walker.Rs2RouteRequest; +import net.runelite.client.plugins.microbot.util.walker.Rs2RouteResult; +import net.runelite.client.plugins.microbot.util.walker.Rs2RouteStep; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportEdge; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportItemRequirement; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportLoadout; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportType; import net.runelite.client.plugins.microbot.shortestpath.PurchasableItemCatalog; import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.TransportItemRequirement; import net.runelite.client.plugins.microbot.shortestpath.TransportType; import net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment; import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; import net.runelite.client.plugins.microbot.util.bank.enums.BankLocation; import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; +import net.runelite.client.plugins.microbot.util.leaguetransport.Rs2LeaguesTransport; import net.runelite.client.plugins.microbot.util.magic.Runes; import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; import net.runelite.client.plugins.microbot.util.walker.TransportRouteAnalysis; @@ -23,11 +32,15 @@ import net.runelite.client.plugins.microbot.util.player.Rs2Player; import java.util.ArrayList; -import java.util.Collection; import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.function.IntPredicate; +import java.util.function.IntUnaryOperator; import java.util.stream.Collectors; @Slf4j @@ -36,31 +49,94 @@ public final class Rs2WalkerBankingPlanner { private Rs2WalkerBankingPlanner() { } + /** + * Plan the destination and retain only the immutable transport edges selected by that search. + * + *

This is the planner-independent banking contract. In particular, it does not rescan the + * mutable transport catalog by origin/destination after pathfinding, so two transports sharing an + * edge cannot be confused.

+ */ + public static List getTransportEdgesForDestination( + WorldPoint destination, boolean useBankItems) + { + if (destination == null) + { + return List.of(); + } + WorldPoint start = Rs2Player.getWorldLocation(); + if (start == null) + { + log.debug("Unable to plan transport edges without a player location"); + return List.of(); + } + + Rs2RouteResult route = Rs2PathApi.plan( + Rs2RouteRequest.to(start, destination) + .withBankItems(useBankItems)); + if (route.getPath().isEmpty()) + { + log.debug("Unable to find path to destination: {}", destination); + return List.of(); + } + + List selected = route.getTransportSteps().stream() + .map(Rs2RouteStep::getTransport) + .map(transport -> transport.orElseThrow( + () -> new IllegalStateException("typed route step has no transport metadata"))) + .collect(Collectors.toList()); + List transports = applyTransportEdgeFiltering(selected); + transports.forEach(transport -> log.debug("Transport edge found: {} -> {} ({})", + transport.getOrigin(), transport.getDestination(), transport.getType())); + return transports; + } + + /** + * Return the bank-to-target transport requirements from the exact route already compared. + * + *

This must not perform another search from the player's current pre-bank location. Doing so + * can select a different transport network from the route whose distance caused the banking + * decision, and then withdraw items for a route that will never be executed.

+ */ + public static List getRequiredTransportEdgesFromBank( + TransportRouteAnalysis analysis) + { + if (analysis == null || !analysis.isRouteFromBankStepsExact()) + { + return List.of(); + } + return applyTransportEdgeFiltering(analysis.getTransportEdgesFromBank()); + } + + /** + * Compatibility API for Hub plugins compiled against concrete shortest-path transports. + * New code must use {@link #getTransportEdgesForDestination(WorldPoint, boolean)}. + */ + @Deprecated public static List getTransportsForDestination(WorldPoint destination, boolean useBankItems, TransportType prefTransportType) { if (destination == null) { return new ArrayList<>(); } + WorldPoint start = Rs2Player.getWorldLocation(); + if (start == null) { + return new ArrayList<>(); + } - boolean originalUseBankItems = Rs2PathApi.getPathfinderConfig().isUseBankItems(); - try { - Rs2PathApi.getPathfinderConfig().setUseBankItems(useBankItems); - Rs2PathApi.getPathfinderConfig().refresh(); - Pathfinder pf = new Pathfinder(Rs2PathApi.getPathfinderConfig(), Rs2Player.getWorldLocation(), destination); - pf.run(); - - List path = pf.getPath(); - if (path.isEmpty()) { - log.debug("Unable to find path to destination: " + destination); - return new ArrayList<>(); - } - - List transports = Rs2Walker.getTransportsForPath(path, 0, prefTransportType, true); - transports.forEach(t -> log.debug("Transport found: " + t)); - return transports; - } finally { - Rs2PathApi.getPathfinderConfig().setUseBankItems(originalUseBankItems); - Rs2PathApi.getPathfinderConfig().refresh(); + Rs2RouteResult route = Rs2PathApi.plan( + Rs2RouteRequest.to(start, destination) + .withBankItems(useBankItems)); + List path = route.getPath(); + if (path.isEmpty()) { + log.debug("Unable to find path to destination: " + destination); + return new ArrayList<>(); } + + // This deprecated return type cannot carry the planner-owned immutable edge, so retain the + // historical catalog view for binary compatibility. The active banking path above uses exact + // Rs2TransportEdge instances and never enters this endpoint-based adapter. + List transports = Rs2Walker.getTransportsForPath( + path, 0, prefTransportType, true); + transports.forEach(t -> log.debug("Transport found: " + t)); + return transports; } /** @@ -86,6 +162,82 @@ static boolean planningCoversPlainTransport(Transport transport) { || (transport.getItemIdRequirements() != null && !transport.getItemIdRequirements().isEmpty()); } + /** Legacy concrete-transport filter while banking consumers migrate to immutable edge views. */ + public static List applyTransportFiltering(List transports) { + return transports.stream() + .filter(t -> t.getType() == TransportType.TELEPORTATION_ITEM + || t.getType() == TransportType.FAIRY_RING + || t.getType() == TransportType.TELEPORTATION_SPELL + || t.getType() == TransportType.CANOE + || t.getType() == TransportType.BOAT + || t.getType() == TransportType.CHARTER_SHIP + || t.getType() == TransportType.SHIP + || t.getType() == TransportType.MINECART + || t.getType() == TransportType.MAGIC_CARPET + || t.getType() == TransportType.SPIRIT_TREE + || planningCoversPlainTransport(t) + || t.getType() == TransportType.SEASONAL_TRANSPORT + && Rs2LeaguesTransport.isLeaguesActive() + && t.getDisplayInfo() != null + && t.getDisplayInfo().toLowerCase().startsWith("leagues area:")) + .peek(t -> { + if (t.getType() == TransportType.FAIRY_RING + && (t.getItemIdRequirements() == null || t.getItemIdRequirements().isEmpty()) + && Microbot.getVarbitValue(VarbitID.LUMBRIDGE_DIARY_ELITE_COMPLETE) != 1) { + t.setItemIdRequirements(Set.of(Set.of( + ItemID.DRAMEN_STAFF, + ItemID.LUNAR_MOONCLAN_LIMINAL_STAFF))); + } + if (isCurrencyBasedTransport(t.getType()) + && (t.getItemIdRequirements() == null || t.getItemIdRequirements().isEmpty()) + && t.getCurrencyName() != null && !t.getCurrencyName().isEmpty() + && t.getCurrencyAmount() > 0) { + int currencyItemId = getCurrencyItemId(t.getCurrencyName()); + if (currencyItemId != -1) { + t.setItemIdRequirements(Set.of(Set.of(currencyItemId))); + log.debug("Set currency requirement for {}: {} x{} (ID: {})", + t.getType(), t.getCurrencyName(), t.getCurrencyAmount(), currencyItemId); + } + } + }) + .collect(Collectors.toList()); + } + + static boolean planningCoversPlainTransportEdge(Rs2TransportEdge transport) + { + return transport != null + && transport.getType() == Rs2TransportType.TRANSPORT + && (transport.getCurrencyAmount() > 0 || !transport.getItemRequirements().isEmpty()); + } + + /** Filter selected immutable route edges down to transports relevant to bank preparation. */ + public static List applyTransportEdgeFiltering( + List transports) + { + if (transports == null) + { + return List.of(); + } + return transports.stream() + .filter(transport -> transport.getType() == Rs2TransportType.TELEPORTATION_ITEM + || transport.getType() == Rs2TransportType.FAIRY_RING + || transport.getType() == Rs2TransportType.TELEPORTATION_SPELL + || transport.getType() == Rs2TransportType.CANOE + || transport.getType() == Rs2TransportType.BOAT + || transport.getType() == Rs2TransportType.CHARTER_SHIP + || transport.getType() == Rs2TransportType.SHIP + || transport.getType() == Rs2TransportType.MINECART + || transport.getType() == Rs2TransportType.MAGIC_CARPET + || transport.getType() == Rs2TransportType.SPIRIT_TREE + || planningCoversPlainTransportEdge(transport) + || transport.getType() == Rs2TransportType.SEASONAL_TRANSPORT + && Rs2LeaguesTransport.isLeaguesActive() + && transport.getDisplayInfo() != null + && transport.getDisplayInfo().toLowerCase(Locale.ROOT) + .startsWith("leagues area:")) + .collect(Collectors.toUnmodifiableList()); + } + public static boolean hasRequiredTransportItems(Transport transport) { if (transport == null) { return false; @@ -107,6 +259,14 @@ public static boolean hasRequiredTransportItems(Transport transport) { || transport.getType() == TransportType.MAGIC_CARPET || planningCoversPlainTransport(transport)) { if (transport.getType() == TransportType.TELEPORTATION_SPELL && transport.getDisplayInfo() != null) { + if (!transport.getItemRequirements().isEmpty()) { + return TransportItemRequirement.selectProviders( + transport.getItemRequirements(), + Rs2WalkerBankingPlanner::carriedRequirementItemQuantity, + itemId -> Rs2Equipment.isWearing(itemId) || Rs2Inventory.hasItem(itemId), + itemId -> Rs2Equipment.isWearing(itemId) || Rs2Inventory.hasItem(itemId)) + .isPresent(); + } String spellName = transport.getDisplayInfo().contains(":") ? transport.getDisplayInfo().split(":")[0].trim() : transport.getDisplayInfo().trim(); @@ -124,21 +284,97 @@ public static boolean hasRequiredTransportItems(Transport transport) { && !transport.getCurrencyName().isEmpty() && transport.getCurrencyAmount() > 0) { int currencyItemId = getCurrencyItemId(transport.getCurrencyName()); - return Rs2Inventory.count(currencyItemId) >= transport.getCurrencyAmount(); + return Rs2Inventory.itemQuantity(currencyItemId) >= transport.getCurrencyAmount(); } if (transport.getItemIdRequirements() == null || transport.getItemIdRequirements().isEmpty()) { return true; } - return transport.getItemIdRequirements() - .stream() - .flatMap(Collection::stream) - .anyMatch(itemId -> Rs2Equipment.isWearing(itemId) || Rs2Inventory.hasItem(itemId)); + return transport.getItemRequirements().stream() + .allMatch(requirement -> requirement.isSatisfiedBy( + Rs2WalkerBankingPlanner::carriedItemQuantity)); } return true; } + public static boolean hasRequiredTransportEdgeItems(Rs2TransportEdge transport) + { + if (transport == null) + { + return false; + } + if (transport.getType() == Rs2TransportType.FAIRY_RING) + { + return hasFairyRingAccess(); + } + if (!isBankPlanningTransport(transport)) + { + return true; + } + if (isSpellTransport(transport) && transport.getDisplayInfo() != null) + { + if (!transport.getItemRequirements().isEmpty()) + { + return Rs2TransportItemRequirement.selectProviders( + transport.getItemRequirements(), + Rs2WalkerBankingPlanner::carriedRequirementItemQuantity, + itemId -> Rs2Equipment.isWearing(itemId) || Rs2Inventory.hasItem(itemId), + itemId -> Rs2Equipment.isWearing(itemId) || Rs2Inventory.hasItem(itemId)) + .isPresent(); + } + Rs2Spells rs2Spell = Rs2Magic.getRs2Spell(spellLookupName(transport.getDisplayInfo())); + return rs2Spell != null && Rs2Magic.hasRequiredRunes(rs2Spell); + } + if (isCurrencyBasedTransport(transport.getType()) + && transport.getItemRequirements().isEmpty() + && !transport.getCurrencyName().isEmpty() + && transport.getCurrencyAmount() > 0) + { + int currencyItemId = getCurrencyItemId(transport.getCurrencyName()); + return currencyItemId > 0 + && Rs2Inventory.itemQuantity(currencyItemId) >= transport.getCurrencyAmount(); + } + return transport.getItemRequirements().stream() + .allMatch(requirement -> requirement.isSatisfiedBy( + Rs2WalkerBankingPlanner::carriedItemQuantity)); + } + + private static boolean hasFairyRingAccess() + { + return Rs2Inventory.hasItem(ItemID.DRAMEN_STAFF) + || Rs2Equipment.isWearing(ItemID.DRAMEN_STAFF) + || Rs2Inventory.hasItem(ItemID.LUNAR_MOONCLAN_LIMINAL_STAFF) + || Rs2Equipment.isWearing(ItemID.LUNAR_MOONCLAN_LIMINAL_STAFF) + || Microbot.getVarbitValue(VarbitID.LUMBRIDGE_DIARY_ELITE_COMPLETE) == 1; + } + + private static boolean isBankPlanningTransport(Rs2TransportEdge transport) + { + Rs2TransportType type = transport.getType(); + return type == Rs2TransportType.TELEPORTATION_ITEM + || isSpellTransport(transport) + || type == Rs2TransportType.CANOE + || type == Rs2TransportType.BOAT + || type == Rs2TransportType.CHARTER_SHIP + || type == Rs2TransportType.SHIP + || type == Rs2TransportType.MINECART + || type == Rs2TransportType.MAGIC_CARPET + || planningCoversPlainTransportEdge(transport); + } + + private static boolean isSpellTransport(Rs2TransportEdge transport) + { + return transport.getType() == Rs2TransportType.TELEPORTATION_SPELL; + } + + private static String spellLookupName(String displayInfo) + { + return displayInfo.contains(":") + ? displayInfo.split(":", 2)[0].trim().toLowerCase(Locale.ROOT) + : displayInfo.trim().toLowerCase(Locale.ROOT); + } + public static List getMissingTransports(List transports) { if (transports == null) { return new ArrayList<>(); @@ -149,7 +385,32 @@ public static List getMissingTransports(List transports) { .collect(Collectors.toList()); } + public static List getMissingTransportEdges( + List transports) + { + if (transports == null) + { + return List.of(); + } + return transports.stream() + .filter(transport -> !hasRequiredTransportEdgeItems(transport)) + .collect(Collectors.toUnmodifiableList()); + } + public static Map getMissingTransportItemIdsWithQuantities(List transports) { + return getMissingTransportItemIdsWithQuantities(transports, Rs2Bank::count); + } + + /** + * Pure selection seam for tests and callers that already hold a bank snapshot. + * + *

The public entry point supplies {@link Rs2Bank#count(int)}. Keeping the provider outside the + * selection rules prevents headless tests from waiting on the client thread and lets the AND/OR + * choice policy be verified independently of the bank widget. + */ + static Map getMissingTransportItemIdsWithQuantities( + List transports, + IntUnaryOperator bankQuantityProvider) { if (transports == null) { return new HashMap<>(); } @@ -162,7 +423,7 @@ public static Map getMissingTransportItemIdsWithQuantities(Lis if (!spellRuneRequirements.isEmpty()) { spellRuneRequirements.forEach((runeItemId, requiredQuantity) -> { try { - int bankQuantity = Rs2Bank.count(runeItemId); + int bankQuantity = bankQuantityProvider.applyAsInt(runeItemId); int currentQuantity = itemQuantityMap.getOrDefault(runeItemId, 0); itemQuantityMap.put(runeItemId, currentQuantity + requiredQuantity); log.debug("Added teleportation spell rune requirement: {} (ID: {}) x{} (bank has: {} short={})", @@ -194,22 +455,35 @@ public static Map getMissingTransportItemIdsWithQuantities(Lis return; } - if (transport.getItemIdRequirements() != null) { - for (Set alternativeItems : transport.getItemIdRequirements()) { - int requiredQuantity = (isCurrencyBasedTransport(transport.getType()) && transport.getCurrencyAmount() > 0) - ? transport.getCurrencyAmount() - : 1; + if (transport.getItemRequirements() != null) { + for (TransportItemRequirement requirement : transport.getItemRequirements()) { + if (requirement.isSatisfiedBy(Rs2WalkerBankingPlanner::carriedItemQuantity)) { + continue; + } + Set alternativeItems = requirement.getItemIds(); Integer preferredItemId = null; int preferredBankQuantity = 0; for (Integer itemId : alternativeItems) { + int requiredQuantity = requirement.getRequiredQuantity(itemId); + if (requiredQuantity == 0) { + continue; + } int bankQuantity = 0; try { - bankQuantity = Rs2Bank.count(itemId); + bankQuantity = bankQuantityProvider.applyAsInt(itemId); } catch (Exception e) { log.debug("Could not check bank for item " + itemId + ": " + e.getMessage()); } - if (preferredItemId == null || bankQuantity > preferredBankQuantity) { + int preferredRequired = preferredItemId == null + ? Integer.MAX_VALUE + : requirement.getRequiredQuantity(preferredItemId); + boolean satisfies = bankQuantity >= requiredQuantity; + boolean preferredSatisfies = preferredItemId != null + && preferredBankQuantity >= preferredRequired; + if (preferredItemId == null + || (satisfies && !preferredSatisfies) + || (satisfies == preferredSatisfies && bankQuantity > preferredBankQuantity)) { preferredItemId = itemId; preferredBankQuantity = bankQuantity; } @@ -226,23 +500,22 @@ public static Map getMissingTransportItemIdsWithQuantities(Lis .orElse(null); int currencyItemId = purchasable == null ? -1 : getCurrencyItemId(purchasable.costCurrencyName); if (currencyItemId > 0) { - // One fare per required ITEM. requiredQuantity above is a currency - // amount for currency-based rows, so it must not be used as a count. + int requiredQuantity = requirement.getRequiredQuantity(purchasable.itemId); int itemsNeeded = isCurrencyBasedTransport(transport.getType()) ? 1 : requiredQuantity; int fare = purchasable.costAmount * itemsNeeded; itemQuantityMap.merge(currencyItemId, fare, Integer::sum); log.debug("Transport item {} not banked but purchasable — withdrawing fare {} x{} instead", purchasable.itemId, purchasable.costCurrencyName, fare); - break; + continue; } } if (preferredItemId != null) { + int requiredQuantity = requirement.getRequiredQuantity(preferredItemId); int currentQuantity = itemQuantityMap.getOrDefault(preferredItemId, 0); itemQuantityMap.put(preferredItemId, currentQuantity + requiredQuantity); log.debug("Added transport item requirement: itemId={} x{} (bank has: {} short={})", preferredItemId, requiredQuantity, preferredBankQuantity, preferredBankQuantity < requiredQuantity); } - break; } } }); @@ -250,6 +523,300 @@ public static Map getMissingTransportItemIdsWithQuantities(Lis return itemQuantityMap; } + public static Map getMissingTransportEdgeItemIdsWithQuantities( + List transports) + { + return getMissingTransportEdgeLoadout(transports).getWithdrawals(); + } + + /** + * Build one atomic preparation contract for the exact selected edges. + * + *

Rune quantities include the inventory, rune pouch, equipped providers and combination runes. + * Bank rune quantities are kept separate from raw bank stacks because a semantic contribution + * still has to resolve to a concrete item that can actually be withdrawn.

+ */ + public static Rs2TransportLoadout getMissingTransportEdgeLoadout( + List transports) + { + Map carriedRunes = runeQuantities( + RuneFilter.builder().includeBank(false).build()); + Map bankRunes = runeQuantities(RuneFilter.builder() + .includeInventory(false) + .includeEquipment(false) + .includeRunePouch(false) + .includeBank(true) + .build()); + return getMissingTransportEdgeLoadout( + transports, + Rs2Bank::count, + Rs2WalkerBankingPlanner::carriedItemQuantity, + itemId -> carriedRunes.getOrDefault(itemId, carriedItemQuantity(itemId)), + itemId -> bankRunes.getOrDefault(itemId, safeQuantity(Rs2Bank::count, itemId)), + Rs2Equipment::isWearing); + } + + /** Compatibility view for callers that only consume withdrawals. */ + static Map getMissingTransportEdgeItemIdsWithQuantities( + List transports, + IntUnaryOperator bankQuantityProvider, + IntUnaryOperator carriedQuantityProvider) + { + return getMissingTransportEdgeLoadout( + transports, + bankQuantityProvider, + carriedQuantityProvider, + carriedQuantityProvider, + bankQuantityProvider, + ignored -> false).getWithdrawals(); + } + + /** Pure source-aware selection seam used by banking regressions. */ + static Rs2TransportLoadout getMissingTransportEdgeLoadout( + List transports, + IntUnaryOperator bankQuantityProvider, + IntUnaryOperator carriedQuantityProvider, + IntUnaryOperator carriedRequirementQuantityProvider, + IntUnaryOperator bankRequirementQuantityProvider, + IntPredicate equippedItemProvider) + { + if (transports == null) + { + return Rs2TransportLoadout.empty(); + } + Map withdrawals = new LinkedHashMap<>(); + LinkedHashSet equipmentItemIds = new LinkedHashSet<>(); + for (Rs2TransportEdge transport : transports) + { + if (isSpellTransport(transport) && transport.getItemRequirements().isEmpty()) + { + for (Map.Entry rune : getSpellRuneRequirements(transport).entrySet()) + { + int bankQuantity = safeQuantity(bankQuantityProvider, rune.getKey()); + if (bankQuantity < rune.getValue()) + { + return Rs2TransportLoadout.unavailable(); + } + withdrawals.merge(rune.getKey(), rune.getValue(), Integer::sum); + } + continue; + } + + if (isCurrencyBasedTransport(transport.getType()) + && transport.getCurrencyAmount() > 0 + && transport.getItemRequirements().isEmpty()) + { + int currencyItemId = getCurrencyItemId(transport.getCurrencyName()); + if (currencyItemId > 0) + { + withdrawals.merge( + currencyItemId, transport.getCurrencyAmount(), Integer::sum); + } + continue; + } + + List requirements = transport.getItemRequirements(); + if (transport.getType() == Rs2TransportType.FAIRY_RING + && requirements.isEmpty() + && Microbot.getVarbitValue(VarbitID.LUMBRIDGE_DIARY_ELITE_COMPLETE) != 1) + { + requirements = List.of(new Rs2TransportItemRequirement(Map.of( + ItemID.DRAMEN_STAFF, 1, + ItemID.LUNAR_MOONCLAN_LIMINAL_STAFF, 1))); + } + + Rs2TransportItemRequirement.ProviderSelection providers = + Rs2TransportItemRequirement.selectEquipmentProviders( + requirements, + itemId -> safeSum( + safeQuantity(carriedRequirementQuantityProvider, itemId), + safeQuantity(bankRequirementQuantityProvider, itemId)), + itemId -> safeSum( + safeQuantity(carriedQuantityProvider, itemId), + safeQuantity(bankQuantityProvider, itemId)) > 0, + itemId -> safeSum( + safeQuantity(carriedQuantityProvider, itemId), + safeQuantity(bankQuantityProvider, itemId)) > 0) + .orElse(null); + if (providers == null) + { + return Rs2TransportLoadout.unavailable(); + } + if (!addProviderPreparation( + providers.getStaffItemId(), withdrawals, equipmentItemIds, + bankQuantityProvider, carriedQuantityProvider, equippedItemProvider) + || !addProviderPreparation( + providers.getOffhandItemId(), withdrawals, equipmentItemIds, + bankQuantityProvider, carriedQuantityProvider, equippedItemProvider)) + { + return Rs2TransportLoadout.unavailable(); + } + + for (Rs2TransportItemRequirement requirement : requirements) + { + if (requirement.isSatisfiedBy(carriedRequirementQuantityProvider) + || requirement.getStaffAlternatives().contains(providers.getStaffItemId()) + || requirement.getOffhandAlternatives().contains(providers.getOffhandItemId())) + { + continue; + } + if (!addPreferredRequirement( + withdrawals, + requirement.getAlternatives(), + transport.getType(), + bankQuantityProvider, + carriedRequirementQuantityProvider)) + { + return Rs2TransportLoadout.unavailable(); + } + } + } + if (withdrawals.isEmpty() && equipmentItemIds.isEmpty()) + { + return Rs2TransportLoadout.empty(); + } + return new Rs2TransportLoadout( + withdrawals, new ArrayList<>(equipmentItemIds), true); + } + + private static boolean addProviderPreparation( + int itemId, + Map withdrawals, + Set equipmentItemIds, + IntUnaryOperator bankQuantityProvider, + IntUnaryOperator carriedQuantityProvider, + IntPredicate equippedItemProvider) + { + if (itemId <= 0 || equippedItemProvider.test(itemId)) + { + return true; + } + equipmentItemIds.add(itemId); + if (safeQuantity(carriedQuantityProvider, itemId) > 0) + { + return true; + } + if (safeQuantity(bankQuantityProvider, itemId) <= 0) + { + return false; + } + withdrawals.merge(itemId, 1, Math::max); + return true; + } + + private static boolean addPreferredRequirement( + Map requested, + Map alternatives, + Rs2TransportType transportType, + IntUnaryOperator bankQuantityProvider, + IntUnaryOperator carriedQuantityProvider) + { + Integer preferredItemId = null; + int preferredBankQuantity = 0; + int preferredDeficit = Integer.MAX_VALUE; + for (Map.Entry alternative : alternatives.entrySet()) + { + int itemId = alternative.getKey(); + int requiredQuantity = alternative.getValue(); + if (requiredQuantity == 0) + { + continue; + } + int bankQuantity = safeQuantity(bankQuantityProvider, itemId); + int deficit = Math.max(0, + requiredQuantity - safeQuantity(carriedQuantityProvider, itemId)); + boolean satisfies = bankQuantity >= deficit; + boolean preferredSatisfies = preferredItemId != null + && preferredBankQuantity >= preferredDeficit; + if (preferredItemId == null + || satisfies && !preferredSatisfies + || satisfies == preferredSatisfies && deficit < preferredDeficit + || satisfies == preferredSatisfies && deficit == preferredDeficit + && bankQuantity > preferredBankQuantity) + { + preferredItemId = itemId; + preferredBankQuantity = bankQuantity; + preferredDeficit = deficit; + } + } + + if (preferredItemId == null) + { + return false; + } + if (preferredBankQuantity < preferredDeficit) + { + PurchasableItemCatalog.PurchasableItem purchasable = alternatives.keySet().stream() + .map(PurchasableItemCatalog::byItemId) + .filter(java.util.Objects::nonNull) + .findFirst() + .orElse(null); + int currencyItemId = purchasable == null + ? -1 : getCurrencyItemId(purchasable.costCurrencyName); + if (currencyItemId > 0) + { + int requiredQuantity = alternatives.get(purchasable.itemId); + int itemsNeeded = isCurrencyBasedTransport(transportType) + ? 1 : requiredQuantity; + requested.merge( + currencyItemId, purchasable.costAmount * itemsNeeded, Integer::sum); + return true; + } + return false; + } + if (preferredDeficit > 0) + { + requested.merge(preferredItemId, preferredDeficit, Integer::sum); + } + return true; + } + + private static Map runeQuantities(RuneFilter filter) + { + Map quantities = new HashMap<>(); + Rs2Magic.getRunes(filter).forEach((rune, quantity) -> + quantities.put(rune.getItemId(), quantity)); + return quantities; + } + + private static int safeSum(int first, int second) + { + long sum = (long) Math.max(0, first) + Math.max(0, second); + return sum >= Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) sum; + } + + private static int safeQuantity(IntUnaryOperator provider, int itemId) + { + try + { + return Math.max(0, provider.applyAsInt(itemId)); + } + catch (Exception exception) + { + log.debug("Could not check bank for item {}: {}", itemId, exception.getMessage()); + return 0; + } + } + + private static int carriedItemQuantity(int itemId) { + int quantity = Rs2Inventory.itemQuantity(itemId); + net.runelite.client.plugins.microbot.util.inventory.Rs2ItemModel equipped = Rs2Equipment.get(itemId); + if (equipped != null) { + quantity += Math.max(1, equipped.getQuantity()); + } + return quantity; + } + + private static int carriedRequirementItemQuantity(int itemId) + { + Runes rune = Runes.byItemId(itemId); + if (rune == null) + { + return carriedItemQuantity(itemId); + } + return Rs2Magic.getRunes().getOrDefault(rune, 0); + } + public static List getMissingTransportItemIds(List transports) { return new ArrayList<>(getMissingTransportItemIdsWithQuantities(transports).keySet()); } @@ -273,7 +840,10 @@ public static TransportRouteAnalysis compareRoutes(WorldPoint startPoint, WorldP try { performanceLog.append("\tStart Point: ").append(startPoint).append(", Target: ").append(target).append("\n"); long directPathStartTime = System.nanoTime(); - List directPath = Rs2Walker.getWalkPath(startPoint, target); + Rs2RouteResult directRoute = planRoute( + startPoint, target, false, Rs2RouteRequest.Purpose.BANK_ROUTE_DIRECT); + List directPath = directRoute.getPath(); + List directRouteSteps = directRoute.getSteps(); long directPathEndTime = System.nanoTime(); double directPathTimeMs = (directPathEndTime - directPathStartTime) / 1_000_000.0; @@ -283,47 +853,56 @@ public static TransportRouteAnalysis compareRoutes(WorldPoint startPoint, WorldP BankLocation nearestBank = null; List pathToBank = new ArrayList<>(); + List routeToBankSteps = List.of(); List pathFromBankToTarget = new ArrayList<>(); + List routeFromBankSteps = List.of(); int bankingRouteDistance = -1; try { - boolean originalUseBankItems = Rs2PathApi.getPathfinderConfig().isUseBankItems(); - try { - Rs2PathApi.getPathfinderConfig().setUseBankItems(true); - Rs2PathApi.getPathfinderConfig().refresh(target); - - performanceLog.append("\t-Bank items available: ").append(Rs2Bank.bankItems().size()).append("\n"); + performanceLog.append("\t-Bank items available: ").append(Rs2Bank.bankItems().size()).append("\n"); - long bankSearchStartTime = System.nanoTime(); - nearestBank = Rs2Bank.getNearestBank(startPoint); - long bankSearchEndTime = System.nanoTime(); - double bankSearchTimeMs = (bankSearchEndTime - bankSearchStartTime) / 1_000_000.0; + long bankSearchStartTime = System.nanoTime(); + nearestBank = Rs2Bank.getNearestBank(startPoint); + long bankSearchEndTime = System.nanoTime(); + double bankSearchTimeMs = (bankSearchEndTime - bankSearchStartTime) / 1_000_000.0; - if (nearestBank != null) { + if (nearestBank != null) { WorldPoint bankLocation = nearestBank.getWorldPoint(); performanceLog.append("\t-Nearest bank search: ").append(String.format("%.2f ms", bankSearchTimeMs)); performanceLog.append("\t -> Found: ").append(nearestBank).append(" at ").append(bankLocation).append("\n"); long pathToBankStartTime = System.nanoTime(); - pathToBank = Rs2Walker.getWalkPath(startPoint, bankLocation); + Rs2RouteResult bankRoute = planRoute( + startPoint, bankLocation, false, + Rs2RouteRequest.Purpose.BANK_ROUTE_TO_BANK); + pathToBank = bankRoute.getPath(); + routeToBankSteps = bankRoute.getSteps(); long pathToBankEndTime = System.nanoTime(); double pathToBankTimeMs = (pathToBankEndTime - pathToBankStartTime) / 1_000_000.0; int distanceToBank = Rs2Walker.getTotalTilesFromPath(pathToBank, bankLocation); long pathFromBankStartTime = System.nanoTime(); - pathFromBankToTarget = Rs2Walker.getWalkPath(bankLocation, target); + Rs2RouteResult bankTargetRoute = planRoute( + bankLocation, target, true, + Rs2RouteRequest.Purpose.BANK_ROUTE_FROM_BANK); + pathFromBankToTarget = bankTargetRoute.getPath(); + routeFromBankSteps = bankTargetRoute.getSteps(); long pathFromBankEndTime = System.nanoTime(); double pathFromBankTimeMs = (pathFromBankEndTime - pathFromBankStartTime) / 1_000_000.0; - List bankLegTransports = Rs2Walker.getTransportsForPath( - pathFromBankToTarget, 0, TransportType.TELEPORTATION_SPELL, true); + List bankLegTransports = bankTargetRoute.getTransportSteps().stream() + .map(Rs2RouteStep::getTransport) + .map(transport -> transport.orElseThrow( + () -> new IllegalStateException("transport step has no edge"))) + .collect(Collectors.toList()); long spellCount = bankLegTransports.stream() - .filter(t -> t.getType() == TransportType.TELEPORTATION_SPELL) + .filter(t -> t.getType() == Rs2TransportType.TELEPORTATION_SPELL) .count(); long itemCount = bankLegTransports.stream() - .filter(t -> t.getType() == TransportType.TELEPORTATION_ITEM) + .filter(t -> t.getType() == Rs2TransportType.TELEPORTATION_ITEM) .count(); int distanceFromBankRaw = Rs2Walker.getTotalTilesFromPath(pathFromBankToTarget, target); - int distanceFromBank = effectiveDistanceFromBank(pathFromBankToTarget, distanceFromBankRaw); + int distanceFromBank = effectiveDistanceFromBank( + pathFromBankToTarget, bankTargetRoute.getSteps(), distanceFromBankRaw); performanceLog.append("\t-Path to bank calculation: ").append(String.format("%.2f ms", pathToBankTimeMs)) .append(" (").append(pathToBank.size()).append(" waypoints, ").append(distanceToBank).append(" tiles)\n"); @@ -333,8 +912,8 @@ public static TransportRouteAnalysis compareRoutes(WorldPoint startPoint, WorldP .append(" spells=").append(spellCount) .append(" items=").append(itemCount) .append("\n"); - Transport firstSpellTransport = bankLegTransports.stream() - .filter(t -> t.getType() == TransportType.TELEPORTATION_SPELL) + Rs2TransportEdge firstSpellTransport = bankLegTransports.stream() + .filter(t -> t.getType() == Rs2TransportType.TELEPORTATION_SPELL) .findFirst() .orElse(null); if (firstSpellTransport != null) { @@ -366,13 +945,9 @@ public static TransportRouteAnalysis compareRoutes(WorldPoint startPoint, WorldP bankingRouteDistance = distanceToBank + distanceFromBank; } performanceLog.append("\t-Total banking route distance: ").append(bankingRouteDistance).append(" tiles\n"); - } else { - performanceLog.append("\t-Nearest bank search: ").append(String.format("%.2f ms", bankSearchTimeMs)) - .append("\t -> No accessible bank found\n"); - } - } finally { - Rs2PathApi.getPathfinderConfig().setUseBankItems(originalUseBankItems); - Rs2PathApi.getPathfinderConfig().refresh(); + } else { + performanceLog.append("\t-Nearest bank search: ").append(String.format("%.2f ms", bankSearchTimeMs)) + .append("\t -> No accessible bank found\n"); } } catch (Exception e) { performanceLog.append("Banking route calculation failed: ").append(e.getMessage()).append("\n"); @@ -388,7 +963,8 @@ public static TransportRouteAnalysis compareRoutes(WorldPoint startPoint, WorldP WebWalkLog.compareDetail(performanceLog.toString()); WebWalkLog.compareSummary(totalTimeMs, directDistance, -1, "direct_only_bank_unavailable"); return new TransportRouteAnalysis(directPath, null, null, new ArrayList<>(), new ArrayList<>(), - "Direct route only (banking route unavailable)"); + "Direct route only (banking route unavailable)", directDistance, -1, + directRouteSteps, List.of(), List.of()); } final boolean tie = directDistance == bankingRouteDistance; @@ -418,7 +994,8 @@ public static TransportRouteAnalysis compareRoutes(WorldPoint startPoint, WorldP return new TransportRouteAnalysis(directPath, nearestBank, nearestBank != null ? nearestBank.getWorldPoint() : null, pathToBank, pathFromBankToTarget, recommendation, - directDistance, bankingRouteDistance); + directDistance, bankingRouteDistance, + directRouteSteps, routeToBankSteps, routeFromBankSteps); } catch (Exception e) { long totalEndTime = System.nanoTime(); double totalTimeMs = (totalEndTime - totalStartTime) / 1_000_000.0; @@ -429,6 +1006,19 @@ public static TransportRouteAnalysis compareRoutes(WorldPoint startPoint, WorldP } } + private static Rs2RouteResult planRoute( + WorldPoint start, + WorldPoint target, + boolean useBankItems, + Rs2RouteRequest.Purpose purpose) + { + return Rs2PathApi.plan( + Rs2RouteRequest.to(start, target) + .withRefreshTarget(target) + .withBankItems(useBankItems) + .withPurpose(purpose)); + } + private static Map getSpellRuneRequirements(Transport transport) { Map runeRequirements = new HashMap<>(); if (transport.getType() != TransportType.TELEPORTATION_SPELL || transport.getDisplayInfo() == null) { @@ -465,6 +1055,31 @@ private static Map getSpellRuneRequirements(Transport transpor return runeRequirements; } + private static Map getSpellRuneRequirements(Rs2TransportEdge transport) + { + Map runeRequirements = new HashMap<>(); + if (!isSpellTransport(transport) || transport.getDisplayInfo() == null) + { + return runeRequirements; + } + try + { + Rs2Spells rs2Spell = Rs2Magic.getRs2Spell(spellLookupName(transport.getDisplayInfo())); + if (rs2Spell == null) + { + return runeRequirements; + } + Rs2Magic.getRequiredRunes(rs2Spell, 1, true).forEach((rune, quantity) -> + runeRequirements.put(rune.getItemId(), quantity)); + } + catch (Exception exception) + { + log.warn("Error getting spell rune requirements for transport '{}': {}", + transport.getDisplayInfo(), exception.getMessage()); + } + return runeRequirements; + } + /** Package-private so the planning tests can select the same rows this collector accepts. */ static boolean isCurrencyBasedTransport(TransportType transportType) { return transportType == TransportType.BOAT @@ -475,6 +1090,16 @@ static boolean isCurrencyBasedTransport(TransportType transportType) { || transportType == TransportType.TRANSPORT; } + static boolean isCurrencyBasedTransport(Rs2TransportType transportType) + { + return transportType == Rs2TransportType.BOAT + || transportType == Rs2TransportType.CHARTER_SHIP + || transportType == Rs2TransportType.SHIP + || transportType == Rs2TransportType.MINECART + || transportType == Rs2TransportType.MAGIC_CARPET + || transportType == Rs2TransportType.TRANSPORT; + } + private static int getCurrencyItemId(String currencyName) { if (currencyName == null || currencyName.trim().isEmpty()) { return -1; @@ -497,64 +1122,72 @@ private static int getCurrencyItemId(String currencyName) { * For originless TELEPORTATION_ITEM / TELEPORTATION_SPELL edges, trim pre-teleport walking * from the bank leg metric and keep the post-teleport tail. */ - private static int effectiveDistanceFromBank(List pathFromBankToTarget, int rawDistance) { - if (pathFromBankToTarget == null || pathFromBankToTarget.isEmpty() || rawDistance == Integer.MAX_VALUE) { - return rawDistance; - } + static int effectiveDistanceFromBank( + List pathFromBankToTarget, + List routeSteps, + int rawDistance) { + if (pathFromBankToTarget == null || pathFromBankToTarget.isEmpty() || rawDistance == Integer.MAX_VALUE) { + return rawDistance; + } + if (routeSteps == null || routeSteps.isEmpty()) { + return rawDistance; + } - List transports = Rs2Walker.getTransportsForPath(pathFromBankToTarget, 0, TransportType.TELEPORTATION_SPELL, true); - if (transports.isEmpty()) { - return rawDistance; - } + int firstTransportStep = -1; + Rs2TransportEdge firstTransport = null; + for (int i = 0; i < routeSteps.size(); i++) { + Rs2RouteStep step = routeSteps.get(i); + if (step != null && step.isTransport()) { + firstTransportStep = i; + firstTransport = step.getTransport().orElse(null); + break; + } + } + if (firstTransport == null) { + return rawDistance; + } - // Use first transport that the bank->target path actually consumes and model: - // walk_to_transport + transport_hop + post_transport_tail. - Transport firstTransport = transports.get(0); - int modeledDistance = transportModeledDistance(pathFromBankToTarget, firstTransport, rawDistance); + // Use the exact first transport selected by this route. Endpoint rematching is ambiguous when + // multiple catalog entries share an origin/destination pair and could score the wrong command. + int modeledDistance = transportModeledDistance( + pathFromBankToTarget, firstTransportStep, firstTransport, rawDistance); if (modeledDistance == Integer.MAX_VALUE) { return rawDistance; } return Math.min(rawDistance, modeledDistance); } - private static boolean isImmediateBankTeleport(Transport transport) { - if (transport == null || transport.getOrigin() != null) { - return false; - } - return transport.getType() == TransportType.TELEPORTATION_ITEM - || transport.getType() == TransportType.TELEPORTATION_SPELL; - } + private static boolean isImmediateBankTeleport(Rs2TransportEdge transport) { + if (transport == null || transport.getOrigin() != null) { + return false; + } + return transport.getType() == Rs2TransportType.TELEPORTATION_ITEM + || transport.getType() == Rs2TransportType.TELEPORTATION_SPELL; + } - private static int transportModeledDistance(List pathFromBankToTarget, Transport transport, int fallbackRawDistance) { - if (transport == null || pathFromBankToTarget == null || pathFromBankToTarget.isEmpty()) { - return fallbackRawDistance; - } + private static int transportModeledDistance( + List pathFromBankToTarget, + int transportStepIndex, + Rs2TransportEdge transport, + int fallbackRawDistance) { + if (transport == null || pathFromBankToTarget == null || pathFromBankToTarget.isEmpty() + || transportStepIndex < 0 || transportStepIndex >= pathFromBankToTarget.size() - 1) { + return fallbackRawDistance; + } - WorldPoint destination = transport.getDestination(); - if (destination == null) { - return fallbackRawDistance; - } - int destinationIndex = pathFromBankToTarget.indexOf(destination); - if (destinationIndex < 0) { - return fallbackRawDistance; - } - - int originIndex; - if (isImmediateBankTeleport(transport)) { - originIndex = 0; - } else { - WorldPoint origin = transport.getOrigin(); - originIndex = origin == null ? 0 : pathFromBankToTarget.indexOf(origin); - if (originIndex < 0) { - originIndex = 0; - } - } - - if (destinationIndex < originIndex) { - return fallbackRawDistance; - } + WorldPoint stepOrigin = pathFromBankToTarget.get(transportStepIndex); + WorldPoint stepDestination = pathFromBankToTarget.get(transportStepIndex + 1); + if (!stepDestination.equals(transport.getDestination())) { + return fallbackRawDistance; + } + if (!isImmediateBankTeleport(transport) + && transport.getOrigin() != null + && !stepOrigin.equals(transport.getOrigin())) { + return fallbackRawDistance; + } - int walkToTransport = Math.max(0, originIndex); + int destinationIndex = transportStepIndex + 1; + int walkToTransport = isImmediateBankTeleport(transport) ? 0 : transportStepIndex; int transportHop = 1; int postTransportTail = Math.max(0, pathFromBankToTarget.size() - destinationIndex); return walkToTransport + transportHop + postTransportTail; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbe.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbe.java index 5191cadb7df..e66aab862dc 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbe.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbe.java @@ -11,11 +11,11 @@ import net.runelite.api.TileObject; import net.runelite.api.WallObject; import net.runelite.api.coords.WorldPoint; -import net.runelite.client.plugins.microbot.shortestpath.Transport; -import net.runelite.client.plugins.microbot.shortestpath.TransportType; import net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject; import net.runelite.client.plugins.microbot.util.player.Rs2Player; import net.runelite.client.plugins.microbot.util.walker.Rs2PathApi; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportEdge; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportType; /** * Door-probe logic that operates against a {@link DoorProbeContext} (the scan-scoped caches) and @@ -47,7 +47,7 @@ public static ObjectComposition resolveDoorComposition(DoorProbeContext ctx, Til /** * True when this scene object is the interactable listed on a transport catalog row (same - * coordinates and object ids as TSV loaded into {@link Rs2PathApi#getTransports()}), and is not + * coordinates and object ids as TSV loaded into the shortest-path catalog), and is not * itself door-like. Used to avoid treating a catalog transport as a plain door. */ public static boolean isCatalogTransportObject(TileObject object) { @@ -62,18 +62,10 @@ public static boolean isCatalogTransportObject(TileObject object) { if (id <= 0) { return false; } - Map> map = Rs2PathApi.getTransports(); - if (map == null || map.isEmpty()) { - return false; - } - for (int dx = -1; dx <= 1; dx++) { + for (int dx = -1; dx <= 1; dx++) { for (int dy = -1; dy <= 1; dy++) { WorldPoint catalogOrigin = new WorldPoint(loc.getX() + dx, loc.getY() + dy, loc.getPlane()); - Set transports = map.get(catalogOrigin); - if (transports == null || transports.isEmpty()) { - continue; - } - for (Transport t : transports) { + for (Rs2TransportEdge t : Rs2PathApi.getCatalogTransportEdges(catalogOrigin)) { if (t != null && t.getObjectId() == id && !isDoorLikeCatalogTransport(t)) { return true; } @@ -83,11 +75,11 @@ public static boolean isCatalogTransportObject(TileObject object) { return false; } - public static boolean isDoorLikeCatalogTransport(Transport transport) { - if (transport == null || transport.getType() != TransportType.TRANSPORT) { + public static boolean isDoorLikeCatalogTransport(Rs2TransportEdge transport) { + if (transport == null || transport.getType() != Rs2TransportType.TRANSPORT) { return false; } - return Rs2DoorClassifier.isDoorLikeGameObjectName(transport.getName()) + return Rs2DoorClassifier.isDoorLikeGameObjectName(transport.getTarget()) || Rs2DoorClassifier.isDoorLikeGameObjectName(transport.getDisplayInfo()) || isDoorLikeTransportAction(transport.getAction()); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/lifecycle/Rs2WalkerLifecycleRuntime.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/lifecycle/Rs2WalkerLifecycleRuntime.java index 4ecbf8588d3..65bedbdd767 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/lifecycle/Rs2WalkerLifecycleRuntime.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/lifecycle/Rs2WalkerLifecycleRuntime.java @@ -1,22 +1,21 @@ package net.runelite.client.plugins.microbot.util.walker.lifecycle; -import com.google.common.util.concurrent.ThreadFactoryBuilder; import lombok.extern.slf4j.Slf4j; import net.runelite.api.Client; import net.runelite.api.Player; import net.runelite.api.coords.LocalPoint; import net.runelite.api.coords.WorldPoint; import net.runelite.client.plugins.microbot.Microbot; -import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; import net.runelite.client.plugins.microbot.util.walker.Rs2PathApi; +import net.runelite.client.plugins.microbot.util.walker.Rs2PlannerShadowContext; +import net.runelite.client.plugins.microbot.util.walker.Rs2RouteRequest; import net.runelite.client.plugins.microbot.util.player.Rs2Player; import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; import net.runelite.client.ui.overlay.worldmap.WorldMapPoint; import net.runelite.client.ui.overlay.worldmap.WorldMapPointManager; import java.util.Set; -import java.util.concurrent.Executors; -import java.util.concurrent.ThreadFactory; +import java.util.Objects; @Slf4j public final class Rs2WalkerLifecycleRuntime { @@ -25,6 +24,24 @@ private Rs2WalkerLifecycleRuntime() { } public static void applyWalkerDestination(WorldPoint target) { + applyWalkerDestination(target, false); + } + + /** Apply a destination while retaining whether the request was a recovery/replan. */ + public static void applyWalkerDestination(WorldPoint target, boolean replan) { + applyWalkerDestination(target, replan + ? Rs2PlannerShadowContext.Invocation.ACTIVE_REPLAN + : Rs2PlannerShadowContext.Invocation.ACTIVE_ROUTE); + } + + /** Apply a destination with explicit, evidence-only invocation classification. */ + public static void applyWalkerDestination( + WorldPoint target, + Rs2PlannerShadowContext.Invocation invocation) { + Objects.requireNonNull(invocation, "invocation"); + if (invocation == Rs2PlannerShadowContext.Invocation.SYNCHRONOUS_QUERY) { + throw new IllegalArgumentException("active destination cannot be a synchronous query"); + } if (target == null) { return; } @@ -74,12 +91,12 @@ public static void applyWalkerDestination(WorldPoint target) { } return Rs2Player.getWorldLocation(); }); - final Pathfinder pathfinder = Rs2PathApi.getPathfinder(); - final WorldPoint effectiveStart = (Rs2PathApi.isStartPointSet() && pathfinder != null) - ? pathfinder.getStart() + final WorldPoint effectiveStart = Rs2PathApi.isStartPointSet() + ? Rs2PathApi.getActiveRouteStart().orElse(start) : start; Rs2PathApi.setLastLocation(effectiveStart); - Microbot.getClientThread().runOnSeperateThread(() -> restartPathfinding(effectiveStart, target)); + Microbot.getClientThread().runOnSeperateThread( + () -> restartPathfinding(effectiveStart, Set.of(target), invocation)); } public static boolean restartPathfinding(WorldPoint start, WorldPoint end) { @@ -87,56 +104,25 @@ public static boolean restartPathfinding(WorldPoint start, WorldPoint end) { } public static boolean restartPathfinding(WorldPoint start, Set ends) { - Pathfinder pathfinder = Rs2PathApi.getPathfinder(); - if (pathfinder != null) { - pathfinder.cancel(); - if (Rs2PathApi.getPathfinderFuture() != null) { - Rs2PathApi.getPathfinderFuture().cancel(true); - } - } + return restartPathfinding( + start, ends, Rs2PlannerShadowContext.Invocation.ACTIVE_ROUTE); + } - if (Rs2PathApi.getPathfindingExecutor() == null) { - ThreadFactory shortestPathNaming = new ThreadFactoryBuilder().setNameFormat("shortest-path-%d").build(); - Rs2PathApi.setPathfindingExecutor(Executors.newSingleThreadExecutor(shortestPathNaming)); + private static boolean restartPathfinding( + WorldPoint start, + Set ends, + Rs2PlannerShadowContext.Invocation invocation) { + if (start == null || ends == null || ends.isEmpty()) { + return false; } - WorldPoint refreshTarget = ends != null && !ends.isEmpty() ? ends.iterator().next() : null; - Rs2PathApi.getPathfinderConfig().refresh(refreshTarget); - if (Rs2Player.isInCave()) { - // Cave pathfinding runs synchronously, so no Future represents the pathfinder installed below. - // Clear the cancelled asynchronous handle instead of leaving stale "work in flight" state. - Rs2PathApi.setPathfinderFuture(null); - pathfinder = new Pathfinder(Rs2PathApi.getPathfinderConfig(), start, ends); - pathfinder.run(); - try { - Rs2PathApi.getPathfinderConfig().setIgnoreTeleportAndItems(true); - Pathfinder pathfinderWithoutTeleports = new Pathfinder(Rs2PathApi.getPathfinderConfig(), start, ends); - pathfinderWithoutTeleports.run(); - - boolean noTeleportPathAvailable = !pathfinderWithoutTeleports.getPath().isEmpty(); - boolean basePathAvailable = pathfinder != null && !pathfinder.getPath().isEmpty(); - if (!noTeleportPathAvailable) { - Rs2PathApi.setPathfinder(basePathAvailable ? pathfinder : pathfinderWithoutTeleports); - return true; - } - - WorldPoint lastPath = pathfinderWithoutTeleports.getPath().get(pathfinderWithoutTeleports.getPath().size() - 1); - int reachedDistance = Rs2Walker.config != null ? Rs2Walker.config.reachedDistance() : 10; - boolean pathWithoutTeleportsIsReachable = lastPath.distanceTo(ends.stream().findFirst().orElse(lastPath)) <= reachedDistance; - if (pathWithoutTeleportsIsReachable - && basePathAvailable - && pathfinder.getPath().size() >= pathfinderWithoutTeleports.getPath().size()) { - Rs2PathApi.setPathfinder(pathfinderWithoutTeleports); - } else { - Rs2PathApi.setPathfinder(basePathAvailable ? pathfinder : pathfinderWithoutTeleports); - } - } finally { - Rs2PathApi.getPathfinderConfig().setIgnoreTeleportAndItems(false); - } - } else { - Rs2PathApi.setPathfinder(new Pathfinder(Rs2PathApi.getPathfinderConfig(), start, ends)); - Rs2PathApi.setPathfinderFuture(Rs2PathApi.getPathfindingExecutor().submit(Rs2PathApi.getPathfinder())); - } - return true; + int reachedDistance = Rs2Walker.config != null ? Rs2Walker.config.reachedDistance() : 10; + return Rs2PathApi.restartActiveRoute( + Rs2RouteRequest.toAny(start, ends) + .withRefreshTarget(refreshTarget) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.ALWAYS), + Rs2Player.isInCave(), + reachedDistance, + invocation); } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/LiveScene.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/LiveScene.java index 84a2cf4d99e..abf4ce9ebda 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/LiveScene.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/LiveScene.java @@ -2,9 +2,6 @@ import net.runelite.api.TileObject; import net.runelite.api.coords.WorldPoint; -import net.runelite.client.plugins.microbot.shortestpath.Transport; - -import java.util.Set; /** * Read-only snapshot of the live world an {@link ObstacleResolver} needs to classify a {@link PlannedEdge}, @@ -21,8 +18,8 @@ public interface LiveScene { /** Whether {@code tile} is walk-reachable from the player right now (live-scene collision BFS). */ boolean isReachable(WorldPoint tile); - /** Transports whose origin is {@code tile} (stairs/ladders/shortcuts/teleports), or empty. */ - Set transportsAt(WorldPoint tile); + /** Whether a transport starts at {@code tile} (stairs/ladders/shortcuts/teleports). */ + boolean hasTransportAt(WorldPoint tile); /** The top interactable object on {@code tile} (door/gate/rockfall/…), or {@code null} if none. */ TileObject objectAt(WorldPoint tile); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/Rs2LiveScene.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/Rs2LiveScene.java index b852876a287..cab98651f4d 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/Rs2LiveScene.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/Rs2LiveScene.java @@ -2,13 +2,10 @@ import net.runelite.api.TileObject; import net.runelite.api.coords.WorldPoint; -import net.runelite.client.plugins.microbot.shortestpath.Transport; import net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject; import net.runelite.client.plugins.microbot.util.walker.Rs2PathApi; -import java.util.Collections; import java.util.Map; -import java.util.Set; /** * Live-client implementation of {@link LiveScene} — the read side of the P2 obstacle plumbing @@ -39,14 +36,9 @@ public boolean isReachable(WorldPoint tile) { } @Override - public Set transportsAt(WorldPoint tile) { - final Map> transports = Rs2PathApi.getTransports(); - if (transports == null) { - return Collections.emptySet(); - } - final Set at = transports.get(tile); - return at == null ? Collections.emptySet() : at; - } + public boolean hasTransportAt(WorldPoint tile) { + return Rs2PathApi.hasCatalogTransportOrigin(tile); + } @Override public TileObject objectAt(WorldPoint tile) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/Rs2ObstacleHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/Rs2ObstacleHandler.java index 5da72edd3b1..8a060b96291 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/Rs2ObstacleHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/Rs2ObstacleHandler.java @@ -70,7 +70,7 @@ public static RockfallResult handleRockfall(List path, int index) { if (path == null || path.isEmpty() || index < 0 || index >= path.size()) { return RockfallResult.NOT_APPLICABLE; } - if (Rs2PathApi.getPathfinder() == null) return RockfallResult.NOT_APPLICABLE; + if (!Rs2PathApi.getActiveRouteStatus().isPresent()) return RockfallResult.NOT_APPLICABLE; if (index == path.size() - 1) return RockfallResult.NOT_APPLICABLE; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/TransportResolver.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/TransportResolver.java index 2448abe27c9..c3f7efcf1a6 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/TransportResolver.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/obstacle/TransportResolver.java @@ -1,9 +1,6 @@ package net.runelite.client.plugins.microbot.util.walker.obstacle; import net.runelite.api.coords.WorldPoint; -import net.runelite.client.plugins.microbot.shortestpath.Transport; - -import java.util.Set; /** * Resolves a planned edge blocked because its far side is across a transport / agility shortcut (a stepping @@ -23,10 +20,9 @@ public boolean handles(PlannedEdge edge, LiveScene scene) { return false; } final WorldPoint origin = edge.from(); - final Set transports = scene.transportsAt(origin); - if (transports == null || transports.isEmpty()) { - return false; - } + if (!scene.hasTransportAt(origin)) { + return false; + } final WorldPoint player = scene.playerLocation(); // Applies only when the player is off the origin but can still reach it: then stepping onto it lets // the normal loop take the transport. When already on the origin, that loop owns it (not recovery); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/RouteRecovery.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/RouteRecovery.java index 7cb37b7275d..7ef59d6952d 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/RouteRecovery.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/recovery/RouteRecovery.java @@ -1,10 +1,8 @@ package net.runelite.client.plugins.microbot.util.walker.recovery; import net.runelite.api.coords.WorldPoint; -import net.runelite.client.plugins.microbot.shortestpath.Transport; import java.util.List; -import java.util.Map; import java.util.Set; import java.util.function.Predicate; @@ -213,21 +211,21 @@ public static boolean isLocalRecoveryCandidateOnForwardRoute(List ra * {@code maxEuclidean} tiles of the player), so recovery can walk the player ONTO it and let the normal * transport handler cross next tick. Returns {@code null} when none qualifies. *

- * Pure and fully injected ({@code reachable} tiles and the {@code transports} map are parameters), so it + * Pure and fully injected ({@code reachable} tiles and the transport-origin predicate are parameters), so it * is exercised headlessly by {@code RouteRecoveryTest} rather than requiring a live walk. Rationale: the * far-side fallback otherwise clicks the opposite bank, which the client cannot reach, so the player * loops on the near bank and the transport (which only dispatches while standing on its origin) never * fires. */ - public static WorldPoint findReachableTransportOriginAhead(List rawPath, - int startIndex, - WorldPoint playerLoc, - Set reachable, - Map> transports, - int maxEuclidean, - int forwardScanTiles) { - if (rawPath == null || rawPath.isEmpty() || playerLoc == null || reachable == null - || transports == null || transports.isEmpty() || startIndex < 0 || startIndex >= rawPath.size()) { + public static WorldPoint findReachableTransportOriginAhead(List rawPath, + int startIndex, + WorldPoint playerLoc, + Set reachable, + Predicate hasTransportOrigin, + int maxEuclidean, + int forwardScanTiles) { + if (rawPath == null || rawPath.isEmpty() || playerLoc == null || reachable == null + || hasTransportOrigin == null || startIndex < 0 || startIndex >= rawPath.size()) { return null; } int maxSq = maxEuclidean * maxEuclidean; @@ -243,8 +241,7 @@ public static WorldPoint findReachableTransportOriginAhead(List rawP if (euclideanSq(wp, playerLoc) > maxSq) { continue; // within minimap-click reach } - Set ts = transports.get(wp); - if (ts != null && !ts.isEmpty()) { + if (hasTransportOrigin.test(wp)) { return wp; // nearest reachable transport / shortcut origin ahead } } diff --git a/runelite-client/src/main/java/net/runelite/client/ui/overlay/OverlayRenderer.java b/runelite-client/src/main/java/net/runelite/client/ui/overlay/OverlayRenderer.java index 32f8f659778..a470bb96c59 100644 --- a/runelite-client/src/main/java/net/runelite/client/ui/overlay/OverlayRenderer.java +++ b/runelite-client/src/main/java/net/runelite/client/ui/overlay/OverlayRenderer.java @@ -42,6 +42,7 @@ import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.Objects; import javax.inject.Inject; import javax.inject.Singleton; import javax.swing.SwingUtilities; @@ -343,7 +344,7 @@ else if (preferredLocation != null) graphics.setPaint(paint); graphics.setRenderingHints(renderingHints); graphics.setBackground(background); - if (!graphics.getClip().equals(clip)) + if (!Objects.equals(graphics.getClip(), clip)) { graphics.setClip(clip); } diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/agility_shortcuts.tsv b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/agility_shortcuts.tsv index 023b0053d03..7b1095334d7 100644 --- a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/agility_shortcuts.tsv +++ b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/agility_shortcuts.tsv @@ -5,14 +5,17 @@ 2556 3074 1 2556 3075 0 Jump;Wall;17048 4 Agility 2936 3355 0 2934 3355 0 Climb-over;Crumbling wall;24222 5 Agility 2934 3355 0 2936 3355 0 Climb-over;Crumbling wall;24222 5 Agility -3246 3179 0 3259 3179 0 Grapple;Broken Raft;17068 8 Agility;37 Ranged;19 Strength -3259 3179 0 3246 3179 0 Grapple;Broken Raft;17068 8 Agility;37 Ranged;19 Strength +3246 3179 0 3259 3179 0 Grapple;Broken Raft;17068 8 Agility;37 Ranged;19 Strength CROSSBOW=1&MITH_GRAPPLE=1 +3259 3179 0 3246 3179 0 Grapple;Broken Raft;17068 8 Agility;37 Ranged;19 Strength CROSSBOW=1&MITH_GRAPPLE=1 2546 2873 0 2546 2871 0 Climb;Rocks;31757 10 Agility 2546 2871 0 2546 2873 0 Climb;Rocks;31757 10 Agility -2766 3665 0 2766 3663 0 Use;Rope -> Boulder;5842 10 Agility 237 10 260>=1 -3033 3390 0 3033 3389 1 Grapple;Wall;17049 11 Agility;19 Ranged;37 Strength 9419 -3032 3388 0 3032 3389 1 Grapple;Wall;17050 11 Agility;19 Ranged;37 Strength 9419 -2575 3107 0 2575 3112 0 Climb-under;Castle wall;16519 16 Agility +2766 3665 0 2766 3663 0 Use;Rope -> Boulder;5842 10 Agility ROPE=1 260>0 10 +3033 3390 0 3033 3389 1 Grapple;Wall;17049 11 Agility;19 Ranged;37 Strength CROSSBOW=1&MITH_GRAPPLE=1 +3032 3388 0 3032 3389 1 Grapple;Wall;17050 11 Agility;19 Ranged;37 Strength CROSSBOW=1&MITH_GRAPPLE=1 +2820 3635 0 2822 3635 0 Climb;Rocks;3748 15 Agility CLIMBING_BOOTS=1 +2857 3611 0 2857 3613 0 Climb;Rocks;3748 15 Agility CLIMBING_BOOTS=1 +2856 3611 0 2856 3613 0 Climb;Rocks;3748 15 Agility CLIMBING_BOOTS=1 +2575 3107 0 2575 3112 0 Climb-under;Castle wall;16519 16 Agility 2575 3112 0 2575 3107 0 Climb-into;Hole;16520 16 Agility 2603 3477 0 2598 3477 0 Walk-across;Log balance;23274 20 Agility 2599 3478 0 2603 3477 0 Walk-across;Log balance;23274 20 Agility @@ -35,17 +38,17 @@ 3153 3363 0 3152 3363 0 Jump-onto;Stepping stone;16533 31 Agility 4 3152 3363 0 3151 3363 0 Jump-onto;Stepping stone;16533 31 Agility 4 3151 3363 0 3150 3363 0 Jump-onto;Stepping stone;16533 31 Agility 4 -2866 3428 0 2869 3428 0 Grapple;Rocks;17042 32 Agility;35 Ranged;35 Strength 9419 +2866 3428 0 2869 3428 0 Grapple;Rocks;17042 32 Agility;35 Ranged;35 Strength CROSSBOW=1&MITH_GRAPPLE=1 2602 3336 0 2598 3336 0 Walk-across;Log balance;16548 33 Agility 2598 3336 0 2602 3336 0 Walk-across;Log balance;16546 33 Agility 2599 3337 0 2602 3336 0 Walk-across;Log balance;16546 33 Agility -2841 3427 0 2841 3433 0 Grapple Crossbow;Tree;17062 36 Agility;39 Ranged;22 Strength 9419 +2841 3427 0 2841 3433 0 Grapple Crossbow;Tree;17062 36 Agility;39 Ranged;22 Strength CROSSBOW=1&MITH_GRAPPLE=1 2486 3515 0 2489 3521 0 Climb;Rocks;16534 37 Agility The Grand Tree 9 2489 3521 0 2486 3515 0 Climb;Rocks;16535 37 Agility The Grand Tree 9 3306 3315 0 3302 3315 0 Climb;Rocks;16549 38 Agility 3302 3315 0 3306 3315 0 Climb;Rocks;16550 38 Agility -2556 3072 0 2556 3073 1 Grapple;Wall;17047 39 Agility;21 Ranged;38 Strength 9419 -2556 3075 0 2556 3074 1 Grapple;Wall;17047 39 Agility;21 Ranged;38 Strength 9419 +2556 3072 0 2556 3073 1 Grapple;Wall;17047 39 Agility;21 Ranged;38 Strength CROSSBOW=1&MITH_GRAPPLE=1 +2556 3075 0 2556 3074 1 Grapple;Wall;17047 39 Agility;21 Ranged;38 Strength CROSSBOW=1&MITH_GRAPPLE=1 2872 3671 0 2869 3671 0 Climb;Rocks;16521 41 Agility 2869 3671 0 2872 3671 0 Climb;Rocks;16521 41 Agility 3070 3260 0 3064 3260 0 Climb-into;Underwall tunnel;19036 42 Agility @@ -86,10 +89,10 @@ 1769 3849 0 1774 3849 0 Climb;Rocks;27988 52 Agility 1774 3849 0 1769 3849 0 Climb;Rocks;27987 52 Agility 2998 3916 0 2998 3931 0 Open;Door;23555 52 Agility -2874 3133 0 2874 3127 0 Grapple;Strong Tree;17074 53 Agility;42 Ranged;21 Strength 9419 -2874 3127 0 2874 3133 0 Grapple;Strong Tree;17074 53 Agility;42 Ranged;21 Strength 9419 -2874 3136 0 2874 3142 0 Grapple;Strong Tree;17074 53 Agility;42 Ranged;21 Strength 9419 -2874 3142 0 2874 3136 0 Grapple;Strong Tree;17074 53 Agility;42 Ranged;21 Strength 9419 +2874 3133 0 2874 3127 0 Grapple;Strong Tree;17074 53 Agility;42 Ranged;21 Strength CROSSBOW=1&MITH_GRAPPLE=1 +2874 3127 0 2874 3133 0 Grapple;Strong Tree;17074 53 Agility;42 Ranged;21 Strength CROSSBOW=1&MITH_GRAPPLE=1 +2874 3136 0 2874 3142 0 Grapple;Strong Tree;17074 53 Agility;42 Ranged;21 Strength CROSSBOW=1&MITH_GRAPPLE=1 +2874 3142 0 2874 3136 0 Grapple;Strong Tree;17074 53 Agility;42 Ranged;21 Strength CROSSBOW=1&MITH_GRAPPLE=1 2573 3859 0 2575 3861 0 Cross;Stepping stone;11768 55 Agility 2575 3861 0 2573 3859 0 Cross;Stepping stone;11768 55 Agility 2688 3697 0 2691 3697 0 Jump;Broken Fence;544 57 Agility @@ -173,9 +176,9 @@ 3713 3830 0 3715 3815 0 Climb through;Hole;31482 70 Agility 3715 3815 0 3713 3830 0 Climb through;Hole;31481 70 Agility 2946 3439 0 2943 3439 0 Climb-up;Climbing rocks;53255 66 Agility -1324 3777 0 1324 3784 0 Climb;Rocks;34397 29 Agility 4 -1324 3787 0 1324 3795 0 Climb;Rocks;34396 62 Agility 4 -1324 3784 0 1324 3777 0 Climb;Rocks;34397 29 Agility 4 +1324 3777 0 1324 3785 0 Climb;Rocks;34397 29 Agility 8 +1324 3787 0 1324 3795 0 Climb;Rocks;34396 62 Agility 8 +1324 3785 0 1324 3777 0 Climb;Rocks;34397 29 Agility 8 1324 3795 0 1324 3787 0 Climb;Rocks;34396 62 Agility 4 2400 4402 0 2400 4404 0 Squeeze-past;Jutting Wall;17002 46 Agility 4 2400 4404 0 2400 4402 0 Squeeze-past;Jutting Wall;17002 46 Agility 4 @@ -191,8 +194,8 @@ 3334 10118 0 3334 10122 0 Squeeze-through;Crevice;53259 77 Agility 4 3371 2958 0 3373 2955 0 Cross;Stepping stone;53241 71 Agility 2 3373 2955 0 3371 2958 0 Cross;Stepping stone;53241 71 Agility 2 -3240 3336 0 3240 3334 0 Jump-over;Fence;16518 13 Agility 3 -3240 3334 0 3240 3336 0 Jump-over;Fence;16518 13 Agility 3 +3240 3335 0 3240 3334 0 Jump-over;Fence;16518 13 Agility 2 +3240 3334 0 3240 3335 0 Jump-over;Fence;16518 13 Agility 2 3222 12441 0 3216 12441 0 Pass;Tight Gap;36692 78 Agility 3 3216 12441 0 3222 12441 0 Pass;Tight Gap;36693 78 Agility 3 3232 12420 0 3242 12420 0 Pass;Tight Gap;36695 84 Agility 3 @@ -284,4 +287,132 @@ 1401 3283 0 1401 3291 0 Walk-across;Log balance;56990 45 Agility 3 1477 3309 0 1477 3305 0 Climb;Rocks;56986 76 Agility 3 1477 3305 0 1477 3309 0 Climb;Rocks;56985 76 Agility 3 -3765 3883 1 3765 3898 0 Teeth-grip;Zip line;57667 76 Agility 6 \ No newline at end of file +3765 3883 1 3765 3898 0 Teeth-grip;Zip line;57667 76 Agility 6 + +# Isafdar Forest shortcuts (reviewed from Skretzo/shortest-path) +2220 3155 0 2220 3152 0 Step-over;Tripwire;3921 1 Agility 8 +2220 3152 0 2220 3155 0 Step-over;Tripwire;3921 1 Agility 8 +2215 3156 0 2215 3153 0 Step-over;Tripwire;3921 1 Agility 8 +2215 3153 0 2215 3156 0 Step-over;Tripwire;3921 1 Agility 8 +2250 3168 0 2253 3168 0 Step-over;Tripwire;3921 1 Agility 8 +2253 3168 0 2250 3168 0 Step-over;Tripwire;3921 1 Agility 8 +2284 3188 0 2287 3188 0 Step-over;Tripwire;3921 1 Agility 8 +2287 3188 0 2284 3188 0 Step-over;Tripwire;3921 1 Agility 8 +2294 3242 0 2294 3245 0 Step-over;Tripwire;3921 1 Agility 8 +2294 3245 0 2294 3242 0 Step-over;Tripwire;3921 1 Agility 8 +2199 3169 0 2202 3169 0 Pass;Sticks;3922 1 Agility 6 +2202 3169 0 2199 3169 0 Pass;Sticks;3922 1 Agility 6 +2234 3181 0 2238 3181 0 Pass;Sticks;3922 1 Agility 6 +2238 3181 0 2234 3181 0 Pass;Sticks;3922 1 Agility 6 +2181 3212 0 2181 3208 0 Pass;Sticks;3922 1 Agility 6 +2181 3208 0 2181 3212 0 Pass;Sticks;3922 1 Agility 6 +2256 3227 0 2260 3227 0 Pass;Sticks;3922 1 Agility 6 +2260 3227 0 2256 3227 0 Pass;Sticks;3922 1 Agility 6 +2274 3163 0 2277 3163 0 Pass;Sticks;3922 1 Agility 6 +2277 3163 0 2274 3163 0 Pass;Sticks;3922 1 Agility 6 +2295 3217 0 2295 3213 0 Pass;Sticks;3922 1 Agility 6 +2295 3213 0 2295 3217 0 Pass;Sticks;3922 1 Agility 6 +2209 3201 0 2209 3205 0 Jump;Leaves;3925 1 Agility 4 +2209 3205 0 2209 3201 0 Jump;Leaves;3925 1 Agility 4 +2275 3262 0 2279 3262 0 Jump;Leaves;3925 1 Agility 4 +2279 3262 0 2275 3262 0 Jump;Leaves;3925 1 Agility 4 +2267 3205 0 2267 3201 0 Jump;Leaves;3925 1 Agility 4 +2267 3201 0 2267 3205 0 Jump;Leaves;3925 1 Agility 4 +2274 3176 0 2274 3172 0 Jump;Leaves;3925 1 Agility 4 +2274 3172 0 2274 3176 0 Jump;Leaves;3925 1 Agility 4 +2202 3237 0 2196 3237 0 Cross;Log balance;3931 45 Agility 8 +2196 3237 0 2202 3237 0 Cross;Log balance;3931 45 Agility 8 +2258 3250 0 2264 3250 0 Cross;Log balance;3932 45 Agility 8 +2264 3250 0 2258 3250 0 Cross;Log balance;3932 45 Agility 8 +2290 3232 0 2290 3239 0 Cross;Log balance;3933 45 Agility 9 +2290 3239 0 2290 3232 0 Cross;Log balance;3933 45 Agility 9 +2188 3162 0 2188 3165 0 Enter;Dense forest;3999 56 Agility 4 +2188 3165 0 2188 3162 0 Enter;Dense forest;3999 56 Agility 4 +2188 3165 0 2188 3168 0 Enter;Dense forest;3939 56 Agility 4 +2188 3168 0 2188 3165 0 Enter;Dense forest;3939 56 Agility 4 +2188 3168 0 2188 3171 0 Enter;Dense forest;3998 56 Agility 4 +2188 3171 0 2188 3168 0 Enter;Dense forest;3998 56 Agility 4 +2217 3169 0 2217 3166 0 Enter;Dense forest;3939 56 Agility 4 +2217 3166 0 2217 3169 0 Enter;Dense forest;3939 56 Agility 4 +2217 3166 0 2217 3163 0 Enter;Dense forest;3938 56 Agility 4 +2217 3163 0 2217 3166 0 Enter;Dense forest;3938 56 Agility 4 +2217 3163 0 2217 3160 0 Enter;Dense forest;3939 56 Agility 4 +2217 3160 0 2217 3163 0 Enter;Dense forest;3939 56 Agility 4 +2231 3149 0 2234 3149 0 Enter;Dense forest;3937 56 Agility 4 +2234 3149 0 2231 3149 0 Enter;Dense forest;3937 56 Agility 4 +2234 3149 0 2237 3149 0 Enter;Dense forest;3938 56 Agility 4 +2237 3149 0 2234 3149 0 Enter;Dense forest;3938 56 Agility 4 +2237 3149 0 2240 3149 0 Enter;Dense forest;3939 56 Agility 4 +2240 3149 0 2237 3149 0 Enter;Dense forest;3939 56 Agility 4 +2226 3219 0 2229 3219 0 Enter;Dense forest;3938 56 Agility 4 +2229 3219 0 2226 3219 0 Enter;Dense forest;3938 56 Agility 4 +2229 3219 0 2232 3219 0 Enter;Dense forest;3939 56 Agility 4 +2232 3219 0 2229 3219 0 Enter;Dense forest;3939 56 Agility 4 +2232 3219 0 2235 3219 0 Enter;Dense forest;3937 56 Agility 4 +2235 3219 0 2232 3219 0 Enter;Dense forest;3937 56 Agility 4 +2235 3219 0 2238 3219 0 Enter;Dense forest;3939 56 Agility 4 +2238 3219 0 2235 3219 0 Enter;Dense forest;3939 56 Agility 4 +2230 3249 0 2233 3249 0 Enter;Dense forest;3938 56 Agility 4 +2233 3249 0 2230 3249 0 Enter;Dense forest;3938 56 Agility 4 +2233 3249 0 2236 3249 0 Enter;Dense forest;3939 56 Agility 4 +2236 3249 0 2233 3249 0 Enter;Dense forest;3939 56 Agility 4 +2236 3249 0 2239 3249 0 Enter;Dense forest;3937 56 Agility 4 +2239 3249 0 2236 3249 0 Enter;Dense forest;3937 56 Agility 4 +2279 3231 0 2279 3228 0 Enter;Dense forest;3939 56 Agility 4 +2279 3228 0 2279 3231 0 Enter;Dense forest;3939 56 Agility 4 +2279 3228 0 2279 3225 0 Enter;Dense forest;3937 56 Agility 4 +2279 3225 0 2279 3228 0 Enter;Dense forest;3937 56 Agility 4 +2279 3225 0 2279 3222 0 Enter;Dense forest;3938 56 Agility 4 +2279 3222 0 2279 3225 0 Enter;Dense forest;3938 56 Agility 4 +2265 3192 0 2268 3192 0 Enter;Dense forest;3938 56 Agility 4 +2268 3192 0 2265 3192 0 Enter;Dense forest;3938 56 Agility 4 +2268 3192 0 2271 3192 0 Enter;Dense forest;3939 56 Agility 4 +2271 3192 0 2268 3192 0 Enter;Dense forest;3939 56 Agility 4 +2271 3192 0 2274 3192 0 Enter;Dense forest;3937 56 Agility 4 +2274 3192 0 2271 3192 0 Enter;Dense forest;3937 56 Agility 4 +2303 3213 0 2303 3216 0 Enter;Dense forest;3937 56 Agility 4 +2303 3216 0 2303 3213 0 Enter;Dense forest;3937 56 Agility 4 +2303 3216 0 2303 3219 0 Enter;Dense forest;3938 56 Agility 4 +2303 3219 0 2303 3216 0 Enter;Dense forest;3938 56 Agility 4 +2303 3219 0 2303 3222 0 Enter;Dense forest;3939 56 Agility 4 +2303 3222 0 2303 3219 0 Enter;Dense forest;3939 56 Agility 4 +2303 3222 0 2303 3225 0 Enter;Dense forest;3938 56 Agility 4 +2303 3225 0 2303 3222 0 Enter;Dense forest;3938 56 Agility 4 + +# Brimhaven Dungeon shortcuts (reviewed from Skretzo/shortest-path) +2698 9492 0 2698 9500 0 Squeeze-through;Pipe;21727 1 Agility 13 +2698 9500 0 2698 9492 0 Squeeze-through;Pipe;21727 1 Agility 13 +2649 9562 0 2647 9557 0 Jump-from;Stepping stone;21738 1 Agility 7 +2647 9557 0 2649 9562 0 Jump-from;Stepping stone;21739 1 Agility 7 +2687 9506 0 2682 9506 0 Walk-across;Log balance;20884 1 Agility 7 +2682 9506 0 2687 9506 0 Walk-across;Log balance;20882 1 Agility 7 + +# Lumbridge cellar shortcut (reviewed from Skretzo/shortest-path) +3219 9618 0 3221 9618 0 Squeeze-through;Hole;6905 13 Agility 532>3 3 +3221 9618 0 3219 9618 0 Squeeze-through;Hole;6905 13 Agility 532>3 3 + +# Karamja rock shortcut (reviewed from Skretzo/shortest-path) +2795 2978 0 2791 2978 0 Climb;Rocks;2231 15 Agility 5 +2795 2979 0 2791 2979 0 Climb;Rocks;2231 15 Agility 5 +2795 2980 0 2791 2980 0 Climb;Rocks;2231 15 Agility 5 +2791 2978 0 2795 2978 0 Climb;Rocks;2231 15 Agility 5 +2791 2979 0 2795 2979 0 Climb;Rocks;2231 15 Agility 5 +2791 2980 0 2795 2980 0 Climb;Rocks;2231 15 Agility 5 + +# Slayer Tower ground-floor shortcut (reviewed from Skretzo/shortest-path) +3421 3550 0 3421 3550 1 Climb-up;Spikey chain;16537 61 Agility +3422 3551 0 3422 3551 1 Climb-up;Spikey chain;16537 61 Agility +3423 3550 0 3423 3550 1 Climb-up;Spikey chain;16537 61 Agility +3422 3549 0 3422 3549 1 Climb-up;Spikey chain;16537 61 Agility +3421 3550 1 3421 3550 0 Climb-down;Spikey chain;16538 61 Agility +3422 3551 1 3422 3551 0 Climb-down;Spikey chain;16538 61 Agility +3422 3549 1 3422 3549 0 Climb-down;Spikey chain;16538 61 Agility +3423 3550 1 3423 3550 0 Climb-down;Spikey chain;16538 61 Agility + +# Darkmeyer wall shortcuts (reviewed from Skretzo/shortest-path) +3667 3375 0 3670 3375 0 Climb;Wall;39542 63 Agility 10449=1 +3670 3375 0 3667 3375 0 Climb;Wall;39542 63 Agility 10449=1 +3670 3375 0 3673 3375 0 Climb;Wall;39541 63 Agility 10450=1 +3673 3375 0 3670 3375 0 Climb;Wall;39541 63 Agility 10450=1 +3672 3376 0 3670 3375 0 Climb;Wall;39541 63 Agility 10450=1 +3672 3374 0 3670 3375 0 Climb;Wall;39541 63 Agility 10450=1 diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/blocked_edges.tsv b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/blocked_edges.tsv index 9ca08f8dde6..45fae801055 100644 --- a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/blocked_edges.tsv +++ b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/blocked_edges.tsv @@ -30,6 +30,9 @@ 2935 3450 0 2935 3449 0 true Taverley wall 2937 3450 0 2937 3449 0 true Taverley wall 2938 3450 0 2938 3449 0 true Taverley wall +# Elemental Workshop odd-looking wall. The collision artifact exposes this cardinal edge; the +# item-gated object transport in transports.tsv is the only valid crossing. +2709 3495 0 2709 3496 0 true Elemental Workshop odd-looking wall 2649 3469 0 2649 3470 0 true Hemenster enclosure north gate - never opens 2650 3469 0 2650 3470 0 true Hemenster enclosure north gate - never opens # Al Kharid mine - block routing through open pit (collision data gap) diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/canoes.tsv b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/canoes.tsv index 07a1c276708..3ae62528631 100644 --- a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/canoes.tsv +++ b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/canoes.tsv @@ -1,35 +1,67 @@ -# Origin Destination menuOption menuTarget objectID Skills Item IDs Quest Duration Display info -# Edgeville -3132 3510 0 3109 3415 0 Paddle Canoe;Canoe Station;12166 12 Woodcutting 1351;1349;1361;1353;1355;1357;1359 12 Barbarian Village -3132 3510 0 3199 3344 0 Paddle Canoe;Canoe Station;12166 27 Woodcutting 1351;1349;1361;1353;1355;1357;1359 12 Champions Guild -3132 3510 0 3240 3242 0 Paddle Canoe;Canoe Station;12166 42 Woodcutting 1351;1349;1361;1353;1355;1357;1359 12 Lumbridge -3132 3510 0 3154 3638 0 Paddle Canoe;Canoe Station;12166 57 Woodcutting 1351;1349;1361;1353;1355;1357;1359 12 Ferox Enclave -3132 3510 0 3141 3796 0 Paddle Canoe;Canoe Station;12166 57 Woodcutting 1351;1349;1361;1353;1355;1357;1359 12 Wilderness Pond - -# Barbarian Village -3112 3411 0 3199 3344 0 Paddle Canoe;Canoe Station;12165 12 Woodcutting 1351;1349;1361;1353;1355;1357;1359 12 Champions Guild -3112 3411 0 3128 3503 0 Paddle Canoe;Canoe Station;12165 12 Woodcutting 1351;1349;1361;1353;1355;1357;1359 12 Edgeville -3112 3411 0 3240 3242 0 Paddle Canoe;Canoe Station;12165 27 Woodcutting 1351;1349;1361;1353;1355;1357;1359 12 Lumbridge -3112 3411 0 3154 3638 0 Paddle Canoe;Canoe Station;12165 57 Woodcutting 1351;1349;1361;1353;1355;1357;1359 12 Ferox Enclave -3112 3411 0 3141 3796 0 Paddle Canoe;Canoe Station;12165 57 Woodcutting 1351;1349;1361;1353;1355;1357;1359 12 Wilderness Pond - -# Champions' Guild -3202 3343 0 3240 3242 0 Paddle Canoe;Canoe Station;12164 12 Woodcutting 1351;1349;1361;1353;1355;1357;1359 12 Lumbridge -3202 3343 0 3109 3415 0 Paddle Canoe;Canoe Station;12164 12 Woodcutting 1351;1349;1361;1353;1355;1357;1359 12 Barbarian Village -3202 3343 0 3128 3503 0 Paddle Canoe;Canoe Station;12164 27 Woodcutting 1351;1349;1361;1353;1355;1357;1359 12 Edgeville -3202 3343 0 3154 3638 0 Paddle Canoe;Canoe Station;12164 57 Woodcutting 1351;1349;1361;1353;1355;1357;1359 12 Ferox Enclave -3202 3343 0 3141 3796 0 Paddle Canoe;Canoe Station;12164 57 Woodcutting 1351;1349;1361;1353;1355;1357;1359 12 Wilderness Pond - -# Lumbridge -3243 3237 0 3199 3344 0 Paddle Canoe;Canoe Station;12163 12 Woodcutting 1351;1349;1361;1353;1355;1357;1359 12 Champions Guild -3243 3237 0 3109 3415 0 Paddle Canoe;Canoe Station;12163 27 Woodcutting 1351;1349;1361;1353;1355;1357;1359 12 Barbarian Village -3243 3237 0 3128 3503 0 Paddle Canoe;Canoe Station;12163 42 Woodcutting 1351;1349;1361;1353;1355;1357;1359 12 Edgeville -3243 3237 0 3154 3638 0 Paddle Canoe;Canoe Station;12163 57 Woodcutting 1351;1349;1361;1353;1355;1357;1359 12 Ferox Enclave -3243 3237 0 3141 3796 0 Paddle Canoe;Canoe Station;12163 57 Woodcutting 1351;1349;1361;1353;1355;1357;1359 12 Wilderness Pond - -# Ferox Enclave -3154 3630 0 3128 3503 0 Paddle Canoe;Canoe Station;39638 12 Woodcutting 1351;1349;1361;1353;1355;1357;1359 12 Edgeville -3154 3630 0 3109 3415 0 Paddle Canoe;Canoe Station;39638 27 Woodcutting 1351;1349;1361;1353;1355;1357;1359 12 Barbarian Village -3154 3630 0 3199 3344 0 Paddle Canoe;Canoe Station;39638 42 Woodcutting 1351;1349;1361;1353;1355;1357;1359 12 Champions Guild -3154 3630 0 3240 3242 0 Paddle Canoe;Canoe Station;39638 57 Woodcutting 1351;1349;1361;1353;1355;1357;1359 12 Lumbridge -3154 3630 0 3141 3796 0 Paddle Canoe;Canoe Station;39638 57 Woodcutting 1351;1349;1361;1353;1355;1357;1359 12 Wilderness Pond +# Origin Destination menuOption menuTarget objectID Skills Items Duration Display info +# River Lum chain +# Edgeville +3132 3510 0 3109 3415 0 Paddle Canoe;Canoe Station;12166 12 Woodcutting AXE=1 30 Barbarian Village +3132 3510 0 3199 3344 0 Paddle Canoe;Canoe Station;12166 27 Woodcutting AXE=1 30 Champions Guild +3132 3510 0 3240 3242 0 Paddle Canoe;Canoe Station;12166 42 Woodcutting AXE=1 30 Lumbridge +3132 3510 0 3154 3638 0 Paddle Canoe;Canoe Station;12166 57 Woodcutting AXE=1 20 Ferox Enclave +3132 3510 0 3141 3796 0 Paddle Canoe;Canoe Station;12166 57 Woodcutting AXE=1 20 Wilderness Pond + +# Barbarian Village +3112 3411 0 3199 3344 0 Paddle Canoe;Canoe Station;12165 12 Woodcutting AXE=1 30 Champions Guild +3112 3411 0 3128 3503 0 Paddle Canoe;Canoe Station;12165 12 Woodcutting AXE=1 30 Edgeville +3112 3411 0 3240 3242 0 Paddle Canoe;Canoe Station;12165 27 Woodcutting AXE=1 30 Lumbridge +3112 3411 0 3154 3638 0 Paddle Canoe;Canoe Station;12165 57 Woodcutting AXE=1 20 Ferox Enclave +3112 3411 0 3141 3796 0 Paddle Canoe;Canoe Station;12165 57 Woodcutting AXE=1 20 Wilderness Pond + +# Champions' Guild +3202 3343 0 3240 3242 0 Paddle Canoe;Canoe Station;12164 12 Woodcutting AXE=1 30 Lumbridge +3202 3343 0 3109 3415 0 Paddle Canoe;Canoe Station;12164 12 Woodcutting AXE=1 30 Barbarian Village +3202 3343 0 3128 3503 0 Paddle Canoe;Canoe Station;12164 27 Woodcutting AXE=1 30 Edgeville +3202 3343 0 3154 3638 0 Paddle Canoe;Canoe Station;12164 57 Woodcutting AXE=1 20 Ferox Enclave +3202 3343 0 3141 3796 0 Paddle Canoe;Canoe Station;12164 57 Woodcutting AXE=1 20 Wilderness Pond + +# Lumbridge +3243 3237 0 3199 3344 0 Paddle Canoe;Canoe Station;12163 12 Woodcutting AXE=1 30 Champions Guild +3243 3237 0 3109 3415 0 Paddle Canoe;Canoe Station;12163 27 Woodcutting AXE=1 30 Barbarian Village +3243 3237 0 3128 3503 0 Paddle Canoe;Canoe Station;12163 42 Woodcutting AXE=1 30 Edgeville +3243 3237 0 3154 3638 0 Paddle Canoe;Canoe Station;12163 57 Woodcutting AXE=1 20 Ferox Enclave +3243 3237 0 3141 3796 0 Paddle Canoe;Canoe Station;12163 57 Woodcutting AXE=1 20 Wilderness Pond + +# Ferox Enclave +3154 3630 0 3128 3503 0 Paddle Canoe;Canoe Station;39638 12 Woodcutting AXE=1 20 Edgeville +3154 3630 0 3109 3415 0 Paddle Canoe;Canoe Station;39638 27 Woodcutting AXE=1 20 Barbarian Village +3154 3630 0 3199 3344 0 Paddle Canoe;Canoe Station;39638 42 Woodcutting AXE=1 20 Champions Guild +3154 3630 0 3240 3242 0 Paddle Canoe;Canoe Station;39638 57 Woodcutting AXE=1 20 Lumbridge +3154 3630 0 3141 3796 0 Paddle Canoe;Canoe Station;39638 57 Woodcutting AXE=1 20 Wilderness Pond + +# River Dougne chain +# Castle Wars +2439 3135 0 2483 3188 0 Paddle Canoe;Canoe Station;60845 12 Woodcutting AXE=1 30 Tree Gnome Village +2439 3135 0 2577 3261 0 Paddle Canoe;Canoe Station;60845 27 Woodcutting AXE=1 30 The Clock Tower +2439 3135 0 2571 3360 0 Paddle Canoe;Canoe Station;60845 42 Woodcutting AXE=1 30 Chaos Druid Tower +2439 3135 0 2523 3408 0 Paddle Canoe;Canoe Station;60845 57 Woodcutting AXE=1 30 Tree Gnome Stronghold + +# Tree Gnome Village +2485 3192 0 2436 3134 0 Paddle Canoe;Canoe Station;60846 12 Woodcutting AXE=1 30 Castle Wars +2485 3192 0 2577 3261 0 Paddle Canoe;Canoe Station;60846 12 Woodcutting AXE=1 30 The Clock Tower +2485 3192 0 2571 3360 0 Paddle Canoe;Canoe Station;60846 27 Woodcutting AXE=1 30 Chaos Druid Tower +2485 3192 0 2523 3408 0 Paddle Canoe;Canoe Station;60846 42 Woodcutting AXE=1 30 Tree Gnome Stronghold + +# The Clock Tower +2579 3260 0 2436 3134 0 Paddle Canoe;Canoe Station;60847 27 Woodcutting AXE=1 30 Castle Wars +2579 3260 0 2483 3188 0 Paddle Canoe;Canoe Station;60847 12 Woodcutting AXE=1 30 Tree Gnome Village +2579 3260 0 2571 3360 0 Paddle Canoe;Canoe Station;60847 12 Woodcutting AXE=1 30 Chaos Druid Tower +2579 3260 0 2523 3408 0 Paddle Canoe;Canoe Station;60847 27 Woodcutting AXE=1 30 Tree Gnome Stronghold + +# Chaos Druid Tower +2573 3358 0 2436 3134 0 Paddle Canoe;Canoe Station;60848 42 Woodcutting AXE=1 30 Castle Wars +2573 3358 0 2483 3188 0 Paddle Canoe;Canoe Station;60848 27 Woodcutting AXE=1 30 Tree Gnome Village +2573 3358 0 2577 3261 0 Paddle Canoe;Canoe Station;60848 12 Woodcutting AXE=1 30 The Clock Tower +2573 3358 0 2523 3408 0 Paddle Canoe;Canoe Station;60848 12 Woodcutting AXE=1 30 Tree Gnome Stronghold + +# Tree Gnome Stronghold +2525 3408 0 2436 3134 0 Paddle Canoe;Canoe Station;60849 57 Woodcutting AXE=1 30 Castle Wars +2525 3408 0 2483 3188 0 Paddle Canoe;Canoe Station;60849 42 Woodcutting AXE=1 30 Tree Gnome Village +2525 3408 0 2577 3261 0 Paddle Canoe;Canoe Station;60849 27 Woodcutting AXE=1 30 The Clock Tower +2525 3408 0 2571 3360 0 Paddle Canoe;Canoe Station;60849 12 Woodcutting AXE=1 30 Chaos Druid Tower diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/collision-map.zip b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/collision-map.zip index 43d1d412423..fbbfbcc5d2b 100644 Binary files a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/collision-map.zip and b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/collision-map.zip differ diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/minecarts.tsv b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/minecarts.tsv index 80872f43deb..d0435124dd1 100644 --- a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/minecarts.tsv +++ b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/minecarts.tsv @@ -1,80 +1,140 @@ -# Origin Destination menuOption menuTarget objectID Skills Currency Quests Duration Display info -# Keldagrim, Grand Exchange -3141 3504 0 2909 10174 0 Travel;Trapdoor;16168 The Giant Dwarf 21 -3140 3503 0 2909 10174 0 Travel;Trapdoor;16168 The Giant Dwarf 21 -3139 3504 0 2909 10174 0 Travel;Trapdoor;16168 The Giant Dwarf 21 -3140 3505 0 2909 10174 0 Travel;Trapdoor;16168 The Giant Dwarf 21 -2923 10172 0 3141 3504 0 Ride;Train cart;7028 The Giant Dwarf -2923 10170 0 3141 3504 0 Ride;Train cart;7028 The Giant Dwarf - -# Keldagrim, Ice Mountain -2923 10176 0 2995 9835 0 Ride;Train cart;7028 150 Coins The Giant Dwarf -2923 10174 0 2995 9835 0 Ride;Train cart;7028 150 Coins The Giant Dwarf -2995 9835 0 2909 10174 0 Ride;Train cart;7029 150 Coins The Giant Dwarf -2995 9837 0 2909 10174 0 Ride;Train cart;7029 150 Coins The Giant Dwarf - -# Keldagrim, White Wolf Mountain -2919 10170 0 2876 9868 0 Ride;Train cart;7028 100 Coins The Giant Dwarf;Fishing Contest -2919 10168 0 2876 9868 0 Ride;Train cart;7028 100 Coins The Giant Dwarf;Fishing Contest -2876 9868 0 2909 10174 0 Ride;Train cart;7030 100 Coins The Giant Dwarf;Fishing Contest -2874 9868 0 2909 10174 0 Ride;Train cart;7030 100 Coins The Giant Dwarf;Fishing Contest - -# Lovakengj Minecart Network (free after The Forsaken Tower varbit 7796=11) -# Arceuus -1670 3833 0 Travel;Minecart;28835 20 Coins -# Farming Guild -1218 3739 0 Travel;Minecart;28835 20 Coins -1218 3737 0 Travel;Minecart;28835 20 Coins -# Hosidius South -1808 3481 0 Travel;Minecart;28835 20 Coins -1808 3479 0 Travel;Minecart;28835 20 Coins -# Hosidius West -1655 3543 0 Travel;Minecart;28835 20 Coins -1655 3541 0 Travel;Minecart;28835 20 Coins -# Kingstown -1697 3660 0 Travel;Minecart;28835 20 Coins -1699 3660 0 Travel;Minecart;28835 20 Coins -# Kourend Woodland -1572 3466 0 Travel;Minecart;28835 20 Coins -# Lovakengj -1518 3733 0 Travel;Minecart;28835 20 Coins -1518 3731 0 Travel;Minecart;28835 20 Coins -# Mount Quidamortem -1253 3548 0 Travel;Minecart;28835 20 Coins -1255 3548 0 Travel;Minecart;28835 20 Coins -# Northern Tundras (Wintertodt) -1648 3931 0 Travel;Minecart;28835 20 Coins -# Port Piscarilius -1759 3710 0 Travel;Minecart;28835 20 Coins -1761 3710 0 Travel;Minecart;28835 20 Coins -# Shayzien East -1590 3622 0 Travel;Minecart;28835 20 Coins -1590 3620 0 Travel;Minecart;28835 20 Coins -# Shayzien West -1413 3577 0 Travel;Minecart;28835 20 Coins -1415 3577 0 Travel;Minecart;28835 20 Coins - -# Arceuus - 1670 3833 0 20 Coins 5 1: Arceuus -# Farming Guild - 1218 3737 0 20 Coins 5 2: Farming Guild -# Hosidius South - 1808 3479 0 20 Coins 5 3: Hosidius South -# Hosidius West - 1655 3543 0 20 Coins 5 4: Hosidius West -# Kingstown - 1699 3660 0 20 Coins 5 5: Kingstown -# Kourend Woodland - 1572 3466 0 20 Coins 5 6: Kourend Woodland -# Lovakengj - 1518 3733 0 20 Coins 5 7: Lovakengj -# Mount Quidamortem - 1255 3548 0 20 Coins 5 8: Mount Quidamortem -# Northern Tundras (Wintertodt) - 1648 3931 0 20 Coins 5 9: Northern Tundras -# Port Piscarilius - 1761 3710 0 20 Coins 5 A: Port Piscarilius -# Shayzien East - 1590 3620 0 20 Coins 5 B: Shayzien East -# Shayzien West - 1415 3577 0 20 Coins 5 C: Shayzien West \ No newline at end of file +# Origin Destination menuOption menuTarget objectID Skills Currency Quests Varbits Duration Display info +# Keldagrim, Grand Exchange +3141 3504 0 2909 10174 0 Travel;Trapdoor;16168 The Giant Dwarf 21 +3140 3503 0 2909 10174 0 Travel;Trapdoor;16168 The Giant Dwarf 21 +3139 3504 0 2909 10174 0 Travel;Trapdoor;16168 The Giant Dwarf 21 +3140 3505 0 2909 10174 0 Travel;Trapdoor;16168 The Giant Dwarf 21 +2923 10172 0 3141 3504 0 Ride;Train cart;7028 The Giant Dwarf +2923 10170 0 3141 3504 0 Ride;Train cart;7028 The Giant Dwarf + +# Keldagrim, Ice Mountain +2923 10176 0 2995 9835 0 Ride;Train cart;7028 150 Coins The Giant Dwarf +2923 10174 0 2995 9835 0 Ride;Train cart;7028 150 Coins The Giant Dwarf +2995 9835 0 2909 10174 0 Ride;Train cart;7029 150 Coins The Giant Dwarf +2995 9837 0 2909 10174 0 Ride;Train cart;7029 150 Coins The Giant Dwarf + +# Keldagrim, White Wolf Mountain +2919 10170 0 2876 9868 0 Ride;Train cart;7028 100 Coins The Giant Dwarf;Fishing Contest +2919 10168 0 2876 9868 0 Ride;Train cart;7028 100 Coins The Giant Dwarf;Fishing Contest +2876 9868 0 2909 10174 0 Ride;Train cart;7030 100 Coins The Giant Dwarf;Fishing Contest +2874 9868 0 2909 10174 0 Ride;Train cart;7030 100 Coins The Giant Dwarf;Fishing Contest + +# Lovakengj Minecart Network (20 Coins before The Forsaken Tower varbit 7796=11) +# Arceuus +1670 3832 0 Travel;Minecart;28835 20 Coins 7796<11 +# Farming Guild +1218 3739 0 Travel;Minecart;28835 20 Coins 7796<11 +1218 3737 0 Travel;Minecart;28835 20 Coins 7796<11 +# Hosidius South +1808 3481 0 Travel;Minecart;28835 20 Coins 7796<11 +1808 3479 0 Travel;Minecart;28835 20 Coins 7796<11 +# Hosidius West +1655 3543 0 Travel;Minecart;28835 20 Coins 7796<11 +1655 3541 0 Travel;Minecart;28835 20 Coins 7796<11 +# Kingstown +1697 3660 0 Travel;Minecart;28835 20 Coins 7796<11 +1699 3660 0 Travel;Minecart;28835 20 Coins 7796<11 +# Kourend Woodland +1572 3466 0 Travel;Minecart;28835 20 Coins 7796<11 +# Lovakengj +1518 3733 0 Travel;Minecart;28835 20 Coins 7796<11 +1518 3731 0 Travel;Minecart;28835 20 Coins 7796<11 +# Mount Quidamortem +1253 3548 0 Travel;Minecart;28835 20 Coins 7796<11 +1255 3548 0 Travel;Minecart;28835 20 Coins 7796<11 +# Northern Tundras (Wintertodt) +1648 3931 0 Travel;Minecart;28835 20 Coins 7796<11 +# Port Piscarilius +1759 3710 0 Travel;Minecart;28835 20 Coins 7796<11 +1761 3710 0 Travel;Minecart;28835 20 Coins 7796<11 +# Shayzien East +1590 3622 0 Travel;Minecart;28835 20 Coins 7796<11 +1590 3620 0 Travel;Minecart;28835 20 Coins 7796<11 +# Shayzien West +1413 3577 0 Travel;Minecart;28835 20 Coins 7796<11 +1415 3577 0 Travel;Minecart;28835 20 Coins 7796<11 + +# Arceuus + 1670 3833 0 7796<11 5 1: Arceuus +# Farming Guild + 1218 3737 0 7796<11 5 2: Farming Guild +# Hosidius South + 1808 3479 0 7796<11 5 3: Hosidius South +# Hosidius West + 1655 3543 0 7796<11 5 4: Hosidius West +# Kingstown + 1699 3660 0 7796<11 5 5: Kingstown +# Kourend Woodland + 1572 3466 0 7796<11 5 6: Kourend Woodland +# Lovakengj + 1518 3733 0 7796<11 5 7: Lovakengj +# Mount Quidamortem + 1255 3548 0 7796<11 5 8: Mount Quidamortem +# Northern Tundras (Wintertodt) + 1648 3931 0 7796<11 5 9: Northern Tundras +# Port Piscarilius + 1761 3710 0 7796<11 5 A: Port Piscarilius +# Shayzien East + 1590 3620 0 7796<11 5 B: Shayzien East +# Shayzien West + 1415 3577 0 7796<11 5 C: Shayzien West + +# Lovakengj Minecart Network (free after The Forsaken Tower varbit 7796=11) +# Arceuus +1670 3832 0 Travel;Minecart;28835 7796=11 +# Farming Guild +1218 3739 0 Travel;Minecart;28835 7796=11 +1218 3737 0 Travel;Minecart;28835 7796=11 +# Hosidius South +1808 3481 0 Travel;Minecart;28835 7796=11 +1808 3479 0 Travel;Minecart;28835 7796=11 +# Hosidius West +1655 3543 0 Travel;Minecart;28835 7796=11 +1655 3541 0 Travel;Minecart;28835 7796=11 +# Kingstown +1697 3660 0 Travel;Minecart;28835 7796=11 +1699 3660 0 Travel;Minecart;28835 7796=11 +# Kourend Woodland +1572 3466 0 Travel;Minecart;28835 7796=11 +# Lovakengj +1518 3733 0 Travel;Minecart;28835 7796=11 +1518 3731 0 Travel;Minecart;28835 7796=11 +# Mount Quidamortem +1253 3548 0 Travel;Minecart;28835 7796=11 +1255 3548 0 Travel;Minecart;28835 7796=11 +# Northern Tundras (Wintertodt) +1648 3931 0 Travel;Minecart;28835 7796=11 +# Port Piscarilius +1759 3710 0 Travel;Minecart;28835 7796=11 +1761 3710 0 Travel;Minecart;28835 7796=11 +# Shayzien East +1590 3622 0 Travel;Minecart;28835 7796=11 +1590 3620 0 Travel;Minecart;28835 7796=11 +# Shayzien West +1413 3577 0 Travel;Minecart;28835 7796=11 +1415 3577 0 Travel;Minecart;28835 7796=11 + +# Arceuus + 1670 3833 0 7796=11 5 1: Arceuus +# Farming Guild + 1218 3737 0 7796=11 5 2: Farming Guild +# Hosidius South + 1808 3479 0 7796=11 5 3: Hosidius South +# Hosidius West + 1655 3543 0 7796=11 5 4: Hosidius West +# Kingstown + 1699 3660 0 7796=11 5 5: Kingstown +# Kourend Woodland + 1572 3466 0 7796=11 5 6: Kourend Woodland +# Lovakengj + 1518 3733 0 7796=11 5 7: Lovakengj +# Mount Quidamortem + 1255 3548 0 7796=11 5 8: Mount Quidamortem +# Northern Tundras (Wintertodt) + 1648 3931 0 7796=11 5 9: Northern Tundras +# Port Piscarilius + 1761 3710 0 7796=11 5 A: Port Piscarilius +# Shayzien East + 1590 3620 0 7796=11 5 B: Shayzien East +# Shayzien West + 1415 3577 0 7796=11 5 C: Shayzien West diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/quetzals.tsv b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/quetzals.tsv index be59cb839ff..168c7c2de89 100644 --- a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/quetzals.tsv +++ b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/quetzals.tsv @@ -1,29 +1,29 @@ -# Origin Destination Skills Quests Varbits Duration Display info -1389 2901 0 Twilight's Promise 6 -1697 3140 0 Twilight's Promise 6 -1585 3053 0 Twilight's Promise 6 -1510 3221 0 Twilight's Promise 6 -1548 2995 0 Twilight's Promise 6 -1437 3171 0 Twilight's Promise 6 -1779 3111 0 Twilight's Promise 9958=1 6 -1700 3037 0 Twilight's Promise 9957=1 6 -1670 2933 0 Twilight's Promise 9956=1 6 -1446 3108 0 Twilight's Promise 9955=1 6 -1613 3300 0 Twilight's Promise 11379=1 6 -1226 3091 0 Twilight's Promise 6 -1344 3022 0 Twilight's Promise 17757=1 6 -1411 3361 0 Twilight's Promise 6 - 1389 2901 0 Twilight's Promise 6 Aldarin - 1697 3140 0 Twilight's Promise 6 Civitas illa Fortis - 1585 3053 0 Twilight's Promise 6 Hunter Guild - 1510 3221 0 Twilight's Promise 6 Quetzacalli Gorge - 1548 2995 0 Twilight's Promise 6 Sunset Coast - 1437 3171 0 Twilight's Promise 6 The Teomat - 1779 3111 0 Twilight's Promise 9958=1 6 Fortis Colosseum - 1700 3037 0 Twilight's Promise 9957=1 6 Outer Fortis - 1670 2933 0 Twilight's Promise 9956=1 6 Colossal Wyrm Remains - 1446 3108 0 Twilight's Promise 9955=1 6 Cam Torum Entrance - 1613 3300 0 Twilight's Promise 11379=1 6 Salvager Overlook - 1226 3091 0 Twilight's Promise 6 Tal Teklan - 1344 3022 0 Twilight's Promise 17757=1 6 Kastori - 1411 3361 0 Twilight's Promise 6 Auburnvale \ No newline at end of file +# Origin Destination menuOption menuTarget objectID Quests Duration Display info VarPlayers +1389 2901 0 Travel Renu 13350 Twilight's Promise 6 +1411 3361 0 Travel Renu 13350 Twilight's Promise 6 +1697 3140 0 Travel Renu 13350 Twilight's Promise 6 +1585 3053 0 Travel Renu 13350 Twilight's Promise 6 +1510 3222 0 Travel Renu 13350 Twilight's Promise 6 +1548 2995 0 Travel Renu 13350 Twilight's Promise 6 +1226 3091 0 Travel Renu 13350 Twilight's Promise 6 +1437 3171 0 Travel Renu 13350 Twilight's Promise 6 +1779 3111 0 Travel Renu 13350 Twilight's Promise 6 4182&256 +1344 3022 0 Travel Renu 13350 Twilight's Promise 6 4182&16384 +1700 3037 0 Travel Renu 13350 Twilight's Promise 6 4182&128 +1670 2933 0 Travel Renu 13350 Twilight's Promise 6 4182&64 +1446 3108 0 Travel Renu 13350 Twilight's Promise 6 4182&32 +1613 3300 0 Travel Renu 13350 Twilight's Promise 6 4182&2048 + 1389 2901 0 Twilight's Promise 6 Aldarin + 1411 3361 0 Twilight's Promise 6 Auburnvale + 1697 3140 0 Twilight's Promise 6 Civitas illa Fortis + 1585 3053 0 Twilight's Promise 6 Hunter Guild + 1510 3222 0 Twilight's Promise 6 Quetzacalli Gorge + 1548 2995 0 Twilight's Promise 6 Sunset Coast + 1226 3091 0 Twilight's Promise 6 Tal Teklan + 1437 3171 0 Twilight's Promise 6 The Teomat + 1779 3111 0 Twilight's Promise 6 Fortis Colosseum 4182&256 + 1344 3022 0 Twilight's Promise 6 Kastori 4182&16384 + 1700 3037 0 Twilight's Promise 6 Outer Fortis 4182&128 + 1670 2933 0 Twilight's Promise 6 Colossal Wyrm Remains 4182&64 + 1446 3108 0 Twilight's Promise 6 Cam Torum 4182&32 + 1613 3300 0 Twilight's Promise 6 Salvager Overlook 4182&2048 diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/ships.tsv b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/ships.tsv index 97ade59f3a0..18f11a08a07 100644 --- a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/ships.tsv +++ b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/ships.tsv @@ -2,6 +2,11 @@ # Port Sarim, Musa Point 3029 3217 0 2956 3143 1 Musa Point;Captain Tobias;3644 30 Coins 10 Musa Point 2956 3146 0 3032 3217 1 Port Sarim;Customs officer;3648 30 Coins 10 Port Sarim +# Port Sarim, Musa Point, Pandemonium +3029 3217 0 3064 3003 0 The Pandemonium;Captain Tobias;14979 30 Coins Pandemonium Y 10 Pandemonium +3064 3003 0 3029 3217 0 Port Sarim;Seaman Morris;8631 30 Coins Pandemonium Y 10 Port Sarim +2956 3146 0 3064 3003 0 The Pandemonium;Customs officer;14985 30 Coins Pandemonium Y 10 Pandemonium +3064 3003 0 2956 3146 0 Musa Point;Seaman Morris;8631 30 Coins Pandemonium Y 10 Musa Point # Ardougne, Brimhaven, Rimmington 2673 3275 0 2775 3233 1 Brimhaven;Captain Barnaby;9250 30 Coins Y 6 Brimhaven 2673 3275 0 2915 3221 1 Rimmington;Captain Barnaby;9250 30 Coins Y 6 Rimmington diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/spirit_trees.tsv b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/spirit_trees.tsv index bbbb01759c3..5935b3eb7dd 100644 --- a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/spirit_trees.tsv +++ b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/spirit_trees.tsv @@ -132,7 +132,7 @@ 1252 3748 0 Travel;Spirit Tree;33733 Tree Gnome Village;The Grand Tree # Player-owned house # Travel;Spirit Tree;29227 -# Poison Waste +# Poison Waste 2337 3110 0 Travel;Spirit Tree;49595 The Path of Glouphrie 2337 3111 0 Travel;Spirit Tree;49595 The Path of Glouphrie 2337 3112 0 Travel;Spirit Tree;49595 The Path of Glouphrie @@ -145,7 +145,17 @@ 2338 3109 0 Travel;Spirit Tree;49595 The Path of Glouphrie 2339 3109 0 Travel;Spirit Tree;49595 The Path of Glouphrie 2340 3109 0 Travel;Spirit Tree;49595 The Path of Glouphrie -# Tree Gnome Village +# Laguna Aurorae +1201 2788 0 Travel;Spirit Tree;26262 +1202 2788 0 Travel;Spirit Tree;26262 +1201 2787 0 Travel;Spirit Tree;26262 +1201 2786 0 Travel;Spirit Tree;26262 +1204 2786 0 Travel;Spirit Tree;26262 +1201 2785 0 Travel;Spirit Tree;26262 +1202 2785 0 Travel;Spirit Tree;26262 +1203 2785 0 Travel;Spirit Tree;26262 +1204 2785 0 Travel;Spirit Tree;26262 +# Tree Gnome Village 2542 3170 0 Tree Gnome Village 2 1: Tree Gnome Village # Gnome Stronghold 2461 3444 0 The Grand Tree 2 2: Gnome Stronghold diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/teleportation_items.tsv b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/teleportation_items.tsv index a1327e27c90..76756471520 100644 --- a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/teleportation_items.tsv +++ b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/teleportation_items.tsv @@ -241,22 +241,25 @@ 3239 6077 0 13280;13342 2187=7 Y F 19 4 Max cape: Home # 8 - Hosidius 1740 3517 0 13280;13342 2187=8 Y F 19 4 Max cape: Home -2952 3224 0 13280;13342 Y F 19 4 Max cape: Rimmington -2892 3465 0 13280;13342 Y F 19 4 Max cape: Taverley -3339 3001 0 13280;13342 Y F 19 4 Max cape: Pollnivneach -1743 3517 0 13280;13342 Y F 19 4 Max cape: Hosidius -2669 3629 0 13280;13342 Y F 19 4 Max cape: Rellekka -2756 3176 0 13280;13342 Y F 19 4 Max cape: Brimhaven -2545 3097 0 13280;13342 Y F 19 4 Max cape: Yanille -3239 6077 0 13280;13342 Y F 19 4 Max cape: Prifddinas -2865 3546 0 13280;13342 Y F 19 4 Max cape: Warriors' Guild -2604 3401 0 13280;13342 Y F 19 4 Max cape: Fishing Guild -2931 3286 0 13280;13342 Y F 19 4 Max cape: Crafting Guild -2556 2917 0 13280;13342 Y F 19 4 Max cape: Feldip Hills -3144 3772 0 13280;13342 Y F 19 4 Max cape: Black chinchompa -3144 3772 0 13280;13342 Y F 19 4 Max cape: Black chinchompa -# Quest point cape (instead of using the item name we use the action teleport, we use the itemids to verifiy the item) -2728 3347 0 9813;13068 Y F 19 4 Quest point cape: Teleport +2952 3224 0 2376 Total 13280=1||13342=1 Y F 20 4 Max cape: POH Portals: Rimmington +2892 3465 0 2376 Total 13280=1||13342=1 Y F 20 4 Max cape: POH Portals: Taverley +3339 3001 0 2376 Total 13280=1||13342=1 Y F 20 4 Max cape: POH Portals: Pollnivneach +1743 3517 0 2376 Total 13280=1||13342=1 Y F 20 4 Max cape: POH Portals: Hosidius +2669 3629 0 2376 Total 13280=1||13342=1 Y F 20 4 Max cape: POH Portals: Rellekka +2756 3176 0 2376 Total 13280=1||13342=1 Y F 20 4 Max cape: POH Portals: Brimhaven +2545 3097 0 2376 Total 13280=1||13342=1 Y F 20 4 Max cape: POH Portals: Yanille +3239 6077 0 2376 Total 13280=1||13342=1 Y F 20 4 Max cape: POH Portals: Prifddinas +2865 3546 0 2376 Total 13280=1||13342=1 Y F 20 4 Max cape: Warriors' Guild +2604 3401 0 2376 Total 13280=1||13342=1 Y F 20 4 Max cape: Fishing Teleports: Fishing Guild +2504 3484 0 2376 Total 13280=1||13342=1 Y F 20 4 Max cape: Fishing Teleports: Otto's Grotto +2931 3286 0 2376 Total 13280=1||13342=1 Y F 20 4 Max cape: Crafting Guild +2556 2917 0 2376 Total 13280=1||13342=1 Y T 20 4 Max cape: Other Teleports: Feldip Hills +3144 3772 0 2376 Total 13280=1||13342=1 Y T 20 4 Max cape: Other Teleports: Black chinchompa +1558 3046 0 2376 Total 13280=1||13342=1 Y F 20 4 Max cape: Other Teleports: Hunter Guild +1248 3725 0 2376 Total 13280=1||13342=1 Y F 20 4 Max cape: Other Teleports: Farming Guild +3048 2972 0 2376 Total 13280=1||13342=1 Y F 20 4 Max cape: Other Teleports: The Pandemonium +# Quest point cape (instead of using the item name we use the action teleport, we use the itemids to verifiy the item) +2729 3348 0 327 Quest 9813=1||13068=1 Y F 20 4 Quest point cape: Teleport 2689 3547 0 13221;13222 Y F 19 4 Music cape: Teleport 2574 3323 0 13069;19476 Y F 19 4 Achievement diary cape: Two-pints 3302 3122 0 13069;19476 Y F 19 4 Achievement diary cape: Jarr @@ -351,21 +354,36 @@ 1367 3087 0 29893 16752=1 Y T 19 4 Pendant of ates: Kastori 1364 3275 0 29893 16757=1 Y T 19 4 Pendant of ates: Nemus Retreat -#Quetzal Whistle — one row per map destination; quest Children of the Sun (item unlock). Varbit columns match quetzals.tsv for locked map pins. -1389 2901 0 29271;29273;29275 Children of the Sun Y T 19 4 Quetzal whistle: Aldarin -1697 3140 0 29271;29273;29275 Children of the Sun Y T 19 4 Quetzal whistle: Civitas illa Fortis -1585 3053 0 29271;29273;29275 Children of the Sun Y T 19 4 Quetzal whistle: Hunter Guild -1510 3221 0 29271;29273;29275 Children of the Sun Y T 19 4 Quetzal whistle: Quetzacalli Gorge -1548 2995 0 29271;29273;29275 Children of the Sun Y T 19 4 Quetzal whistle: Sunset Coast -1437 3171 0 29271;29273;29275 Children of the Sun Y T 19 4 Quetzal whistle: The Teomat -1779 3111 0 29271;29273;29275 Children of the Sun 9958=1 Y T 19 4 Quetzal whistle: Fortis Colosseum -1700 3037 0 29271;29273;29275 Children of the Sun 9957=1 Y T 19 4 Quetzal whistle: Outer Fortis -1670 2933 0 29271;29273;29275 Children of the Sun 9956=1 Y T 19 4 Quetzal whistle: Colossal Wyrm Remains -1446 3108 0 29271;29273;29275 Children of the Sun 9955=1 Y T 19 4 Quetzal whistle: Cam Torum Entrance -1613 3300 0 29271;29273;29275 Children of the Sun 11379=1 Y T 19 4 Quetzal whistle: Salvager Overlook -1226 3091 0 29271;29273;29275 Children of the Sun Y T 19 4 Quetzal whistle: Tal Teklan -1344 3022 0 29271;29273;29275 Children of the Sun 17757=1 Y T 19 4 Quetzal whistle: Kastori -1411 3361 0 29271;29273;29275 Children of the Sun Y T 19 4 Quetzal whistle: Auburnvale +# Quetzal whistles — charged variants are consumable; the perfected infinite whistle is permanent. +# Separate variants preserve Microbot's Inventory (perm) policy while retaining upstream destinations and unlocks. +1389 2901 0 29271=1||29273=1||29275=1 Twilight's Promise Y T 20 4 Quetzal whistle: Aldarin +1411 3361 0 29271=1||29273=1||29275=1 Twilight's Promise Y T 20 4 Quetzal whistle: Auburnvale +1697 3140 0 29271=1||29273=1||29275=1 Twilight's Promise Y T 20 4 Quetzal whistle: Civitas illa Fortis +1585 3053 0 29271=1||29273=1||29275=1 Twilight's Promise Y T 20 4 Quetzal whistle: Hunter Guild +1510 3222 0 29271=1||29273=1||29275=1 Twilight's Promise Y T 20 4 Quetzal whistle: Quetzacalli Gorge +1548 2995 0 29271=1||29273=1||29275=1 Twilight's Promise Y T 20 4 Quetzal whistle: Sunset Coast +1226 3091 0 29271=1||29273=1||29275=1 Twilight's Promise Y T 20 4 Quetzal whistle: Tal Teklan +1437 3171 0 29271=1||29273=1||29275=1 Twilight's Promise Y T 20 4 Quetzal whistle: The Teomat +1779 3111 0 29271=1||29273=1||29275=1 Twilight's Promise 4182&256 Y T 20 4 Quetzal whistle: Fortis Colosseum +1344 3022 0 29271=1||29273=1||29275=1 Twilight's Promise 4182&16384 Y T 20 4 Quetzal whistle: Kastori +1700 3037 0 29271=1||29273=1||29275=1 Twilight's Promise 4182&128 Y T 20 4 Quetzal whistle: Outer Fortis +1670 2933 0 29271=1||29273=1||29275=1 Twilight's Promise 4182&64 Y T 20 4 Quetzal whistle: Colossal Wyrm Remains +1446 3108 0 29271=1||29273=1||29275=1 Twilight's Promise 4182&32 Y T 20 4 Quetzal whistle: Cam Torum +1613 3300 0 29271=1||29273=1||29275=1 Twilight's Promise 4182&2048 Y T 20 4 Quetzal whistle: Salvager Overlook +1389 2901 0 33120=1 Twilight's Promise Y F 20 4 Quetzal whistle: Aldarin +1411 3361 0 33120=1 Twilight's Promise Y F 20 4 Quetzal whistle: Auburnvale +1697 3140 0 33120=1 Twilight's Promise Y F 20 4 Quetzal whistle: Civitas illa Fortis +1585 3053 0 33120=1 Twilight's Promise Y F 20 4 Quetzal whistle: Hunter Guild +1510 3222 0 33120=1 Twilight's Promise Y F 20 4 Quetzal whistle: Quetzacalli Gorge +1548 2995 0 33120=1 Twilight's Promise Y F 20 4 Quetzal whistle: Sunset Coast +1226 3091 0 33120=1 Twilight's Promise Y F 20 4 Quetzal whistle: Tal Teklan +1437 3171 0 33120=1 Twilight's Promise Y F 20 4 Quetzal whistle: The Teomat +1779 3111 0 33120=1 Twilight's Promise 4182&256 Y F 20 4 Quetzal whistle: Fortis Colosseum +1344 3022 0 33120=1 Twilight's Promise 4182&16384 Y F 20 4 Quetzal whistle: Kastori +1700 3037 0 33120=1 Twilight's Promise 4182&128 Y F 20 4 Quetzal whistle: Outer Fortis +1670 2933 0 33120=1 Twilight's Promise 4182&64 Y F 20 4 Quetzal whistle: Colossal Wyrm Remains +1446 3108 0 33120=1 Twilight's Promise 4182&32 Y F 20 4 Quetzal whistle: Cam Torum +1613 3300 0 33120=1 Twilight's Promise 4182&2048 Y F 20 4 Quetzal whistle: Salvager Overlook #Giantsoul Amulet 3174 9898 0 30638 Y T 19 4 Giantsoul Amulet: Bryophyta 6208 6336 0 30638 Y T 19 4 Giantsoul Amulet: Obor diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/teleportation_minigames.tsv b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/teleportation_minigames.tsv index f3dac9a6f8e..d090ff7acaa 100644 --- a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/teleportation_minigames.tsv +++ b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/teleportation_minigames.tsv @@ -21,7 +21,7 @@ Destination Skills Quests isMembers Varbits Varplayers Wilderness level Duration 3361 3149 0 Y 888@20 0 23 Giant's Foundry # Guardians of the Rift - Temple of the Eye -3104 9573 0 Temple of the Eye Y 888@20 0 23 Guardians of the Rift +3614 9477 0 Temple of the Eye Y 888@20 0 23 Guardians of the Rift # Last Man Standing - Ferox Enclave 3150 3635 0 888@20 0 23 Last Man Standing @@ -33,15 +33,13 @@ Destination Skills Quests isMembers Varbits Varplayers Wilderness level Duration 2609 3114 0 Y 888@20 0 23 Nightmare Zone # Pest Control - Void Knights' Outpost (requires 40 combat) -2658 2663 0 Y 888@20 0 23 Pest Control +2658 2663 0 40 Combat Y 888@20 0 23 Pest Control # Rat Pits - East Ardougne, Keldagrim, Port Sarim, Varrock 2561 3319 0 Ratcatchers Y 888@20 0 23 Rat Pits: Ardougne -## NEEDS FIXING -# 2914 10193 0 Ratcatchers Y 888@20 0 23 Rat Pits: Keldagrim -# +2914 10193 0 Ratcatchers Y 888@20 0 23 Rat Pits: Keldagrim 3018 3231 0 Ratcatchers Y 888@20 0 23 Rat Pits: Port Sarim -3267 3399 0 Ratcatchers Y 888@20 0 23 Rat Pits: Varrock +3262 3405 0 Ratcatchers Y 888@20 0 23 Rat Pits: Varrock # Shades of Mort'ton 3506 3304 0 Shades of Mort'ton Y 888@20 0 23 Shades of Mort'ton diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/teleportation_spells.tsv b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/teleportation_spells.tsv index 816d88f483d..705f13b4fd8 100644 --- a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/teleportation_spells.tsv +++ b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/teleportation_spells.tsv @@ -1,147 +1,156 @@ -# Destination Skills Quests Wilderness level Varbits Varplayers isMembers Duration Display info +# Destination Skills Quests Wilderness level Varbits Varplayers isMembers Duration Display info Items # Standard Spellbook - Varbit 4070=0 # Lumbridge Home Teleport. Varplayer 892 is LAST_HOME_TELEPORT in epoch minutes. 3221 3218 0 19 4070=0 892@30 20 Lumbridge Home Teleport + +# Ancient Magicks - Desert Treasure I +3087 3504 0 Desert Treasure I 19 4070=1 892@30 Y 20 Edgeville Home Teleport + +# Lunar spellbook - Lunar Diplomacy +2113 3915 0 Lunar Diplomacy 19 4070=2 892@30 Y 20 Lunar Home Teleport + +# Arceuus spellbook +1700 3882 0 19 4070=3 892@30 Y 20 Arceuus Home Teleport # Varrock Teleport -3213 3424 0 25 Magic 19 4070=0;4585=0 4 Varrock Teleport +3213 3424 0 25 Magic 19 4070=0;4585=0 4 Varrock Teleport AIR_RUNE=3&&FIRE_RUNE=1&&LAW_RUNE=1 # Varbit 4480 is DIARY_VARROCK_MEDIUM -3164 3478 0 25 Magic 19 4070=0;4480=1;4585=1 4 Varrock Teleport: Grand Exchange +3164 3478 0 25 Magic 19 4070=0;4480=1;4585=1 4 Varrock Teleport: Grand Exchange AIR_RUNE=3&&FIRE_RUNE=1&&LAW_RUNE=1 # Lumbridge Teleport -3221 3218 0 31 Magic 19 4070=0 4 Lumbridge Teleport +3221 3218 0 31 Magic 19 4070=0 4 Lumbridge Teleport AIR_RUNE=3&&EARTH_RUNE=1&&LAW_RUNE=1 # Falador Teleport -2965 3378 0 37 Magic 19 4070=0 4 Falador Teleport +2965 3378 0 37 Magic 19 4070=0 4 Falador Teleport AIR_RUNE=3&&WATER_RUNE=1&&LAW_RUNE=1 # Teleport to House - These depend on the current player's home location determined by varbit 2187 # 1 - Rimmington -2952 3224 0 40 Magic 19 4070=0;2187=1 Y 4 Teleport to House: Outside +2952 3224 0 40 Magic 19 4070=0;2187=1 Y 4 Teleport to House: Outside AIR_RUNE=1&&EARTH_RUNE=1&&LAW_RUNE=1 # 2 - Taverly -2892 3465 0 40 Magic 19 4070=0;2187=2 Y 4 Teleport to House: Outside +2892 3465 0 40 Magic 19 4070=0;2187=2 Y 4 Teleport to House: Outside AIR_RUNE=1&&EARTH_RUNE=1&&LAW_RUNE=1 # 3 - Pollnivneach -3339 3001 0 40 Magic 19 4070=0;2187=3 Y 4 Teleport to House: Outside +3339 3001 0 40 Magic 19 4070=0;2187=3 Y 4 Teleport to House: Outside AIR_RUNE=1&&EARTH_RUNE=1&&LAW_RUNE=1 # 4 - Rellekka -2669 3629 0 40 Magic 19 4070=0;2187=4 Y 4 Teleport to House: Outside +2669 3629 0 40 Magic 19 4070=0;2187=4 Y 4 Teleport to House: Outside AIR_RUNE=1&&EARTH_RUNE=1&&LAW_RUNE=1 # 5 - Brimhaven -2756 3176 0 40 Magic 19 4070=0;2187=5 Y 4 Teleport to House: Outside +2756 3176 0 40 Magic 19 4070=0;2187=5 Y 4 Teleport to House: Outside AIR_RUNE=1&&EARTH_RUNE=1&&LAW_RUNE=1 # 6 - Yanille -2545 3097 0 40 Magic 19 4070=0;2187=6 Y 4 Teleport to House: Outside +2545 3097 0 40 Magic 19 4070=0;2187=6 Y 4 Teleport to House: Outside AIR_RUNE=1&&EARTH_RUNE=1&&LAW_RUNE=1 # 7 - Prifddinas -3239 6077 0 40 Magic 19 4070=0;2187=7 Y 4 Teleport to House: Outside +3239 6077 0 40 Magic 19 4070=0;2187=7 Y 4 Teleport to House: Outside AIR_RUNE=1&&EARTH_RUNE=1&&LAW_RUNE=1 # 8 - Hosidius -1740 3517 0 40 Magic 19 4070=0;2187=8 Y 4 Teleport to House: Outside +1740 3517 0 40 Magic 19 4070=0;2187=8 Y 4 Teleport to House: Outside AIR_RUNE=1&&EARTH_RUNE=1&&LAW_RUNE=1 # Camelot Teleport -2757 3478 0 45 Magic 19 4070=0 Y 10 Camelot Teleport +2757 3478 0 45 Magic 19 4070=0 Y 10 Camelot Teleport AIR_RUNE=5&&LAW_RUNE=1 # Kourend Castle Teleport -1641 3673 0 48 Magic Client of Kourend 19 4070=0 Y 4 Kourend Castle Teleport +1641 3673 0 48 Magic Client of Kourend 19 4070=0 Y 4 Kourend Castle Teleport FIRE_RUNE=1&&WATER_RUNE=1&&LAW_RUNE=2 # Ardougne Teleport -2661 3302 0 51 Magic Plague City 19 4070=0 165=30 Y 10 Ardougne Teleport +2661 3302 0 51 Magic Plague City 19 4070=0 165=30 Y 10 Ardougne Teleport WATER_RUNE=2&&LAW_RUNE=2 # Civitas illa Fortis Teleport -1680 3134 0 54 Magic Twilight's Promise 19 4070=0 Y 4 Civitas illa Fortis Teleport +1680 3134 0 54 Magic Twilight's Promise 19 4070=0 Y 4 Civitas illa Fortis Teleport EARTH_RUNE=1&&FIRE_RUNE=1&&LAW_RUNE=2 # Watchtower Teleport -2932 4711 2 58 Magic Watchtower 19 4070=0;4548=0 212=14 Y 10 Watchtower Teleport +2932 4711 2 58 Magic Watchtower 19 4070=0;4548=0 212=14 Y 10 Watchtower Teleport EARTH_RUNE=2&&LAW_RUNE=2 # Varbit 4460 is DIARY_ARDOUGNE_HARD -2584 3097 0 58 Magic Watchtower 19 4070=0;4460=1;4548=1 212=14 Y 10 Watchtower Teleport: Yanille +2584 3097 0 58 Magic Watchtower 19 4070=0;4460=1;4548=1 212=14 Y 10 Watchtower Teleport: Yanille EARTH_RUNE=2&&LAW_RUNE=2 # Trollheim Teleport -2890 3679 0 61 Magic Eadgar's Ruse 19 4070=0 Y 10 Trollheim Teleport +2890 3679 0 61 Magic Eadgar's Ruse 19 4070=0 Y 10 Trollheim Teleport FIRE_RUNE=2&&LAW_RUNE=2 # Ape Atoll Teleport -2797 2798 1 64 Magic Freeing King Awowogei 19 4070=0 Y 10 Ape Atoll Teleport +2797 2798 1 64 Magic Freeing King Awowogei 19 4070=0 Y 10 Ape Atoll Teleport FIRE_RUNE=2&&WATER_RUNE=2&&LAW_RUNE=2&&BANANA=1 # Ancient Spellbook - Varbit 4070=1 # Paddewwa Teleport -3100 9883 0 54 Magic Desert Treasure I 19 4070=1 Y 10 Paddewwa Teleport +3100 9883 0 54 Magic Desert Treasure I 19 4070=1 Y 10 Paddewwa Teleport AIR_RUNE=1&&FIRE_RUNE=1&&LAW_RUNE=2 # Senntisten Teleport -3320 3337 0 60 Magic Desert Treasure I 19 4070=1 Y 10 Senntisten Teleport +3320 3337 0 60 Magic Desert Treasure I 19 4070=1 Y 10 Senntisten Teleport LAW_RUNE=2&&SOUL_RUNE=1 # Kharyrll Teleport -3494 3473 0 66 Magic Desert Treasure I 19 4070=1 Y 10 Kharyrll Teleport +3494 3473 0 66 Magic Desert Treasure I 19 4070=1 Y 10 Kharyrll Teleport LAW_RUNE=2&&BLOOD_RUNE=1 # Lassar Teleport -3002 3470 0 72 Magic Desert Treasure I 19 4070=1 Y 10 Lassar Teleport +3002 3470 0 72 Magic Desert Treasure I 19 4070=1 Y 10 Lassar Teleport LAW_RUNE=2&&WATER_RUNE=4 # Dareeyak Teleport -2968 3696 0 78 Magic Desert Treasure I 19 4070=1 Y 10 Dareeyak Teleport +2968 3696 0 78 Magic Desert Treasure I 19 4070=1 Y 10 Dareeyak Teleport AIR_RUNE=2&&FIRE_RUNE=3&&LAW_RUNE=2 # Carrallanger Teleport -3158 3666 0 84 Magic Desert Treasure I 19 4070=1 Y 10 Carrallanger Teleport +3158 3666 0 84 Magic Desert Treasure I 19 4070=1 Y 10 Carrallanger Teleport LAW_RUNE=2&&SOUL_RUNE=2 # Teleport to Target # 85 Magic Desert Treasure I 19 4070=1 Y 10 Teleport to Target # Annakarl Teleport -3287 3888 0 90 Magic Desert Treasure I 19 4070=1 Y 10 Annakarl Teleport +3287 3888 0 90 Magic Desert Treasure I 19 4070=1 Y 10 Annakarl Teleport LAW_RUNE=2&&BLOOD_RUNE=2 # Ghorrock Teleport -2974 3873 0 96 Magic Desert Treasure I 19 4070=1 Y 10 Ghorrock Teleport +2974 3873 0 96 Magic Desert Treasure I 19 4070=1 Y 10 Ghorrock Teleport LAW_RUNE=2&&WATER_RUNE=8 # Lunar Spellbook - Varbit 4070=2 # Moonclan Teleport -2113 3915 0 69 Magic Lunar Diplomacy 19 4070=2 Y 10 Moonclan Teleport +2113 3915 0 69 Magic Lunar Diplomacy 19 4070=2 Y 10 Moonclan Teleport EARTH_RUNE=2&&ASTRAL_RUNE=2&&LAW_RUNE=1 # Ourania Teleport -2468 3246 0 71 Magic Lunar Diplomacy 19 4070=2;5376=1 Y 10 Ourania Teleport +2468 3246 0 71 Magic Lunar Diplomacy 19 4070=2;5376=1 Y 10 Ourania Teleport EARTH_RUNE=6&&ASTRAL_RUNE=2&&LAW_RUNE=1 # Waterbirth Teleport -2546 3756 0 72 Magic Lunar Diplomacy 19 4070=2 Y 10 Waterbirth Teleport +2546 3756 0 72 Magic Lunar Diplomacy 19 4070=2 Y 10 Waterbirth Teleport WATER_RUNE=1&&ASTRAL_RUNE=2&&LAW_RUNE=1 # Barbarian Teleport -2543 3569 0 75 Magic Lunar Diplomacy 19 4070=2 Y 10 Barbarian Teleport +2543 3569 0 75 Magic Lunar Diplomacy 19 4070=2 Y 10 Barbarian Teleport FIRE_RUNE=3&&ASTRAL_RUNE=2&&LAW_RUNE=2 # Khazard Teleport -2636 3167 0 78 Magic Lunar Diplomacy 19 4070=2 Y 10 Khazard Teleport +2636 3167 0 78 Magic Lunar Diplomacy 19 4070=2 Y 10 Khazard Teleport WATER_RUNE=4&&ASTRAL_RUNE=2&&LAW_RUNE=2 # Fishing Guild Teleport -2613 3391 0 85 Magic Lunar Diplomacy 19 4070=2 Y 10 Fishing Guild Teleport +2613 3391 0 85 Magic Lunar Diplomacy 19 4070=2 Y 10 Fishing Guild Teleport WATER_RUNE=10&&ASTRAL_RUNE=3&&LAW_RUNE=3 # Catherby Teleport -2801 3449 0 87 Magic Lunar Diplomacy 19 4070=2 Y 10 Catherby Teleport +2801 3449 0 87 Magic Lunar Diplomacy 19 4070=2 Y 10 Catherby Teleport WATER_RUNE=10&&ASTRAL_RUNE=3&&LAW_RUNE=3 # Ice Plateau Teleport -2975 3938 0 89 Magic Lunar Diplomacy 19 4070=2 Y 10 Ice Plateau Teleport +2975 3938 0 89 Magic Lunar Diplomacy 19 4070=2 Y 10 Ice Plateau Teleport WATER_RUNE=8&&ASTRAL_RUNE=3&&LAW_RUNE=3 # Arceuus Spellbook - Varbit 4070=3 # Arceuus Library Teleport -1632 3834 0 6 Magic 19 4070=3 Y 10 Arceuus Library Teleport +1632 3834 0 6 Magic 19 4070=3 Y 10 Arceuus Library Teleport EARTH_RUNE=2&&LAW_RUNE=1 # Draynor Manor Teleport -3108 3351 0 17 Magic 19 4070=3 Y 4 Draynor Manor Teleport +3108 3351 0 17 Magic 19 4070=3 Y 4 Draynor Manor Teleport EARTH_RUNE=1&&WATER_RUNE=1&&LAW_RUNE=1 # Battlefront Teleport -1347 3741 0 23 Magic 19 4070=3 Y 4 Battlefront Teleport +1347 3741 0 23 Magic 19 4070=3 Y 4 Battlefront Teleport EARTH_RUNE=1&&FIRE_RUNE=1&&LAW_RUNE=1 # Mind Altar Teleport -2976 3509 0 28 Magic 19 4070=3 Y 10 Mind Altar Teleport +2976 3509 0 28 Magic 19 4070=3 Y 10 Mind Altar Teleport LAW_RUNE=1&&MIND_RUNE=2 # Respawn Teleport # TODO: Figure out values of Varbit 668 # 34 Magic 19 4070=3 Y 10 Respawn Teleport # Salve Graveyard Teleport -3432 3459 0 40 Magic Priest in Peril 19 4070=3 Y 10 Salve Graveyard Teleport +3432 3459 0 40 Magic Priest in Peril 19 4070=3 Y 10 Salve Graveyard Teleport LAW_RUNE=1&&SOUL_RUNE=2 # Fenkenstrain's Castle Teleport -3548 3529 0 48 Magic Priest in Peril 19 4070=3 Y 10 Fenkenstrain's Castle Teleport +3548 3529 0 48 Magic Priest in Peril 19 4070=3 Y 10 Fenkenstrain's Castle Teleport EARTH_RUNE=1&&LAW_RUNE=1&&SOUL_RUNE=1 # West Ardougne Teleport -2500 3290 0 61 Magic Biohazard 19 4070=3 Y 10 West Ardougne Teleport +2500 3290 0 61 Magic Biohazard 19 4070=3 Y 10 West Ardougne Teleport LAW_RUNE=2&&SOUL_RUNE=2 # Harmony Island Teleport -3796 2866 0 65 Magic The Great Brain Robbery 19 4070=3 Y 10 Harmony Island Teleport +3796 2866 0 65 Magic The Great Brain Robbery 19 4070=3 Y 10 Harmony Island Teleport LAW_RUNE=1&&NATURE_RUNE=1&&SOUL_RUNE=1 # Cemetery Teleport -2980 3762 0 71 Magic 20 4070=3 Y 10 Cemetery Teleport +2980 3762 0 71 Magic 20 4070=3 Y 10 Cemetery Teleport BLOOD_RUNE=1&&LAW_RUNE=1&&SOUL_RUNE=1 # Barrows Teleport -3563 3314 0 83 Magic 20 4070=3 Y 10 Barrows Teleport +3563 3314 0 83 Magic 20 4070=3 Y 10 Barrows Teleport BLOOD_RUNE=1&&LAW_RUNE=2&&SOUL_RUNE=2 # Ape Atoll Teleport -2768 2702 0 90 Magic Monkey Madness I 20 4070=3 Y 10 Ape Atoll Teleport +2768 2702 0 90 Magic Monkey Madness I 20 4070=3 Y 10 Ape Atoll Teleport BLOOD_RUNE=2&&LAW_RUNE=2&&SOUL_RUNE=2 diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/transports.tsv b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/transports.tsv index 9592412a7f5..d5fdc6401f8 100644 --- a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/transports.tsv +++ b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/transports.tsv @@ -952,7 +952,6 @@ 3267 3379 0 3269 3379 1 Climb-up;Ladder;11794 3267 3379 1 3267 3379 0 Climb-down;Ladder;11795 3237 9858 0 3236 3458 0 Climb-up;Ladder;11806 -3236 3458 0 3237 9858 0 Open;Manhole;881 2 3236 3458 0 3237 9858 0 Climb-down;Manhole;882 2 3210 9899 0 3210 9898 0 Slash;Web;733 8 3210 9898 0 3210 9899 0 Slash;Web;733 8 @@ -1606,18 +1605,12 @@ 2636 9517 0 2636 9510 2 Walk-up;Stairs;21725 2636 9510 2 2636 9517 0 Walk-down;Stairs;21726 # Stepping Stones -2647 9557 0 2649 9562 0 Jump-from;Stepping stone;21739 -2649 9562 0 2647 9557 0 Jump-from;Stepping stone;21738 2695 9533 0 2697 9525 0 Cross;Stepping stone;19040 2697 9525 0 2695 9533 0 Cross;Stepping stone;19040 2690 9547 0 2682 9548 0 Cross;Stepping stone;19040 2682 9548 0 2690 9547 0 Cross;Stepping stone;19040 # Log Balance -2682 9506 0 2687 9506 0 Walk-across;Log balance;20882 -2687 9506 0 2682 9506 0 Walk-across;Log balance;20884 # Pipes -2698 9500 0 2698 9492 0 Squeeze-through;Pipe;21727 -2698 9492 0 2698 9500 0 Squeeze-through;Pipe;21727 2655 9573 0 2655 9566 0 Squeeze-through;Pipe;21728 2655 9566 0 2655 9573 0 Squeeze-through;Pipe;21728 # Crevice @@ -2178,12 +2171,8 @@ 3611 3323 1 3611 3327 0 Climb-down;Staircase;39492 3599 3317 0 3603 3317 1 Climb-up;Staircase;39491 3603 3317 1 3599 3317 0 Climb-down;Staircase;39492 -3667 3375 0 3670 3375 0 Climb;Wall;39542 -3670 3375 0 3667 3375 0 Climb;Wall;39542 -3670 3375 0 3673 3375 0 Climb;Wall;39541 -3673 3375 0 3670 3375 0 Climb;Wall;39541 -3672 3376 0 3670 3375 0 Climb;Wall;39541 -3672 3374 0 3670 3375 0 Climb;Wall;39541 +3672 3376 0 3670 3375 0 Climb;Wall;39541 +3672 3374 0 3670 3375 0 Climb;Wall;39541 # The Hollows 3480 9837 0 3480 9836 0 Search;Wall;5052 @@ -2259,8 +2248,6 @@ 3809 9801 1 3809 9797 2 Climb-up;Stairs;37835 # Ectofunctus -3672 3376 0 3670 3375 0 Climb;Wall;39541 -3672 3374 0 3670 3375 0 Climb;Wall;39541 3666 3517 0 3666 3522 1 Climb-up;Staircase;16646 3666 3522 1 3666 3517 0 Climb-down;Staircase;16647 3667 3517 0 3667 3522 1 Climb-up;Staircase;16646 @@ -2366,14 +2353,6 @@ # Slayer Tower 3417 3536 0 3412 9932 3 Climb-down;Ladder;30191 3412 9932 3 3417 3536 0 Climb-up;Ladder;30192 -3421 3550 0 3421 3550 1 Climb-up;Spikey chain;16537 -3422 3551 0 3422 3551 1 Climb-up;Spikey chain;16537 -3423 3550 0 3423 3550 1 Climb-up;Spikey chain;16537 -3422 3549 0 3422 3549 1 Climb-up;Spikey chain;16537 -3421 3550 1 3421 3550 0 Climb-down;Spikey chain;16538 -3422 3551 1 3422 3551 0 Climb-down;Spikey chain;16538 -3422 3549 1 3422 3549 0 Climb-down;Spikey chain;16538 -3423 3550 1 3423 3550 0 Climb-down;Spikey chain;16538 3427 3555 1 3427 3556 1 Open;Door;2104 3427 3556 1 3427 3555 1 Open;Door;2104 3426 3555 1 3426 3556 1 Open;Door;2102 @@ -4374,9 +4353,7 @@ 2880 3593 0 2880 3596 0 Climb;Rocks;3723 2881 3596 0 2881 3593 0 Climb;Rocks;3722 2881 3593 0 2881 3596 0 Climb;Rocks;3723 -2857 3611 0 2857 3613 0 Climb;Rocks;3748 15 Agility 3105 2857 3613 0 2857 3611 0 Climb;Rocks;3748 15 Agility -2856 3611 0 2856 3613 0 Climb;Rocks;3748 15 Agility 3105 2856 3613 0 2856 3611 0 Climb;Rocks;3748 15 Agility 2858 3627 0 2861 3627 0 Climb;Rocks;3790 15 Agility Troll Stronghold 2861 3627 0 2858 3627 0 Climb;Rocks;3791 15 Agility Troll Stronghold @@ -4410,8 +4387,7 @@ 2834 3629 0 2834 3627 0 Climb;Rocks;3748 2833 3627 0 2833 3629 0 Climb;Rocks;3748 2833 3629 0 2833 3627 0 Climb;Rocks;3748 -2822 3635 0 2820 3635 0 Climb;Rocks;3748 -2820 3635 0 2822 3635 0 Climb;Rocks;3748 +2822 3635 0 2820 3635 0 Climb;Rocks;3748 2827 3646 0 2823 10050 0 Open;Secret Door;3762 2823 10050 0 2827 3646 0 Open;Exit;3761 2828 3646 0 2823 10050 0 Open;Secret Door;3762 @@ -4634,12 +4610,6 @@ 2836 3257 0 2833 9658 0 Enter;Hole;25154 1 2998 3032 1 3001 3032 0 Cross;Gangplank;17397 3001 3032 0 2998 3032 1 Cross;Gangplank;17396 -2795 2978 0 2791 2978 0 Climb;Rocks;2231 3 -2791 2978 0 2795 2978 0 Climb;Rocks;2231 3 -2795 2979 0 2791 2979 0 Climb;Rocks;2231 3 -2791 2979 0 2795 2979 0 Climb;Rocks;2231 3 -2795 2980 0 2791 2980 0 Climb;Rocks;2231 3 -2791 2980 0 2795 2980 0 Climb;Rocks;2231 3 2871 2970 0 2871 2970 1 Climb-up;Ladder;16683 2871 2970 1 2871 2970 0 Climb-down;Ladder;16679 2871 2972 0 2871 2972 1 Climb-up;Ladder;16683 @@ -4701,8 +4671,6 @@ 2736 3580 0 2736 3580 1 Climb-up;Staircase;25682 # Lumbridge basement / cavern -3219 9618 0 3221 9618 0 Squeeze-through;Hole;6898 The Lost Tribe -3221 9618 0 3219 9618 0 Squeeze-through;Hole;6899 The Lost Tribe 3224 9604 0 3224 9600 0 Squeeze-through;Hole;6912 3224 9600 0 3224 9604 0 Squeeze-through;Hole;6912 3226 9542 0 3219 9532 2 Enter;Tunnel;6659 @@ -4919,80 +4887,10 @@ 2142 3122 0 2142 3125 1 Cross;Gangplank;17408 # Isafdar Forest -2188 3162 0 2188 3165 0 Enter;Dense forest;3999 -2188 3165 0 2188 3168 0 Enter;Dense forest;3939 -2188 3168 0 2188 3171 0 Enter;Dense forest;3998 -2188 3165 0 2188 3162 0 Enter;Dense forest;3999 -2188 3168 0 2188 3165 0 Enter;Dense forest;3939 -2188 3171 0 2188 3168 0 Enter;Dense forest;3998 -2199 3169 0 2202 3169 0 Pass;Sticks;3922 -2202 3169 0 2199 3169 0 Pass;Sticks;3922 -2217 3169 0 2217 3166 0 Enter;Dense forest;3939 -2217 3166 0 2217 3163 0 Enter;Dense forest;3938 -2217 3163 0 2217 3160 0 Enter;Dense forest;3939 -2217 3166 0 2217 3169 0 Enter;Dense forest;3939 -2217 3163 0 2217 3166 0 Enter;Dense forest;3938 -2217 3160 0 2217 3163 0 Enter;Dense forest;3939 -2215 3156 0 2215 3153 0 Step-over;Tripwire;3921 -2220 3155 0 2220 3152 0 Step-over;Tripwire;3921 -2215 3153 0 2215 3156 0 Step-over;Tripwire;3921 -2220 3152 0 2220 3155 0 Step-over;Tripwire;3921 -2231 3149 0 2234 3149 0 Enter;Dense forest;3937 -2234 3149 0 2237 3149 0 Enter;Dense forest;3938 -2237 3149 0 2240 3149 0 Enter;Dense forest;3939 -2234 3149 0 2231 3149 0 Enter;Dense forest;3937 -2237 3149 0 2234 3149 0 Enter;Dense forest;3938 -2240 3149 0 2237 3149 0 Enter;Dense forest;3939 -2274 3172 0 2274 3176 0 Jump;Leaves;3925 -2274 3176 0 2274 3172 0 Jump;Leaves;3925 -2234 3181 0 2238 3181 0 Pass;Sticks;3922 -2238 3181 0 2234 3181 0 Pass;Sticks;3922 -2238 3219 0 2235 3219 0 Enter;Dense forest;3939 -2235 3219 0 2232 3219 0 Enter;Dense forest;3937 -2232 3219 0 2229 3219 0 Enter;Dense forest;3939 -2229 3219 0 2226 3219 0 Enter;Dense forest;3938 -2235 3219 0 2238 3219 0 Enter;Dense forest;3939 -2232 3219 0 2235 3219 0 Enter;Dense forest;3937 -2229 3219 0 2232 3219 0 Enter;Dense forest;3939 -2226 3219 0 2229 3219 0 Enter;Dense forest;3938 -2274 3192 0 2271 3192 0 Enter;Dense forest;3937 -2271 3192 0 2268 3192 0 Enter;Dense forest;3939 -2268 3192 0 2265 3192 0 Enter;Dense forest;3938 -2271 3192 0 2274 3192 0 Enter;Dense forest;3937 -2268 3192 0 2271 3192 0 Enter;Dense forest;3939 -2265 3192 0 2268 3192 0 Enter;Dense forest;3938 -2267 3201 0 2267 3205 0 Jump;Leaves;3925 -2267 3205 0 2267 3201 0 Jump;Leaves;3925 -2284 3188 0 2287 3188 0 Step-over;Tripwire;3921 -2287 3188 0 2284 3188 0 Step-over;Tripwire;3921 2306 3194 0 2304 3194 0 Pass;Tree;8742 2306 3195 0 2304 3195 0 Pass;Tree;8742 2304 3194 0 2306 3195 0 Pass;Tree;8742 2304 3195 0 2306 3195 0 Pass;Tree;8742 -2202 3237 0 2196 3237 0 Cross;Log balance;3931 -2196 3237 0 2202 3237 0 Cross;Log balance;3931 -2303 3213 0 2303 3216 0 Enter;Dense forest;3937 -2303 3216 0 2303 3219 0 Enter;Dense forest;3938 -2303 3219 0 2303 3222 0 Enter;Dense forest;3939 -2303 3222 0 2303 3225 0 Enter;Dense forest;3938 -2303 3216 0 2303 3213 0 Enter;Dense forest;3937 -2303 3219 0 2303 3216 0 Enter;Dense forest;3938 -2303 3222 0 2303 3219 0 Enter;Dense forest;3939 -2303 3225 0 2303 3222 0 Enter;Dense forest;3938 -2290 3232 0 2290 3239 0 Cross;Log balance;3933 -2290 3239 0 2290 3232 0 Cross;Log balance;3933 -2294 3242 0 2294 3245 0 Step-over;Tripwire;3921 -2294 3245 0 2294 3242 0 Step-over;Tripwire;3921 -2295 3215 0 2295 3217 0 Pass;Sticks;3922 -2295 3217 0 2295 3215 0 Pass;Sticks;3922 -2279 3221 0 2279 3225 0 Enter;Dense forest;3938 -2279 3225 0 2279 3228 0 Enter;Dense forest;3937 -2279 3228 0 2279 3231 0 Enter;Dense forest;3939 -2279 3225 0 2279 3221 0 Enter;Dense forest;3938 -2279 3228 0 2279 3225 0 Enter;Dense forest;3937 -2279 3231 0 2279 3228 0 Enter;Dense forest;3939 -2264 3250 0 2258 3250 0 Cross;Log balance;3932 -2258 3250 0 2264 3250 0 Cross;Log balance;3932 2312 3216 0 2314 9624 0 Enter;Cave entrance;4006 2314 9624 0 2312 3216 0 Leave;Cave exit;4007 2385 3335 0 2385 3333 0 Enter;Huge Gate;3945 @@ -5976,6 +5874,12 @@ 3317 9612 0 3245 9648 0 Watermill;Mistag;7299 Death to the Dorgeshuun 8 Watermill 3246 9646 0 3313 9613 0 Mines;Dartog;7301 Death to the Dorgeshuun 8 Mines 3246 9646 0 3232 9610 0 Cellar;Dartog;997 Death to the Dorgeshuun 8 Cellar + +# Elemental Workshop odd-looking wall. The steel key ring is not sufficient evidence that the +# battered key is stored on it, so these rows deliberately accept only the concrete key item. +2709 3495 0 2709 3496 0 Open;Odd-looking wall;26115 2887=1 2 +2709 3496 0 2709 3495 0 Open;Odd-looking wall;26115 2887=1 2 + 2709 3498 0 2716 9888 0 Climb-down;Staircase;3415 1 2716 9888 0 2709 3497 0 Climb-up;Staircase;3416 1 2370 3621 0 2371 3620 0 Climb-over;Stile;19222 5 @@ -6075,3 +5979,17 @@ 2893 9907 0 2893 3507 0 Climb-up;Ladder;17387 1 3026 3511 1 3025 3511 1 Open;Sturdy door;2339 3025 3511 1 3026 3511 1 Open;Sturdy door;2339 + +# Barrows mounds and individual crypt exits (surface destinations are representative mound anchors) +3564 3291 0 3559 9703 3 Dig;Barrow;0 952=1 Y 3 Ahrim's Barrow +3575 3299 0 3558 9718 3 Dig;Barrow;0 952=1 Y 3 Dharok's Barrow +3578 3281 0 3534 9706 3 Dig;Barrow;0 952=1 Y 3 Guthan's Barrow +3567 3274 0 3546 9686 3 Dig;Barrow;0 952=1 Y 3 Karil's Barrow +3553 3281 0 3566 9683 3 Dig;Barrow;0 952=1 Y 3 Torag's Barrow +3556 3297 0 3578 9704 3 Dig;Barrow;0 952=1 Y 3 Verac's Barrow +3559 9703 3 3564 3291 0 Climb-up;Staircase;20667 Y 1 Ahrim's Barrow exit +3558 9718 3 3575 3299 0 Climb-up;Staircase;20668 Y 1 Dharok's Barrow exit +3534 9706 3 3578 3281 0 Climb-up;Staircase;20669 Y 1 Guthan's Barrow exit +3546 9686 3 3567 3274 0 Climb-up;Staircase;20670 Y 1 Karil's Barrow exit +3566 9683 3 3553 3281 0 Climb-up;Staircase;20671 Y 1 Torag's Barrow exit +3578 9704 3 3556 3297 0 Climb-up;Staircase;20672 Y 1 Verac's Barrow exit diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/handler/WalkerShadowHandlerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/handler/WalkerShadowHandlerTest.java new file mode 100644 index 00000000000..e5b78343e7c --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/agentserver/handler/WalkerShadowHandlerTest.java @@ -0,0 +1,58 @@ +package net.runelite.client.plugins.microbot.agentserver.handler; + +import com.google.gson.Gson; +import org.junit.Test; + +import java.util.Map; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class WalkerShadowHandlerTest +{ + @Test + public void snapshotIsCoordinateFreeAndContainsCoverageOutcomes() + { + Map snapshot = WalkerShadowHandler.snapshot(); + String json = new Gson().toJson(snapshot); + + assertTrue(snapshot.containsKey("enabled")); + assertTrue(snapshot.containsKey("plannerMode")); + assertTrue(snapshot.get("schemaVersion").equals(2)); + assertTrue(snapshot.get("candidateEngineId").toString() + .startsWith("shortest-path-upstream@")); + assertTrue(snapshot.containsKey("totals")); + @SuppressWarnings("unchecked") + Map totals = (Map) snapshot.get("totals"); + assertTrue(totals.containsKey("upstreamCanarySelections")); + assertTrue(totals.containsKey("localFallbackDivergences")); + assertTrue(totals.containsKey("localFallbackFailures")); + assertTrue(snapshot.containsKey("coverage")); + assertTrue(snapshot.containsKey("transportExecutors")); + assertTrue(snapshot.containsKey("transportTypes")); + assertTrue(snapshot.containsKey("execution")); + assertTrue(snapshot.containsKey("canaryPerformance")); + @SuppressWarnings("unchecked") + Map canaryPerformance = + (Map) snapshot.get("canaryPerformance"); + assertTrue(canaryPerformance.containsKey("planningSamples")); + assertTrue(canaryPerformance.containsKey("planningNanosTotal")); + assertTrue(canaryPerformance.containsKey("planningNanosMax")); + assertTrue(canaryPerformance.containsKey("localSearchNanosTotal")); + assertTrue(canaryPerformance.containsKey("upstreamSearchSamples")); + assertTrue(canaryPerformance.containsKey("upstreamSearchNanosTotal")); + assertTrue(snapshot.containsKey("latest")); + assertTrue(snapshot.containsKey("latestRouteShapeDifference")); + assertTrue(snapshot.containsKey("latestDivergence")); + assertTrue(snapshot.containsKey("latestFailure")); + assertTrue(json.contains("SURFACE_COORDINATES_ONLY")); + assertTrue(json.contains("UNDERGROUND_COORDINATES")); + assertTrue(json.contains("RECOVERY_REPLAN")); + assertTrue(json.contains("LIVE_COLLISION_CONSULTED")); + assertTrue(json.contains("TERMINAL_TRAVEL")); + assertTrue(json.contains("recoveryArrived")); + assertFalse(json.contains("\"start\"")); + assertFalse(json.contains("\"target\"")); + assertFalse(json.contains("\"path\"")); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java index 374a5e0cf6c..e3d0eab7077 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java @@ -252,6 +252,7 @@ public void noOverlay_readsIdenticalToStaticMap() { assertEquals(plain.isBlocked(x, y, 0), withEmptyOverlay.isBlocked(x, y, 0)); } } + assertEquals(0L, withEmptyOverlay.getLiveEdgeQueries()); } @Test @@ -287,12 +288,19 @@ public void overlayBlocksAnOpenStaticEdge_andFallsBackOutsideScene() { // overlay wins inside the scene assertTrue("precondition: static edge open", staticMap.n(tx, ty, 0)); assertFalse("overlay must block the edge", live.n(tx, ty, 0)); + assertEquals(1L, live.getLiveEdgeQueries()); // a tile far outside the snapshot falls back to the static map int farX = baseX + 5000; int farY = baseY + 5000; assertEquals(staticMap.n(farX, farY, 0), live.n(farX, farY, 0)); assertEquals(staticMap.e(farX, farY, 0), live.e(farX, farY, 0)); + assertEquals("static fallback must not count as live evidence", 1L, + live.getLiveEdgeQueries()); + + live.beginSearch(); + assertEquals("a new search resets the live evidence counter", 0L, + live.getLiveEdgeQueries()); } // ---- Stage 3: route validation (LiveRouteValidator) ---- diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathCoreTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathCoreTest.java index 0e2e448ca33..a21232ce325 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathCoreTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathCoreTest.java @@ -1,6 +1,7 @@ package net.runelite.client.plugins.microbot.shortestpath; import net.runelite.api.Quest; +import net.runelite.api.QuestState; import net.runelite.api.VarPlayer; import net.runelite.api.coords.WorldArea; import net.runelite.api.coords.WorldPoint; @@ -335,17 +336,472 @@ public void testNewTransportTypesLoaded() { } @Test - public void testLumbridgeHomeTeleportTransportLoaded() { - Transport transport = getLumbridgeHomeTeleportTransport(); + public void testMinigameTeleportsUseCurrentLandingsAndSpecialRequirements() { + Set teleports = Transport.loadAllFromResources() + .getOrDefault(null, Collections.emptySet()); + + Transport guardians = findTeleport(teleports, "Guardians of the Rift"); + assertEquals("Guardians teleport should land inside the Temple of the Eye", + new WorldPoint(3614, 9477, 0), guardians.getDestination()); + + Transport keldagrimRatPits = findTeleport(teleports, "Rat Pits: Keldagrim"); + assertEquals(new WorldPoint(2914, 10193, 0), keldagrimRatPits.getDestination()); + Transport varrockRatPits = findTeleport(teleports, "Rat Pits: Varrock"); + assertEquals(new WorldPoint(3262, 3405, 0), varrockRatPits.getDestination()); + + Transport pestControl = findTeleport(teleports, "Pest Control"); + assertEquals("Pest Control teleport should retain its 40 combat gate", + 40, pestControl.getRequiredCombatLevel()); + } - assertTrue("Lumbridge Home Teleport should stay gated to the standard spellbook", - transport.getVarbits().stream().anyMatch(v -> v.getVarbitId() == 4070 && v.getValue() == 0)); - assertFalse("Lumbridge Home Teleport should not depend on the buff-display disabled varbit", - transport.getVarbits().stream().anyMatch(v -> v.getVarbitId() == 12353)); - assertTrue("Lumbridge Home Teleport should be gated by LAST_HOME_TELEPORT cooldown", - transport.getVarplayers().stream().anyMatch(v -> v.getVarplayerId() == VarPlayer.LAST_HOME_TELEPORT - && v.getOperator() == TransportVarPlayer.Operator.COOLDOWN_MINUTES - && v.getValue() == 30)); + @Test + public void testTransportParserSupportsUpstreamSpecialLevelRequirements() { + Map fields = new HashMap<>(); + fields.put("Destination", "1 2 0"); + fields.put("Skills", "2376 Total;40 Combat;327 Quest points"); + Transport transport = new Transport(fields, TransportType.TELEPORTATION_ITEM); + + assertEquals(2376, transport.getRequiredTotalLevel()); + assertEquals(40, transport.getRequiredCombatLevel()); + assertEquals(327, transport.getRequiredQuestPoints()); + } + + @Test + public void testDirectMaxCapeAndQuestCapeImportPreservesRequirementsAndDestinations() { + Set teleports = Transport.loadAllFromResources() + .getOrDefault(null, Collections.emptySet()); + + List directMaxCape = new ArrayList<>(); + for (Transport transport : teleports) { + if (transport.getType() == TransportType.TELEPORTATION_ITEM + && transport.getDisplayInfo() != null + && transport.getDisplayInfo().startsWith("Max cape:") + && !transport.getDisplayInfo().equals("Max cape: Home")) { + directMaxCape.add(transport); + } + } + + assertEquals("The reviewed direct Max-cape family should contain every upstream destination", + 17, directMaxCape.size()); + Set routeIdentities = new HashSet<>(); + for (Transport transport : directMaxCape) { + assertEquals(2376, transport.getRequiredTotalLevel()); + assertEquals(20, transport.getMaxWildernessLevel()); + assertEquals(1, transport.getItemRequirements().size()); + assertEquals(Set.of(13280, 13342), transport.getItemRequirements().get(0).getItemIds()); + assertTrue("Duplicate Max-cape route: " + transport.getDisplayInfo(), + routeIdentities.add(transport.getDestination() + "|" + transport.getDisplayInfo())); + } + + Transport hunterGuild = findItemTeleport(teleports, + "Max cape: Other Teleports: Hunter Guild"); + assertEquals(new WorldPoint(1558, 3046, 0), hunterGuild.getDestination()); + Transport pandemonium = findItemTeleport(teleports, + "Max cape: Other Teleports: The Pandemonium"); + assertEquals(new WorldPoint(3048, 2972, 0), pandemonium.getDestination()); + + Transport questCape = findItemTeleport(teleports, "Quest point cape: Teleport"); + assertEquals(new WorldPoint(2729, 3348, 0), questCape.getDestination()); + assertEquals(327, questCape.getRequiredQuestPoints()); + assertEquals(20, questCape.getMaxWildernessLevel()); + assertEquals(Set.of(9813, 13068), questCape.getItemRequirements().get(0).getItemIds()); + } + + @Test + public void testQuetzalNetworkAndWhistleFamilyMatchReviewedUpstream() { + HashMap> transports = Transport.loadAllFromResources(); + WorldPoint aldarin = new WorldPoint(1389, 2901, 0); + WorldPoint quetzacalli = new WorldPoint(1510, 3222, 0); + WorldPoint oldQuetzacalli = new WorldPoint(1510, 3221, 0); + WorldPoint camTorum = new WorldPoint(1446, 3108, 0); + + assertFalse("the obsolete one-tile-off Quetzacalli origin must be gone", + transports.getOrDefault(oldQuetzacalli, Collections.emptySet()).stream() + .anyMatch(transport -> transport.getType() == TransportType.QUETZAL)); + Transport aldarinToCamTorum = transports.getOrDefault(aldarin, Collections.emptySet()).stream() + .filter(transport -> transport.getType() == TransportType.QUETZAL) + .filter(transport -> camTorum.equals(transport.getDestination())) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing Aldarin -> Cam Torum quetzal route")); + assertEquals("Travel", aldarinToCamTorum.getAction()); + assertEquals("Renu", aldarinToCamTorum.getName()); + assertEquals(13350, aldarinToCamTorum.getObjectId()); + assertEquals("Cam Torum", aldarinToCamTorum.getDisplayInfo()); + assertTrue(aldarinToCamTorum.getVarplayers().stream().anyMatch(requirement -> + requirement.getVarplayerId() == 4182 + && requirement.getOperator() == TransportVarPlayer.Operator.BIT_SET + && requirement.getValue() == 32)); + assertTrue("the corrected Quetzacalli origin must participate in the network", + transports.getOrDefault(quetzacalli, Collections.emptySet()).stream() + .anyMatch(transport -> transport.getType() == TransportType.QUETZAL)); + + List whistles = transports.getOrDefault(null, Collections.emptySet()).stream() + .filter(transport -> transport.getType() == TransportType.TELEPORTATION_ITEM) + .filter(transport -> transport.getDisplayInfo() != null + && transport.getDisplayInfo().startsWith("Quetzal whistle:")) + .collect(java.util.stream.Collectors.toList()); + assertEquals("every whistle destination needs charged and permanent variants", 28, whistles.size()); + Set whistleVariants = new HashSet<>(); + for (Transport whistle : whistles) { + Set itemIds = whistle.getItemRequirements().get(0).getItemIds(); + if (whistle.isConsumable()) { + assertEquals(Set.of(29271, 29273, 29275), itemIds); + } else { + assertEquals(Set.of(33120), itemIds); + } + assertEquals(QuestState.FINISHED, whistle.getQuests().get(Quest.TWILIGHTS_PROMISE)); + assertEquals(20, whistle.getMaxWildernessLevel()); + assertTrue("duplicate whistle policy variant: " + whistle.getDisplayInfo(), + whistleVariants.add(whistle.getDisplayInfo() + "|" + whistle.isConsumable())); + assertFalse("obsolete executor label must not survive", + whistle.getDisplayInfo().contains("Cam Torum Entrance")); + } + assertEquals("each destination must have one charged and one permanent variant", + 28, whistleVariants.size()); + Transport quetzacalliWhistle = whistles.stream() + .filter(transport -> "Quetzal whistle: Quetzacalli Gorge".equals(transport.getDisplayInfo())) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing Quetzacalli whistle destination")); + assertEquals(quetzacalli, quetzacalliWhistle.getDestination()); + } + + @Test + public void testBothCanoeChainsUsePinnedAxeCollectionAndUpstreamCosts() { + HashMap> transports = Transport.loadAllFromResources(); + Set riverLumOrigins = Set.of( + new WorldPoint(3132, 3510, 0), + new WorldPoint(3112, 3411, 0), + new WorldPoint(3202, 3343, 0), + new WorldPoint(3243, 3237, 0), + new WorldPoint(3154, 3630, 0)); + Set riverDougneOrigins = Set.of( + new WorldPoint(2439, 3135, 0), + new WorldPoint(2485, 3192, 0), + new WorldPoint(2579, 3260, 0), + new WorldPoint(2573, 3358, 0), + new WorldPoint(2525, 3408, 0)); + Set supportedOrigins = new HashSet<>(riverLumOrigins); + supportedOrigins.addAll(riverDougneOrigins); + List canoes = supportedOrigins.stream() + .flatMap(origin -> transports.getOrDefault(origin, Collections.emptySet()).stream()) + .filter(transport -> transport.getType() == TransportType.CANOE) + .collect(java.util.stream.Collectors.toList()); + + assertEquals("both supported canoe chains must retain all reviewed upstream routes", 45, canoes.size()); + for (Transport canoe : canoes) { + assertEquals("Paddle Canoe", canoe.getAction()); + assertEquals("Canoe Station", canoe.getName()); + assertTrue(canoe.getDuration() == 20 || canoe.getDuration() == 30); + assertEquals(1, canoe.getItemRequirements().size()); + Set axes = canoe.getItemRequirements().get(0).getItemIds(); + assertEquals(12, axes.size()); + assertTrue(axes.contains(net.runelite.api.gameval.ItemID.BRONZE_AXE)); + assertTrue(axes.contains(net.runelite.api.gameval.ItemID.CRYSTAL_AXE)); + } + List dougneCanoes = canoes.stream() + .filter(transport -> transport.getObjectId() >= 60845 && transport.getObjectId() <= 60849) + .collect(java.util.stream.Collectors.toList()); + assertEquals("River Dougne has four destinations from each of five stations", 20, dougneCanoes.size()); + assertEquals(Set.of(60845, 60846, 60847, 60848, 60849), dougneCanoes.stream() + .map(Transport::getObjectId) + .collect(java.util.stream.Collectors.toSet())); + } + + @Test + public void testGrappleShortcutsRequireCrossbowAndMithGrapple() { + Set reviewedGrappleObjects = Set.of(17042, 17047, 17049, 17050, 17062, 17068, 17074); + List grappleShortcuts = Transport.loadAllFromResources().values().stream() + .flatMap(Collection::stream) + .filter(transport -> transport.getType() == TransportType.GRAPPLE_SHORTCUT) + .filter(transport -> reviewedGrappleObjects.contains(transport.getObjectId())) + .collect(java.util.stream.Collectors.toList()); + + assertEquals("every reviewed grapple edge must retain the upstream equipment pair", + 12, grappleShortcuts.size()); + for (Transport grapple : grappleShortcuts) { + assertEquals("crossbow and grapple are independent AND requirements: " + grapple, + 2, grapple.getItemRequirements().size()); + assertTrue("a usable crossbow family is required: " + grapple, + grapple.getItemRequirements().stream().anyMatch(requirement -> + requirement.getItemIds().contains(net.runelite.api.gameval.ItemID.CROSSBOW) + && requirement.getItemIds().contains(net.runelite.api.gameval.ItemID.ZARYTE_XBOW))); + assertTrue("the mith grapple is required separately: " + grapple, + grapple.getItemRequirements().stream().anyMatch(requirement -> + requirement.getItemIds().equals(Set.of( + net.runelite.api.gameval.ItemID.XBOWS_GRAPPLE_TIP_BOLT_MITHRIL_ROPE)))); + } + } + + @Test + public void testTrollheimRopeShortcutRetainsItemAndUnlockVarbit() { + WorldPoint origin = new WorldPoint(2766, 3665, 0); + Transport rope = Transport.loadAllFromResources().getOrDefault(origin, Collections.emptySet()).stream() + .filter(transport -> new WorldPoint(2766, 3663, 0).equals(transport.getDestination())) + .filter(transport -> transport.getObjectId() == 5842) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing Trollheim rope shortcut")); + + assertEquals(1, rope.getItemRequirements().size()); + assertEquals(Set.of(net.runelite.api.gameval.ItemID.ROPE), + rope.getItemRequirements().get(0).getItemIds()); + assertTrue("shortcut is available only after the rope has been attached", + rope.getVarbits().stream().anyMatch(requirement -> requirement.getVarbitId() == 260 + && requirement.getOperator() == TransportVarbit.Operator.GREATER_THAN + && requirement.getValue() == 0)); + assertEquals(10, rope.getDuration()); + } + + @Test + public void testTrollheimClimbingRockAscentsRequireBootsButDescentsDoNot() { + HashMap> transports = Transport.loadAllFromResources(); + Map ascents = Map.of( + new WorldPoint(2820, 3635, 0), new WorldPoint(2822, 3635, 0), + new WorldPoint(2856, 3611, 0), new WorldPoint(2856, 3613, 0), + new WorldPoint(2857, 3611, 0), new WorldPoint(2857, 3613, 0)); + + for (Map.Entry edge : ascents.entrySet()) { + Transport ascent = transports.getOrDefault(edge.getKey(), Collections.emptySet()).stream() + .filter(transport -> edge.getValue().equals(transport.getDestination())) + .filter(transport -> transport.getObjectId() == 3748) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing Trollheim ascent: " + edge)); + assertEquals(TransportType.AGILITY_SHORTCUT, ascent.getType()); + assertEquals(1, ascent.getItemRequirements().size()); + assertEquals(Set.of( + net.runelite.api.gameval.ItemID.DEATH_CLIMBINGBOOTS, + net.runelite.api.gameval.ItemID.CLIMBING_BOOTS_G), + ascent.getItemRequirements().get(0).getItemIds()); + + Transport descent = transports.getOrDefault(edge.getValue(), Collections.emptySet()).stream() + .filter(transport -> edge.getKey().equals(transport.getDestination())) + .filter(transport -> transport.getObjectId() == 3748) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing unrestricted Trollheim descent: " + edge)); + assertEquals(TransportType.TRANSPORT, descent.getType()); + assertTrue(descent.getItemRequirements().isEmpty()); + } + } + + @Test + public void testIsafdarForestObstaclesRetainAgilityAndDurationRequirements() { + Set forestObjectIds = Set.of( + 3921, 3922, 3925, 3931, 3932, 3933, 3937, 3938, 3939, 3998, 3999); + Map requiredAgility = Map.ofEntries( + Map.entry(3921, 1), + Map.entry(3922, 1), + Map.entry(3925, 1), + Map.entry(3931, 45), + Map.entry(3932, 45), + Map.entry(3933, 45), + Map.entry(3937, 56), + Map.entry(3938, 56), + Map.entry(3939, 56), + Map.entry(3998, 56), + Map.entry(3999, 56)); + Map expectedDuration = Map.ofEntries( + Map.entry(3921, 8), + Map.entry(3922, 6), + Map.entry(3925, 4), + Map.entry(3931, 8), + Map.entry(3932, 8), + Map.entry(3933, 9), + Map.entry(3937, 4), + Map.entry(3938, 4), + Map.entry(3939, 4), + Map.entry(3998, 4), + Map.entry(3999, 4)); + + HashMap> transports = Transport.loadAllFromResources(); + List forestShortcuts = transports.values().stream() + .flatMap(Collection::stream) + .filter(transport -> forestObjectIds.contains(transport.getObjectId())) + .filter(transport -> transport.getOrigin() != null + && transport.getOrigin().getX() >= 2100 && transport.getOrigin().getX() <= 2310 + && transport.getOrigin().getY() >= 3100 && transport.getOrigin().getY() <= 3300) + .collect(java.util.stream.Collectors.toList()); + + assertEquals("the complete reviewed Isafdar obstacle family must be loaded", 88, + forestShortcuts.size()); + for (Transport shortcut : forestShortcuts) { + assertEquals("forest obstacles must not bypass the agility toggle or level gate: " + shortcut, + TransportType.AGILITY_SHORTCUT, shortcut.getType()); + assertEquals("wrong Agility requirement for object " + shortcut.getObjectId(), + requiredAgility.get(shortcut.getObjectId()).intValue(), + shortcut.getSkillLevels()[net.runelite.api.Skill.AGILITY.ordinal()]); + assertEquals("wrong traversal duration for object " + shortcut.getObjectId(), + expectedDuration.get(shortcut.getObjectId()).intValue(), shortcut.getDuration()); + } + + assertTrue("current stick landing must replace the stale 2295,3215 origin", + transports.getOrDefault(new WorldPoint(2295, 3213, 0), Collections.emptySet()).stream() + .anyMatch(transport -> transport.getObjectId() == 3922 + && new WorldPoint(2295, 3217, 0).equals(transport.getDestination()))); + assertFalse("stale stick landing must not remain as a generic transport", + transports.getOrDefault(new WorldPoint(2295, 3215, 0), Collections.emptySet()).stream() + .anyMatch(transport -> transport.getObjectId() == 3922)); + assertTrue("current dense-forest landing must replace the stale 2279,3221 origin", + transports.getOrDefault(new WorldPoint(2279, 3222, 0), Collections.emptySet()).stream() + .anyMatch(transport -> transport.getObjectId() == 3938 + && new WorldPoint(2279, 3225, 0).equals(transport.getDestination()))); + assertFalse("stale dense-forest landing must not remain as a generic transport", + transports.getOrDefault(new WorldPoint(2279, 3221, 0), Collections.emptySet()).stream() + .anyMatch(transport -> transport.getObjectId() == 3938)); + } + + @Test + public void testConvertedGenericShortcutFamiliesRetainUpstreamRequirements() { + Set reviewedObjects = Set.of( + 21727, 21738, 21739, 20882, 20884, // Brimhaven Dungeon + 6905, // Lumbridge cellar + 2231, // Karamja rocks + 16537, 16538, // Slayer Tower ground floor + 39541, 39542); // Darkmeyer walls + Map expectedAgility = Map.ofEntries( + Map.entry(21727, 1), + Map.entry(21738, 1), + Map.entry(21739, 1), + Map.entry(20882, 1), + Map.entry(20884, 1), + Map.entry(6905, 13), + Map.entry(2231, 15), + Map.entry(16537, 61), + Map.entry(16538, 61), + Map.entry(39541, 63), + Map.entry(39542, 63)); + Map expectedDuration = Map.ofEntries( + Map.entry(21727, 13), + Map.entry(21738, 7), + Map.entry(21739, 7), + Map.entry(20882, 7), + Map.entry(20884, 7), + Map.entry(6905, 3), + Map.entry(2231, 5), + Map.entry(16537, 0), + Map.entry(16538, 0), + Map.entry(39541, 0), + Map.entry(39542, 0)); + + HashMap> transports = Transport.loadAllFromResources(); + java.util.function.Predicate reviewedFamily = transport -> { + WorldPoint origin = transport.getOrigin(); + if (origin == null || !reviewedObjects.contains(transport.getObjectId())) { + return false; + } + int objectId = transport.getObjectId(); + if (objectId == 16537 || objectId == 16538) { + return origin.getX() >= 3421 && origin.getX() <= 3423 + && origin.getY() >= 3549 && origin.getY() <= 3551; + } + return true; + }; + List shortcuts = transports.values().stream() + .flatMap(Collection::stream) + .filter(reviewedFamily) + .filter(transport -> transport.getType() == TransportType.AGILITY_SHORTCUT) + .collect(java.util.stream.Collectors.toList()); + + assertEquals("all 28 reviewed generic edges must become agility shortcuts", 28, shortcuts.size()); + for (Transport shortcut : shortcuts) { + assertEquals("wrong Agility level for " + shortcut, + expectedAgility.get(shortcut.getObjectId()).intValue(), + shortcut.getSkillLevels()[net.runelite.api.Skill.AGILITY.ordinal()]); + assertEquals("wrong traversal duration for " + shortcut, + expectedDuration.get(shortcut.getObjectId()).intValue(), shortcut.getDuration()); + } + + List cellar = shortcuts.stream() + .filter(transport -> transport.getObjectId() == 6905) + .collect(java.util.stream.Collectors.toList()); + assertEquals(2, cellar.size()); + assertTrue("Lumbridge cellar hole must use the quest-progress varbit, not a completion-only wall", + cellar.stream().allMatch(transport -> transport.getVarbits().stream().anyMatch(requirement -> + requirement.getVarbitId() == 532 + && requirement.getOperator() == TransportVarbit.Operator.GREATER_THAN + && requirement.getValue() == 3))); + assertFalse("obsolete Lost Tribe wall objects must not survive the representation change", + transports.values().stream().flatMap(Collection::stream) + .anyMatch(transport -> transport.getObjectId() == 6898 || transport.getObjectId() == 6899)); + + assertTrue("west Darkmeyer wall must retain its unlock varbit", + shortcuts.stream().filter(transport -> transport.getObjectId() == 39542) + .allMatch(transport -> transport.getVarbits().stream().anyMatch(requirement -> + requirement.getVarbitId() == 10449 + && requirement.getOperator() == TransportVarbit.Operator.EQUAL + && requirement.getValue() == 1))); + assertTrue("east Darkmeyer wall must retain its unlock varbit", + shortcuts.stream().filter(transport -> transport.getObjectId() == 39541) + .allMatch(transport -> transport.getVarbits().stream().anyMatch(requirement -> + requirement.getVarbitId() == 10450 + && requirement.getOperator() == TransportVarbit.Operator.EQUAL + && requirement.getValue() == 1))); + + Set genericReviewedEdges = transports.values().stream() + .flatMap(Collection::stream) + .filter(reviewedFamily) + .filter(transport -> transport.getType() == TransportType.TRANSPORT) + .map(transport -> transport.getOrigin() + " -> " + transport.getDestination()) + .collect(java.util.stream.Collectors.toSet()); + assertEquals("only upstream's two intentional Darkmeyer diagonal generic approaches may remain", + Set.of( + new WorldPoint(3672, 3376, 0) + " -> " + new WorldPoint(3670, 3375, 0), + new WorldPoint(3672, 3374, 0) + " -> " + new WorldPoint(3670, 3375, 0)), + genericReviewedEdges); + } + + private static Transport findTeleport(Set teleports, String displayInfo) { + return teleports.stream() + .filter(transport -> transport.getType() == TransportType.TELEPORTATION_MINIGAME) + .filter(transport -> displayInfo.equals(transport.getDisplayInfo())) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing minigame teleport: " + displayInfo)); + } + + private static Transport findItemTeleport(Set teleports, String displayInfo) { + return teleports.stream() + .filter(transport -> transport.getType() == TransportType.TELEPORTATION_ITEM) + .filter(transport -> displayInfo.equals(transport.getDisplayInfo())) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing item teleport: " + displayInfo)); + } + + @Test + public void testLovakengjMinecartsRespectForsakenTowerUnlock() { + HashMap> transports = Transport.loadAllFromResources(); + WorldPoint arceuusOrigin = new WorldPoint(1670, 3832, 0); + WorldPoint farmingGuildDestination = new WorldPoint(1218, 3737, 0); + Set atArceuus = transports.getOrDefault(arceuusOrigin, Collections.emptySet()); + + boolean paidBeforeUnlock = false; + boolean freeAfterUnlock = false; + boolean ungatedVariant = false; + for (Transport transport : atArceuus) { + if (transport.getType() != TransportType.MINECART + || !farmingGuildDestination.equals(transport.getDestination())) { + continue; + } + boolean beforeUnlock = transport.getVarbits().stream().anyMatch(v -> v.getVarbitId() == 7796 + && v.getOperator() == TransportVarbit.Operator.LESS_THAN && v.getValue() == 11); + boolean afterUnlock = transport.getVarbits().stream().anyMatch(v -> v.getVarbitId() == 7796 + && v.getOperator() == TransportVarbit.Operator.EQUAL && v.getValue() == 11); + paidBeforeUnlock |= beforeUnlock && !afterUnlock + && transport.getCurrencyAmount() == 20 && "Coins".equals(transport.getCurrencyName()); + freeAfterUnlock |= afterUnlock && !beforeUnlock && transport.getCurrencyAmount() == 0; + ungatedVariant |= !beforeUnlock && !afterUnlock; + } + + assertTrue("Arceuus minecart should cost 20 coins before The Forsaken Tower unlock", paidBeforeUnlock); + assertTrue("Arceuus minecart should be free after The Forsaken Tower unlock", freeAfterUnlock); + assertFalse("Lovakengj minecart routes must not have an ungated fare variant", ungatedVariant); + } + + @Test + public void testAllSpellbookHomeTeleportTransportsLoaded() { + assertHomeTeleport("Lumbridge Home Teleport", new WorldPoint(3221, 3218, 0), 0, null, false); + assertHomeTeleport("Edgeville Home Teleport", new WorldPoint(3087, 3504, 0), 1, + Quest.DESERT_TREASURE_I, true); + assertHomeTeleport("Lunar Home Teleport", new WorldPoint(2113, 3915, 0), 2, + Quest.LUNAR_DIPLOMACY, true); + assertHomeTeleport("Arceuus Home Teleport", new WorldPoint(1700, 3882, 0), 3, null, true); } @Test @@ -366,17 +822,42 @@ public void testLumbridgeHomeTeleportCooldownRejectsRecentUse() { } private static Transport getLumbridgeHomeTeleportTransport() { + return getHomeTeleportTransport("Lumbridge Home Teleport", new WorldPoint(3221, 3218, 0)); + } + + private static void assertHomeTeleport(String displayInfo, WorldPoint destination, int spellbook, + Quest requiredQuest, boolean members) { + Transport transport = getHomeTeleportTransport(displayInfo, destination); + + assertEquals(displayInfo + " should have exactly one spellbook requirement", + 1, transport.getVarbits().size()); + assertTrue(displayInfo + " should require spellbook " + spellbook, + transport.getVarbits().stream().anyMatch(v -> v.getVarbitId() == 4070 && v.getValue() == spellbook)); + assertTrue(displayInfo + " should be gated by LAST_HOME_TELEPORT cooldown", + transport.getVarplayers().stream().anyMatch(v -> v.getVarplayerId() == VarPlayer.LAST_HOME_TELEPORT + && v.getOperator() == TransportVarPlayer.Operator.COOLDOWN_MINUTES + && v.getValue() == 30)); + assertEquals(displayInfo + " membership requirement", members, transport.isMembers()); + if (requiredQuest == null) { + assertTrue(displayInfo + " should not have a quest requirement", transport.getQuests().isEmpty()); + } else { + assertEquals(displayInfo + " quest requirement", QuestState.FINISHED, + transport.getQuests().get(requiredQuest)); + } + } + + private static Transport getHomeTeleportTransport(String displayInfo, WorldPoint destination) { HashMap> transports = Transport.loadAllFromResources(); - Optional lumbridgeHomeTeleport = transports.values().stream() + Optional homeTeleport = transports.values().stream() .flatMap(Set::stream) .filter(t -> t.getType() == TransportType.TELEPORTATION_SPELL - && "Lumbridge Home Teleport".equals(t.getDisplayInfo()) - && new WorldPoint(3221, 3218, 0).equals(t.getDestination())) + && displayInfo.equals(t.getDisplayInfo()) + && destination.equals(t.getDestination())) .findFirst(); - assertTrue("Lumbridge Home Teleport should be loaded", lumbridgeHomeTeleport.isPresent()); - return lumbridgeHomeTeleport.get(); + assertTrue(displayInfo + " should be loaded", homeTeleport.isPresent()); + return homeTeleport.get(); } private static void assertTollGateTransport(HashMap> transports, @@ -762,6 +1243,23 @@ public void testVarrockSewerPathAvoidsDisabledPalaceTrellisShortcut() { endpoint.distanceTo(dst) <= 1); } + @Test + public void testVarrockSewerManholeCatalogContainsOnlyTheTraversingEdge() { + WorldPoint origin = new WorldPoint(3236, 3458, 0); + WorldPoint destination = new WorldPoint(3237, 9858, 0); + List manholeEdges = Transport.loadAllFromResources() + .getOrDefault(origin, Collections.emptySet()).stream() + .filter(transport -> destination.equals(transport.getDestination())) + .collect(java.util.stream.Collectors.toList()); + + assertEquals("Opening the cover is object state preparation, not a traversing graph edge", + 1, manholeEdges.size()); + Transport manhole = manholeEdges.get(0); + assertEquals("Climb-down", manhole.getAction()); + assertEquals("Manhole", manhole.getName()); + assertEquals(882, manhole.getObjectId()); + } + @Test public void testVarrockSewerPathAvoidsPalaceGardenSouthFenceCollisionGap() { PathfinderConfig config = createConfigWithUnavailableShortcutEdges(TransportType.AGILITY_SHORTCUT); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/TransportExecutionRegistryTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/TransportExecutionRegistryTest.java new file mode 100644 index 00000000000..694c34a5d08 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/TransportExecutionRegistryTest.java @@ -0,0 +1,196 @@ +package net.runelite.client.plugins.microbot.shortestpath; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.ItemID; +import org.junit.Test; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class TransportExecutionRegistryTest +{ + @Test + public void objectExecutorRequiresAnExecutableInteraction() + { + WorldPoint origin = new WorldPoint(3200, 3200, 0); + WorldPoint destination = new WorldPoint(3200, 3200, 1); + Transport executable = new Transport( + origin, destination, "Upstairs", TransportType.TRANSPORT, false, + "Climb-up", "Staircase", 16671); + Transport missingObject = new Transport( + origin, destination, "Upstairs", TransportType.TRANSPORT, false, 1); + + assertEquals(TransportExecutionRegistry.Executor.OBJECT, + TransportExecutionRegistry.executorFor(executable).orElse(null)); + assertFalse(TransportExecutionRegistry.canExecute(missingObject)); + } + + @Test + public void barrowsDigExecutorRequiresAnExactMoundMappingAndSpade() + { + WorldPoint origin = new WorldPoint(3564, 3291, 0); + WorldPoint destination = new WorldPoint(3559, 9703, 3); + Transport valid = new Transport( + origin, destination, "Ahrim's Barrow", TransportType.TRANSPORT, true, + "Dig", "Barrow", 0, 3); + valid.setItemIdRequirements(Set.of(Set.of(ItemID.SPADE))); + + assertEquals(TransportExecutionRegistry.Executor.BARROWS_DIG, + TransportExecutionRegistry.executorFor(valid).orElse(null)); + + Transport wrongDestination = new Transport( + origin, new WorldPoint(3558, 9718, 3), "Wrong crypt", TransportType.TRANSPORT, true, + "Dig", "Barrow", 0, 3); + wrongDestination.setItemIdRequirements(Set.of(Set.of(ItemID.SPADE))); + Transport missingSpade = new Transport( + origin, destination, "Missing spade", TransportType.TRANSPORT, true, + "Dig", "Barrow", 0, 3); + + assertFalse(TransportExecutionRegistry.canExecute(wrongDestination)); + assertFalse(TransportExecutionRegistry.canExecute(missingSpade)); + } + + @Test + public void spellExecutorMatchesTheActualMagicActionCatalog() + { + assertTrue(TransportExecutionRegistry.canExecute(spell("Lumbridge Home Teleport"))); + assertTrue(TransportExecutionRegistry.canExecute(spell("Edgeville Home Teleport"))); + assertTrue(TransportExecutionRegistry.canExecute(spell("Lunar Home Teleport"))); + assertTrue(TransportExecutionRegistry.canExecute(spell("Arceuus Home Teleport"))); + assertTrue(TransportExecutionRegistry.canExecute(spell("Varrock Teleport: Grand Exchange"))); + assertFalse(TransportExecutionRegistry.canExecute(spell("Unknown Home Teleport"))); + } + + @Test + public void homeTeleportMappingIsExactAndSharedWithExecution() + { + for (TransportExecutionRegistry.HomeTeleport homeTeleport + : TransportExecutionRegistry.HomeTeleport.values()) + { + assertEquals(homeTeleport, + TransportExecutionRegistry.homeTeleportFor(homeTeleport.getDisplayName()).orElse(null)); + assertEquals(homeTeleport, + TransportExecutionRegistry.homeTeleportFor( + " " + homeTeleport.getDisplayName().toUpperCase(Locale.ROOT) + " ").orElse(null)); + } + assertFalse(TransportExecutionRegistry.homeTeleportFor("Lumbridge Home Teleport: Fake").isPresent()); + } + + @Test + public void balloonExecutorRequiresAnExactKnownNetworkRow() + { + WorldPoint origin = new WorldPoint(2461, 3111, 0); + WorldPoint destination = new WorldPoint(3299, 3482, 0); + Transport valid = new Transport( + origin, destination, "Varrock", TransportType.HOT_AIR_BALLOON, true, + "Use", "Basket", 19129, 7); + assertEquals(TransportExecutionRegistry.Executor.HOT_AIR_BALLOON, + TransportExecutionRegistry.executorFor(valid).orElse(null)); + + Transport unknownDestination = new Transport( + origin, destination, "Unknown", TransportType.HOT_AIR_BALLOON, true, + "Use", "Basket", 19129, 7); + Transport unknownObject = new Transport( + origin, destination, "Varrock", TransportType.HOT_AIR_BALLOON, true, + "Use", "Basket", 99999, 7); + assertFalse(TransportExecutionRegistry.canExecute(unknownDestination)); + assertFalse(TransportExecutionRegistry.canExecute(unknownObject)); + } + + @Test + public void terminalTravelExecutorDoesNotAssumeTheCatalogTargetIsAnNpc() + { + WorldPoint origin = new WorldPoint(3271, 3144, 0); + WorldPoint destination = new WorldPoint(3148, 2843, 0); + for (TransportType type : List.of(TransportType.SHIP, TransportType.NPC, TransportType.BOAT)) + { + Transport transport = new Transport( + origin, destination, "", type, true, + "Board", "Ferry", 41311, 8); + assertEquals(TransportExecutionRegistry.Executor.TERMINAL_TRAVEL, + TransportExecutionRegistry.executorFor(transport).orElse(null)); + assertEquals(TransportExecutionRegistry.TerminalTravelMode.DIRECT, + TransportExecutionRegistry.terminalTravelModeFor(transport).orElse(null)); + } + } + + @Test + public void terminalTravelModesFailClosedForUnimplementedDestinationSelection() + { + WorldPoint origin = new WorldPoint(1342, 3645, 0); + Transport multiDestinationBoat = new Transport( + origin, new WorldPoint(1408, 3612, 0), "Shayzien", TransportType.BOAT, true, + "Board", "Boaty", 33614, 5); + Transport mountainGuide = new Transport( + new WorldPoint(1277, 3558, 0), new WorldPoint(1401, 3536, 0), + "The Shayzien Outpost", TransportType.NPC, true, + "Travel", "Mountain Guide", 24190, 4); + + assertFalse(TransportExecutionRegistry.canExecute(multiDestinationBoat)); + assertFalse(TransportExecutionRegistry.terminalTravelModeFor(multiDestinationBoat).isPresent()); + assertEquals(TransportExecutionRegistry.TerminalTravelMode.DIALOGUE_DESTINATION, + TransportExecutionRegistry.terminalTravelModeFor(mountainGuide).orElse(null)); + assertEquals(TransportExecutionRegistry.Executor.TERMINAL_TRAVEL, + TransportExecutionRegistry.executorFor(mountainGuide).orElse(null)); + } + + @Test + public void resourceCatalogHasOnlyExplicitTerminalExecutionDebt() + { + Map> catalog = Transport.loadAllFromResources(); + List unsupported = catalog.values().stream() + .flatMap(Set::stream) + .filter(transport -> !TransportExecutionRegistry.canExecute(transport)) + .collect(Collectors.toList()); + + assertEquals("only explicitly audited terminal rows may remain fail-closed: " + describe(unsupported), + 41, unsupported.size()); + assertTrue("non-terminal execution debt: " + describe(unsupported), + unsupported.stream().allMatch(transport -> + transport.getType() == TransportType.SHIP + || transport.getType() == TransportType.NPC + || transport.getType() == TransportType.BOAT)); + Map debtByInteraction = unsupported.stream().collect(Collectors.groupingBy( + transport -> transport.getType() + ":" + transport.getAction() + ":" + transport.getName(), + Collectors.counting())); + assertEquals(Map.of( + "BOAT:Board:Boat", 18L, + "BOAT:Board:Boaty", 12L, + "BOAT:Talk-to:Pirate Pete", 2L, + "BOAT:Travel:Rowboat", 6L, + "SHIP:Talk-to:Captain Shanks", 3L), debtByInteraction); + assertEquals("all teleport spells must have a registered executor", + 0L, unsupported.stream() + .filter(transport -> transport.getType() == TransportType.TELEPORTATION_SPELL) + .count()); + assertEquals("every expanded hot-air-balloon edge must use the dedicated executor", + 225L, catalog.values().stream() + .flatMap(Set::stream) + .filter(transport -> transport.getType() == TransportType.HOT_AIR_BALLOON) + .filter(transport -> TransportExecutionRegistry.executorFor(transport) + .orElse(null) == TransportExecutionRegistry.Executor.HOT_AIR_BALLOON) + .count()); + } + + private static Transport spell(String displayInfo) + { + return new Transport( + null, new WorldPoint(3200, 3200, 0), displayInfo, + TransportType.TELEPORTATION_SPELL, false, 1); + } + + private static String describe(List transports) + { + return transports.stream() + .map(transport -> transport.getType() + ":" + transport.getDisplayInfo() + + "@" + transport.getOrigin() + "->" + transport.getDestination()) + .collect(Collectors.joining(", ")); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/TransportItemRequirementTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/TransportItemRequirementTest.java new file mode 100644 index 00000000000..497796d1360 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/TransportItemRequirementTest.java @@ -0,0 +1,201 @@ +package net.runelite.client.plugins.microbot.shortestpath; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.ItemID; +import org.junit.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.fail; +import static org.junit.Assert.assertTrue; + +public class TransportItemRequirementTest { + @Test + public void numericUpstreamGrammarPreservesAndOrAndUsesUpstreamMaximumQuantity() { + List requirements = + TransportItemRequirement.parseNumericRequirements("100=2||101=3&&200=1"); + + assertEquals(2, requirements.size()); + assertEquals(3, requirements.get(0).getRequiredQuantity(100)); + assertEquals(3, requirements.get(0).getRequiredQuantity(101)); + assertEquals(Set.of(100, 101), requirements.get(0).getItemIds()); + assertEquals(Set.of(200), requirements.get(1).getItemIds()); + + Map available = new HashMap<>(); + available.put(100, 3); + available.put(200, 1); + assertTrue(requirements.stream().allMatch( + requirement -> requirement.isSatisfiedBy(id -> available.getOrDefault(id, 0)))); + + available.put(200, 0); + assertFalse(requirements.stream().allMatch( + requirement -> requirement.isSatisfiedBy(id -> available.getOrDefault(id, 0)))); + } + + @Test + public void legacySemicolonIdsRemainOneAlternativeRequirement() { + Map fields = new HashMap<>(); + fields.put("Destination", "1 2 0"); + fields.put("Item IDs", "3853;3855;3857"); + + Transport transport = new Transport(fields, TransportType.TELEPORTATION_ITEM); + + assertEquals(1, transport.getItemRequirements().size()); + assertEquals(Set.of(3853, 3855, 3857), transport.getItemRequirements().get(0).getItemIds()); + assertTrue(transport.getItemRequirements().get(0).isSatisfiedBy(id -> id == 3855 ? 1 : 0)); + } + + @Test + public void transportAcceptsNumericUpstreamGrammarInCompatibilityColumn() { + Map fields = new HashMap<>(); + fields.put("Destination", "1 2 0"); + fields.put("Item IDs", "13280=1||13342=1&&995=20"); + + Transport transport = new Transport(fields, TransportType.TELEPORTATION_ITEM); + + assertEquals(2, transport.getItemRequirements().size()); + assertEquals(Set.of(13280, 13342), transport.getItemRequirements().get(0).getItemIds()); + assertEquals(20, transport.getItemRequirements().get(1).getRequiredQuantity(995)); + } + + @Test + public void transportAcceptsUpstreamInteractionAndVarPlayersGrammar() { + Map fields = new HashMap<>(); + fields.put("Origin", "1 2 0"); + fields.put("Destination", "3 4 0"); + fields.put("menuOption menuTarget objectID", "Travel Renu 13350"); + fields.put("VarPlayers", "4182&32"); + + Transport transport = new Transport(fields, TransportType.QUETZAL); + + assertEquals("Travel", transport.getAction()); + assertEquals("Renu", transport.getName()); + assertEquals(13350, transport.getObjectId()); + assertEquals(1, transport.getVarplayers().size()); + TransportVarPlayer varplayer = transport.getVarplayers().iterator().next(); + assertEquals(4182, varplayer.getVarplayerId()); + assertEquals(32, varplayer.getValue()); + assertEquals(TransportVarPlayer.Operator.BIT_SET, + varplayer.getOperator()); + } + + @Test + public void supportedSymbolicCollectionsExpandWithoutFlatteningAndGroups() { + List requirements = + TransportItemRequirement.parseRequirements("CROSSBOW=1&MITH_GRAPPLE=1"); + + assertEquals(2, requirements.size()); + assertTrue(requirements.get(0).getItemIds().contains(ItemID.CROSSBOW)); + assertTrue(requirements.get(0).getItemIds().contains(ItemID.ZARYTE_XBOW)); + assertEquals(20, requirements.get(0).getItemIds().size()); + assertEquals(Set.of(ItemID.XBOWS_GRAPPLE_TIP_BOLT_MITHRIL_ROPE), + requirements.get(1).getItemIds()); + } + + @Test + public void transportAcceptsPinnedAxeCollectionFromUpstreamItemsColumn() { + Map fields = new HashMap<>(); + fields.put("Origin", "1 2 0"); + fields.put("Destination", "3 4 0"); + fields.put("Items", "AXE=1"); + + Transport transport = new Transport(fields, TransportType.CANOE); + + assertEquals(1, transport.getItemRequirements().size()); + Set axes = transport.getItemRequirements().get(0).getItemIds(); + assertEquals(12, axes.size()); + assertTrue(axes.contains(ItemID.BRONZE_AXE)); + assertTrue(axes.contains(ItemID.CRYSTAL_AXE)); + assertTrue(axes.contains(ItemID._3A_AXE)); + } + + @Test + public void runeCollectionRetainsComboRuneAndEquipmentProviders() { + TransportItemRequirement requirement = + TransportItemRequirement.parseRequirements("AIR_RUNE=3").get(0); + + assertEquals(3, requirement.getRequiredQuantity(ItemID.AIRRUNE)); + assertEquals(3, requirement.getRequiredQuantity(ItemID.MISTRUNE)); + assertEquals(3, requirement.getRequiredQuantity(ItemID.DUSTRUNE)); + assertEquals(3, requirement.getRequiredQuantity(ItemID.SMOKERUNE)); + assertTrue(requirement.getStaffAlternatives().contains(ItemID.STAFF_OF_AIR)); + assertTrue(requirement.getStaffAlternatives().contains(ItemID.SHADOWFLAME_QUADRANT)); + assertTrue(requirement.getOffhandAlternatives().isEmpty()); + assertTrue(requirement.isRuneOnly()); + } + + @Test + public void oneCombinationStaffCanSatisfyMultipleRuneClauses() { + List requirements = + TransportItemRequirement.parseRequirements("FIRE_RUNE=2&WATER_RUNE=2"); + + TransportItemRequirement.ProviderSelection selection = + TransportItemRequirement.selectProviders( + requirements, + ignored -> 0, + itemId -> itemId == ItemID.TWINFLAME_STAFF, + ignored -> false) + .orElseThrow(() -> new AssertionError("Twinflame staff should satisfy both clauses")); + + assertEquals(ItemID.TWINFLAME_STAFF, selection.getStaffItemId()); + assertFalse(selection.hasOffhand()); + } + + @Test + public void unequippedStaffIsNotMistakenForOrdinaryRuneQuantity() { + List requirements = + TransportItemRequirement.parseRequirements("FIRE_RUNE=2"); + + assertFalse(TransportItemRequirement.selectProviders( + requirements, + itemId -> itemId == ItemID.STAFF_OF_FIRE ? 1 : 0, + ignored -> false, + ignored -> false).isPresent()); + assertTrue(TransportItemRequirement.selectProviders( + requirements, + itemId -> itemId == ItemID.STAFF_OF_FIRE ? 1 : 0, + itemId -> itemId == ItemID.STAFF_OF_FIRE, + ignored -> false).isPresent()); + } + + @Test + public void unsupportedSlotCollectionStillFailsClosed() { + try { + TransportItemRequirement.parseRequirements("CAPESLOT=1"); + fail("slot requirements need explicit equipment-slot semantics"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("unresolved symbolic")); + } + } + + @Test + public void mergedTransportRequiresBothEndpointRequirementGroups() { + Transport origin = new Transport( + new WorldPoint(1, 2, 0), "origin", TransportType.TRANSPORT, true, 19, + Set.of(Set.of(10, 11))); + Transport destination = new Transport( + new WorldPoint(3, 4, 0), "destination", TransportType.TRANSPORT, true, 19, + Set.of(Set.of(20))); + + Transport merged = new Transport(origin, destination); + + assertEquals(2, merged.getItemRequirements().size()); + assertEquals(Set.of(10, 11), merged.getItemRequirements().get(0).getItemIds()); + assertEquals(Set.of(20), merged.getItemRequirements().get(1).getItemIds()); + } + + @Test + public void unresolvedSymbolicItemsFailClosed() { + try { + TransportItemRequirement.parseNumericRequirements("COINS=20"); + fail("symbolic item names must be resolved by the schema adapter"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("unresolved symbolic")); + } + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/WalkerRouteCorpusTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/WalkerRouteCorpusTest.java index e9544ec8d99..3f74635127a 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/WalkerRouteCorpusTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/WalkerRouteCorpusTest.java @@ -1,8 +1,10 @@ package net.runelite.client.plugins.microbot.shortestpath; import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.ItemID; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathfinderConfig; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathEdge; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.SplitFlagMap; import org.junit.BeforeClass; import org.junit.Test; @@ -18,6 +20,7 @@ import java.util.function.Predicate; import java.util.stream.Collectors; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -96,11 +99,25 @@ private static PathfinderConfig configWith(Predicate allow) { return config; } - private static List route(PathfinderConfig config, WorldPoint from, WorldPoint to) { + private static Pathfinder runPathfinder(PathfinderConfig config, WorldPoint from, WorldPoint to) { Pathfinder pf = new Pathfinder(config, from, to); pf.run(); assertTrue("pathfinder did not complete for " + from + " -> " + to, pf.isDone()); - return pf.getPath(); + return pf; + } + + private static List route(PathfinderConfig config, WorldPoint from, WorldPoint to) { + return runPathfinder(config, from, to).getPath(); + } + + private static boolean selectsTransport(Pathfinder pathfinder, Predicate predicate) { + List edges = pathfinder.getPathEdges(); + return edges != null && edges.stream().anyMatch(edge -> + edge.getTransport() != null && predicate.test(edge.getTransport())); + } + + private static boolean selectsTransportObject(Pathfinder pathfinder, int objectId) { + return selectsTransport(pathfinder, transport -> transport.getObjectId() == objectId); } private static boolean arrives(List path, WorldPoint goal, int tolerance) { @@ -116,10 +133,74 @@ private static boolean visits(List path, WorldPoint tile, int radius p.getPlane() == tile.getPlane() && p.distanceTo2D(tile) <= radius); } + private static boolean usesTransportType(List path, TransportType type) { + if (path == null || path.size() < 2) { + return false; + } + for (int index = 0; index < path.size() - 1; index++) { + WorldPoint origin = path.get(index); + WorldPoint destination = path.get(index + 1); + if (allTransports.getOrDefault(origin, Collections.emptySet()).stream().anyMatch(transport -> + transport.getType() == type && destination.equals(transport.getDestination()))) { + return true; + } + } + return false; + } + + private static boolean usesTransportObject(List path, int objectId) { + if (path == null || path.size() < 2) { + return false; + } + for (int index = 0; index < path.size() - 1; index++) { + WorldPoint origin = path.get(index); + WorldPoint destination = path.get(index + 1); + if (allTransports.getOrDefault(origin, Collections.emptySet()).stream().anyMatch(transport -> + transport.getObjectId() == objectId && destination.equals(transport.getDestination()))) { + return true; + } + } + return false; + } + private static final WorldPoint LUMBRIDGE = new WorldPoint(3222, 3218, 0); // ---- baseline ---------------------------------------------------------------------------------- + @Test + public void standardApeAtollSpellRetainsSourceAwareRequirementsWithoutMovingItsLanding() { + WorldPoint reviewedLanding = new WorldPoint(2797, 2798, 1); + Transport teleport = allTransports.getOrDefault(null, Collections.emptySet()).stream() + .filter(transport -> transport.getType() == TransportType.TELEPORTATION_SPELL) + .filter(transport -> reviewedLanding.equals(transport.getDestination())) + .filter(transport -> "Ape Atoll Teleport".equals(transport.getDisplayInfo())) + .findFirst() + .orElseThrow(() -> new AssertionError("standard Ape Atoll spell row is missing")); + + assertEquals("the requirement import must not change the reviewed landing", + reviewedLanding, teleport.getDestination()); + assertEquals("fire, water, law and banana are separate AND-clauses", + 4, teleport.getItemRequirements().size()); + TransportItemRequirement fire = teleport.getItemRequirements().stream() + .filter(requirement -> requirement.getAlternatives().containsKey(ItemID.FIRERUNE)) + .findFirst() + .orElseThrow(() -> new AssertionError("fire-rune clause is missing")); + TransportItemRequirement water = teleport.getItemRequirements().stream() + .filter(requirement -> requirement.getAlternatives().containsKey(ItemID.WATERRUNE)) + .findFirst() + .orElseThrow(() -> new AssertionError("water-rune clause is missing")); + TransportItemRequirement banana = teleport.getItemRequirements().stream() + .filter(requirement -> requirement.getAlternatives().containsKey(ItemID.BANANA)) + .findFirst() + .orElseThrow(() -> new AssertionError("banana clause is missing")); + + assertTrue("one Twinflame staff must satisfy both elemental clauses", + fire.getStaffAlternatives().contains(ItemID.TWINFLAME_STAFF) + && water.getStaffAlternatives().contains(ItemID.TWINFLAME_STAFF)); + assertTrue("ordinary inventory items must not become equipment providers", + banana.getStaffAlternatives().isEmpty() && banana.getOffhandAlternatives().isEmpty()); + } + @Test public void lumbridgeToGrandExchange_plainWalkArrives() { List path = route(configWith(WalkerRouteCorpusTest::unrestricted), @@ -127,6 +208,248 @@ public void lumbridgeToGrandExchange_plainWalkArrives() { assertTrue("baseline overland route must arrive", arrives(path, new WorldPoint(3164, 3485, 0), 5)); } + @Test + public void quetzalNetworkUsesCurrentQuetzacalliLanding() { + WorldPoint aldarin = new WorldPoint(1389, 2901, 0); + WorldPoint quetzacalli = new WorldPoint(1510, 3222, 0); + List path = route(configWith(transport -> unrestricted(transport) + || transport.getType() == TransportType.QUETZAL), + aldarin, quetzacalli); + + assertTrue("quetzal route must arrive at the current Gorge landing", + arrives(path, quetzacalli, 1)); + assertTrue("the long Varlamore crossing must use the QUETZAL network", + usesTransportType(path, TransportType.QUETZAL)); + } + + @Test + public void riverDougneCanoeConnectsCastleWarsToTreeGnomeStronghold() { + WorldPoint castleWarsStation = new WorldPoint(2439, 3135, 0); + WorldPoint strongholdLanding = new WorldPoint(2523, 3408, 0); + List path = route(configWith(transport -> unrestricted(transport) + || transport.getType() == TransportType.CANOE), + castleWarsStation, strongholdLanding); + + assertTrue("River Dougne route must arrive at Tree Gnome Stronghold", + arrives(path, strongholdLanding, 1)); + assertTrue("the long western crossing must use the CANOE network", + usesTransportType(path, TransportType.CANOE)); + } + + @Test + public void lagunaAuroraeSpiritTreeHasTheCompleteReviewedOutboundPerimeter() { + Set reviewedOrigins = Set.of( + new WorldPoint(1201, 2788, 0), + new WorldPoint(1202, 2788, 0), + new WorldPoint(1201, 2787, 0), + new WorldPoint(1201, 2786, 0), + new WorldPoint(1204, 2786, 0), + new WorldPoint(1201, 2785, 0), + new WorldPoint(1202, 2785, 0), + new WorldPoint(1203, 2785, 0), + new WorldPoint(1204, 2785, 0)); + WorldPoint grandExchange = new WorldPoint(3185, 3508, 0); + + for (WorldPoint origin : reviewedOrigins) { + assertTrue("Laguna perimeter origin must offer the reviewed spirit-tree network: " + origin, + allTransports.getOrDefault(origin, Collections.emptySet()).stream() + .anyMatch(transport -> transport.getType() == TransportType.SPIRIT_TREE + && transport.getObjectId() == 26262 + && grandExchange.equals(transport.getDestination()))); + } + + WorldPoint pohSpiritTree = new WorldPoint(2007, 5700, 0); + assertFalse("POH spirit-tree execution is programmatic and must not be duplicated in static data", + allTransports.getOrDefault(pohSpiritTree, Collections.emptySet()).stream() + .anyMatch(transport -> transport.getType() == TransportType.SPIRIT_TREE)); + assertFalse("the static destination list must not duplicate the programmatic POH spirit tree", + allTransports.getOrDefault(null, Collections.emptySet()).stream() + .anyMatch(transport -> transport.getType() == TransportType.SPIRIT_TREE + && pohSpiritTree.equals(transport.getDestination()))); + + WorldPoint northWestApproach = new WorldPoint(1201, 2788, 0); + List path = route(configWith(transport -> unrestricted(transport) + || transport.getType() == TransportType.SPIRIT_TREE), + northWestApproach, grandExchange); + assertTrue("Laguna Aurorae must route outbound through its spirit tree", + arrives(path, grandExchange, 1)); + assertTrue("the selected Laguna edge must retain the current object id", + usesTransportObject(path, 26262)); + } + + @Test + public void elementalWorkshopWallUsesConcreteObjectAndFailsClosedForUnverifiedKeyring() { + WorldPoint south = new WorldPoint(2709, 3495, 0); + WorldPoint north = new WorldPoint(2709, 3496, 0); + Set endpoints = Set.of(south, north); + + for (WorldPoint origin : endpoints) { + WorldPoint destination = origin.equals(south) ? north : south; + Transport wall = allTransports.getOrDefault(origin, Collections.emptySet()).stream() + .filter(transport -> destination.equals(transport.getDestination())) + .filter(transport -> transport.getObjectId() == 26115) + .findFirst() + .orElseThrow(() -> new AssertionError( + "Elemental Workshop wall edge is missing: " + origin + " -> " + destination)); + + assertEquals("the reviewed wall action must remain explicit", "Open", wall.getAction()); + assertEquals("the wall has one OR-clause", 1, wall.getItemRequirements().size()); + assertEquals("only the concrete battered key is currently verifiable", + Collections.singleton(ItemID.ELEMENTAL_WORKSHOP_KEY), + wall.getItemRequirements().get(0).getItemIds()); + assertFalse("a steel key ring does not prove that it contains the battered key", + wall.getItemRequirements().get(0).getItemIds().contains(ItemID.FAVOUR_KEY_RING)); + } + + Predicate batteredKeyState = transport -> unrestricted(transport) + || transport.getItemRequirements().stream().allMatch(requirement -> + requirement.isSatisfiedBy(itemId -> + itemId == ItemID.ELEMENTAL_WORKSHOP_KEY ? 1 : 0)); + Pathfinder withKeyPathfinder = runPathfinder(configWith(batteredKeyState), south, north); + List withKey = withKeyPathfinder.getPath(); + assertTrue("the concrete battered key must unlock the direct wall crossing", + arrives(withKey, north, 0)); + assertTrue("the route must select the current Elemental Workshop wall object", + selectsTransportObject(withKeyPathfinder, 26115)); + + Predicate keyRingOnlyState = transport -> unrestricted(transport) + || transport.getItemRequirements().stream().allMatch(requirement -> + requirement.isSatisfiedBy(itemId -> itemId == ItemID.FAVOUR_KEY_RING ? 1 : 0)); + Pathfinder keyRingOnlyPathfinder = runPathfinder(configWith(keyRingOnlyState), south, north); + List keyRingOnly = keyRingOnlyPathfinder.getPath(); + assertFalse("an unverified key-ring state must not select the wall transport", + selectsTransportObject(keyRingOnlyPathfinder, 26115)); + } + + @Test + public void lumbridgeFarmFenceUsesCurrentOneTileLanding() { + WorldPoint south = new WorldPoint(3240, 3334, 0); + WorldPoint north = new WorldPoint(3240, 3335, 0); + List path = route(configWith(transport -> unrestricted(transport) + || transport.getObjectId() == 16518), + south, north); + + assertTrue("the current fence landing must be reachable", arrives(path, north, 0)); + assertTrue("crossing the closed fence must select the agility shortcut edge", + usesTransportType(path, TransportType.AGILITY_SHORTCUT)); + } + + @Test + public void northernVarlamoreRocksUseCurrentEightTileLanding() { + WorldPoint south = new WorldPoint(1324, 3777, 0); + WorldPoint north = new WorldPoint(1324, 3785, 0); + List path = route(configWith(transport -> unrestricted(transport) + || transport.getObjectId() == 34397), + south, north); + + assertTrue("the reviewed northern landing must be reachable", arrives(path, north, 0)); + assertTrue("the rock face must select the agility shortcut edge", + usesTransportType(path, TransportType.AGILITY_SHORTCUT)); + } + + @Test + public void trollheimClimbingRocksUseBootsGatedAscent() { + WorldPoint west = new WorldPoint(2820, 3635, 0); + WorldPoint east = new WorldPoint(2822, 3635, 0); + List path = route(configWith(transport -> unrestricted(transport) + || (transport.getType() == TransportType.AGILITY_SHORTCUT + && transport.getObjectId() == 3748)), + west, east); + + assertTrue("the climbing-rock landing must be reachable", arrives(path, east, 0)); + assertTrue("the ascent must use the boots-gated agility edge", + usesTransportType(path, TransportType.AGILITY_SHORTCUT)); + } + + @Test + public void isafdarDenseForestChainUsesReviewedShortcutLandings() { + WorldPoint south = new WorldPoint(2188, 3162, 0); + WorldPoint north = new WorldPoint(2188, 3171, 0); + Set chainObjectIds = Set.of(3939, 3998, 3999); + List path = route(configWith(transport -> unrestricted(transport) + || (transport.getType() == TransportType.AGILITY_SHORTCUT + && chainObjectIds.contains(transport.getObjectId()))), + south, north); + + assertTrue("the three-obstacle forest chain must reach its reviewed northern landing", + arrives(path, north, 0)); + assertTrue("the route must traverse the first dense-forest landing", + visits(path, new WorldPoint(2188, 3165, 0), 0)); + assertTrue("the route must traverse the second dense-forest landing", + visits(path, new WorldPoint(2188, 3168, 0), 0)); + assertTrue("the forest chain must use Agility-gated shortcut edges", + usesTransportType(path, TransportType.AGILITY_SHORTCUT)); + } + + @Test + public void brimhavenDungeonPipeUsesAgilityShortcut() { + WorldPoint south = new WorldPoint(2698, 9492, 0); + WorldPoint north = new WorldPoint(2698, 9500, 0); + List path = route(configWith(transport -> unrestricted(transport) + || transport.getObjectId() == 21727), south, north); + + assertTrue("Brimhaven pipe route must reach the reviewed landing", arrives(path, north, 0)); + assertTrue("Brimhaven pipe route must use object 21727", usesTransportObject(path, 21727)); + assertTrue("Brimhaven pipe must be represented as an agility shortcut", + usesTransportType(path, TransportType.AGILITY_SHORTCUT)); + } + + @Test + public void karamjaRocksUseAgilityShortcut() { + WorldPoint west = new WorldPoint(2791, 2978, 0); + WorldPoint east = new WorldPoint(2795, 2978, 0); + List path = route(configWith(transport -> unrestricted(transport) + || transport.getObjectId() == 2231), west, east); + + assertTrue("Karamja rocks route must reach the reviewed landing", arrives(path, east, 0)); + assertTrue("Karamja rocks route must use object 2231", usesTransportObject(path, 2231)); + assertTrue("Karamja rocks must be represented as an agility shortcut", + usesTransportType(path, TransportType.AGILITY_SHORTCUT)); + } + + @Test + public void lumbridgeCellarHoleUsesQuestProgressShortcut() { + WorldPoint west = new WorldPoint(3219, 9618, 0); + WorldPoint east = new WorldPoint(3221, 9618, 0); + List path = route(configWith(transport -> unrestricted(transport) + || transport.getObjectId() == 6905), west, east); + + assertTrue("Lumbridge cellar route must reach the reviewed hole landing", arrives(path, east, 0)); + assertTrue("Lumbridge cellar route must use hole object 6905", usesTransportObject(path, 6905)); + assertTrue("Lumbridge cellar hole must be represented as an agility shortcut", + usesTransportType(path, TransportType.AGILITY_SHORTCUT)); + } + + @Test + public void slayerTowerGroundFloorChainUsesAgilityShortcut() { + WorldPoint ground = new WorldPoint(3421, 3550, 0); + WorldPoint firstFloor = new WorldPoint(3421, 3550, 1); + List path = route(configWith(transport -> unrestricted(transport) + || transport.getObjectId() == 16537), ground, firstFloor); + + assertTrue("Slayer Tower chain must reach the first floor", arrives(path, firstFloor, 0)); + assertTrue("Slayer Tower route must use chain object 16537", usesTransportObject(path, 16537)); + assertTrue("Slayer Tower chain must be represented as an agility shortcut", + usesTransportType(path, TransportType.AGILITY_SHORTCUT)); + } + + @Test + public void darkmeyerWallChainUsesBothAgilityShortcuts() { + WorldPoint west = new WorldPoint(3667, 3375, 0); + WorldPoint middle = new WorldPoint(3670, 3375, 0); + WorldPoint east = new WorldPoint(3673, 3375, 0); + List path = route(configWith(transport -> unrestricted(transport) + || transport.getObjectId() == 39541 + || transport.getObjectId() == 39542), west, east); + + assertTrue("Darkmeyer wall chain must reach the eastern landing", arrives(path, east, 0)); + assertTrue("Darkmeyer wall chain must cross the middle landing", visits(path, middle, 0)); + assertTrue("Darkmeyer wall chain must use west wall object 39542", usesTransportObject(path, 39542)); + assertTrue("Darkmeyer wall chain must use east wall object 39541", usesTransportObject(path, 39541)); + assertTrue("Darkmeyer walls must be represented as agility shortcuts", + usesTransportType(path, TransportType.AGILITY_SHORTCUT)); + } + // ---- Falador-area farm (the walk-1 "hairpin" report — resolved: no bug) ------------------------ @Test @@ -154,6 +477,94 @@ public void ruinsOfUnkah_reachedViaTheFerry() { assertTrue("route to Ruins of Unkah must arrive", arrives(path, unkahBank, 5)); assertTrue("route to Ruins of Unkah must use the ferry landing", visits(path, new WorldPoint(3148, 2843, 0), 3)); + assertTrue("route to Ruins of Unkah must contain a BOAT transport edge", + usesTransportType(path, TransportType.BOAT)); + } + + @Test + public void portSarimToMusaPoint_usesShipAndGangplank() { + WorldPoint portSarim = new WorldPoint(3029, 3217, 0); + WorldPoint musaPoint = new WorldPoint(2956, 3146, 0); + List path = route(configWith(t -> unrestricted(t) + || (t.getType() == TransportType.SHIP + && t.getCurrencyAmount() == 30 + && (t.getQuests() == null || t.getQuests().isEmpty()))), + portSarim, musaPoint); + + assertTrue("30-coin ship route must arrive at Musa Point", arrives(path, musaPoint, 1)); + assertTrue("Port Sarim to Musa Point must contain a SHIP transport edge", + usesTransportType(path, TransportType.SHIP)); + assertTrue("Microbot's ship route must retain the Musa Point deck/gangplank transition", + visits(path, new WorldPoint(2956, 3143, 1), 0)); + } + + @Test + public void pandemoniumShipsAreQuestAndFareGatedDirectTerminalEdges() { + WorldPoint portSarim = new WorldPoint(3029, 3217, 0); + WorldPoint musaPoint = new WorldPoint(2956, 3146, 0); + WorldPoint pandemonium = new WorldPoint(3064, 3003, 0); + Object[][] reviewed = { + {portSarim, pandemonium, 14979, "The Pandemonium", "Pandemonium"}, + {pandemonium, portSarim, 8631, "Port Sarim", "Port Sarim"}, + {musaPoint, pandemonium, 14985, "The Pandemonium", "Pandemonium"}, + {pandemonium, musaPoint, 8631, "Musa Point", "Musa Point"} + }; + + for (Object[] expectation : reviewed) { + WorldPoint origin = (WorldPoint) expectation[0]; + WorldPoint destination = (WorldPoint) expectation[1]; + int npcId = (int) expectation[2]; + String action = (String) expectation[3]; + String display = (String) expectation[4]; + Transport ship = allTransports.getOrDefault(origin, Collections.emptySet()).stream() + .filter(transport -> destination.equals(transport.getDestination())) + .filter(transport -> transport.getType() == TransportType.SHIP) + .filter(transport -> transport.getObjectId() == npcId) + .findFirst() + .orElseThrow(() -> new AssertionError( + "Pandemonium ship edge is missing: " + origin + " -> " + destination)); + + assertEquals("the current NPC menu action must remain exact", action, ship.getAction()); + assertEquals("the network label must remain stable", display, ship.getDisplayInfo()); + assertEquals("all four routes charge the reviewed fare", 30, ship.getCurrencyAmount()); + assertFalse("the Pandemonium quest gate must not be dropped", ship.getQuests().isEmpty()); + assertEquals(TransportExecutionRegistry.Executor.TERMINAL_TRAVEL, + TransportExecutionRegistry.executorFor(ship).orElse(null)); + assertEquals(TransportExecutionRegistry.TerminalTravelMode.DIRECT, + TransportExecutionRegistry.terminalTravelModeFor(ship).orElse(null)); + } + + Predicate pandemoniumShip = transport -> transport.getType() == TransportType.SHIP + && (transport.getObjectId() == 14979 + || transport.getObjectId() == 14985 + || transport.getObjectId() == 8631) + && (pandemonium.equals(transport.getOrigin()) + || pandemonium.equals(transport.getDestination())); + Pathfinder unlocked = runPathfinder(configWith(transport -> unrestricted(transport) + || pandemoniumShip.test(transport)), + portSarim, pandemonium); + assertTrue("the reviewed ship must reach the Pandemonium dock", + arrives(unlocked.getPath(), pandemonium, 0)); + assertTrue("the selected path must own an explicit Pandemonium SHIP edge", + selectsTransport(unlocked, pandemoniumShip)); + + Pathfinder locked = runPathfinder(configWith(WalkerRouteCorpusTest::unrestricted), + portSarim, pandemonium); + assertFalse("without the quest and fare the planner must not select a Pandemonium ship", + selectsTransport(locked, pandemoniumShip)); + } + + @Test + public void treeGnomeVillageShortcut_usesElkoyNpcTravel() { + WorldPoint mazeEntrance = new WorldPoint(2503, 3193, 0); + WorldPoint villageSide = new WorldPoint(2515, 3159, 0); + List path = route(configWith(t -> unrestricted(t) + || (t.getType() == TransportType.NPC && "Elkoy".equals(t.getName()))), + mazeEntrance, villageSide); + + assertTrue("Elkoy shortcut must reach the village side", arrives(path, villageSide, 1)); + assertTrue("Tree Gnome Village shortcut must contain an NPC transport edge", + usesTransportType(path, TransportType.NPC)); } @Test @@ -322,4 +733,142 @@ public void whiteWolfTunnel_notUsedWithoutTheQuest() { assertFalse("a player without Fishing Contest must not be routed through the tunnel", visits(surface, TUNNEL_EAST_UNDER, 5)); } + + // ---- Draynor sewers (surface/underground transition coverage) --------------------------------- + + private static final WorldPoint DRAYNOR_SEWER_EAST_SURFACE = new WorldPoint(3118, 3243, 0); + private static final WorldPoint DRAYNOR_SEWER_EAST_UNDER = new WorldPoint(3118, 9644, 0); + private static final WorldPoint DRAYNOR_SEWER_WEST_UNDER = new WorldPoint(3084, 9673, 0); + + private static boolean isDraynorWestTransition(Transport transport) { + WorldPoint origin = transport.getOrigin(); + WorldPoint destination = transport.getDestination(); + if (origin == null || destination == null) { + return false; + } + boolean originWest = origin.getX() >= 3083 && origin.getX() <= 3085 + && (origin.getY() >= 3271 && origin.getY() <= 3273 + || origin.getY() >= 9671 && origin.getY() <= 9673); + boolean destinationWest = destination.getX() >= 3083 && destination.getX() <= 3085 + && (destination.getY() >= 3271 && destination.getY() <= 3273 + || destination.getY() >= 9671 && destination.getY() <= 9673); + return originWest && destinationWest; + } + + @Test + public void draynorSewer_eastEntranceConnectsSurfaceAndWestUnderground() { + // Disable the west ladders so both directions must use the east transition and traverse the + // underground corridor. This prevents a regression from being hidden by walking above ground + // to a different trapdoor before entering the sewer. + PathfinderConfig config = configWith(t -> unrestricted(t) && !isDraynorWestTransition(t)); + + List descending = route(config, DRAYNOR_SEWER_EAST_SURFACE, DRAYNOR_SEWER_WEST_UNDER); + assertTrue("east trapdoor must reach the west side of Draynor sewers", + arrives(descending, DRAYNOR_SEWER_WEST_UNDER, 1)); + assertTrue("descent route must enter at the mapped east underground landing", + visits(descending, DRAYNOR_SEWER_EAST_UNDER, 2)); + + List ascending = route(config, DRAYNOR_SEWER_WEST_UNDER, DRAYNOR_SEWER_EAST_SURFACE); + assertTrue("west sewer must return to the surface through the east ladder", + arrives(ascending, DRAYNOR_SEWER_EAST_SURFACE, 1)); + assertTrue("ascent route must approach the mapped east underground ladder", + visits(ascending, DRAYNOR_SEWER_EAST_UNDER, 2)); + } + + // ---- Barrows mounds, individual crypts and randomized tunnel boundary ------------------------- + + /** + * Surface dig/route-anchor tile, deterministic individual-crypt stair/landing, sarcophagus approach + * and exit-stair object id. These values are shared with Quest Helper's reviewed Barrows zones and + * object steps; the fifth value is the sarcophagus object id, which must never become a static + * tunnel edge because the empty crypt is randomized per run. A crypt exit can spawn on another tile + * within its surface mound, so the surface point is a planner anchor rather than an exact live landing. + */ + private static final Object[][] BARROWS_CRYPTS = { + {new WorldPoint(3564, 3291, 0), new WorldPoint(3559, 9703, 3), + new WorldPoint(3554, 9699, 3), 20667, 20770}, + {new WorldPoint(3575, 3299, 0), new WorldPoint(3558, 9718, 3), + new WorldPoint(3555, 9713, 3), 20668, 20720}, + {new WorldPoint(3578, 3281, 0), new WorldPoint(3534, 9706, 3), + new WorldPoint(3539, 9702, 3), 20669, 20722}, + {new WorldPoint(3567, 3274, 0), new WorldPoint(3546, 9686, 3), + new WorldPoint(3549, 9683, 3), 20670, 20771}, + {new WorldPoint(3553, 3281, 0), new WorldPoint(3566, 9683, 3), + new WorldPoint(3568, 9686, 3), 20671, 20721}, + {new WorldPoint(3556, 3297, 0), new WorldPoint(3578, 9704, 3), + new WorldPoint(3572, 9706, 3), 20672, 20772} + }; + + private static boolean isBarrowsDig(Transport transport) { + return TransportExecutionRegistry.executorFor(transport).orElse(null) + == TransportExecutionRegistry.Executor.BARROWS_DIG; + } + + private static boolean usesExactTransport(List path, WorldPoint origin, + WorldPoint destination, + TransportExecutionRegistry.Executor executor) { + if (path == null || path.size() < 2) { + return false; + } + for (int index = 0; index < path.size() - 1; index++) { + if (!origin.equals(path.get(index)) || !destination.equals(path.get(index + 1))) { + continue; + } + if (allTransports.getOrDefault(origin, Collections.emptySet()).stream().anyMatch(transport -> + destination.equals(transport.getDestination()) + && TransportExecutionRegistry.executorFor(transport).orElse(null) == executor)) { + return true; + } + } + return false; + } + + @Test + public void barrowsMoundsAndIndividualCryptExitsAreStaticallyRoutable() { + PathfinderConfig withSpade = configWith(transport -> unrestricted(transport) || isBarrowsDig(transport)); + PathfinderConfig withoutSpecialRequirements = configWith(WalkerRouteCorpusTest::unrestricted); + + for (Object[] crypt : BARROWS_CRYPTS) { + WorldPoint surface = (WorldPoint) crypt[0]; + WorldPoint stair = (WorldPoint) crypt[1]; + WorldPoint sarcophagusApproach = (WorldPoint) crypt[2]; + int stairObjectId = (int) crypt[3]; + + List entering = route(withSpade, surface, sarcophagusApproach); + assertTrue("mound dig must enter the matching individual crypt: " + surface, + arrives(entering, sarcophagusApproach, 0)); + assertTrue("mound route must retain the exact spade executor edge: " + surface, + usesExactTransport(entering, surface, stair, + TransportExecutionRegistry.Executor.BARROWS_DIG)); + + List leaving = route(withoutSpecialRequirements, sarcophagusApproach, surface); + assertTrue("individual crypt must route to its own surface-mound anchor: " + stair, + arrives(leaving, surface, 2)); + assertTrue("crypt exit must use its reviewed staircase object: " + stairObjectId, + usesTransportObject(leaving, stairObjectId)); + } + } + + @Test + public void barrowsRandomSarcophagusTunnelIsNotInventedAsAStaticTransport() { + Set sarcophagusIds = Arrays.stream(BARROWS_CRYPTS) + .map(crypt -> (Integer) crypt[4]) + .collect(Collectors.toSet()); + List staticSarcophagusEdges = allTransports.values().stream() + .flatMap(Set::stream) + .filter(transport -> sarcophagusIds.contains(transport.getObjectId())) + .collect(Collectors.toList()); + + assertTrue("the empty sarcophagus is randomized and must be observed live, not statically routed: " + + staticSarcophagusEdges, + staticSarcophagusEdges.isEmpty()); + + WorldPoint surface = (WorldPoint) BARROWS_CRYPTS[0][0]; + WorldPoint tunnelChest = new WorldPoint(3551, 9695, 0); + List attempted = route( + configWith(transport -> unrestricted(transport) || isBarrowsDig(transport)), + surface, tunnelChest); + assertFalse("a mound dig alone must not claim deterministic access to the randomized tunnel", + arrives(attempted, tunnelChest, 2)); + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfigTransportRefreshHashTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfigTransportRefreshHashTest.java index 63e526e1b40..4af64e69ebe 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfigTransportRefreshHashTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfigTransportRefreshHashTest.java @@ -3,6 +3,7 @@ import net.runelite.api.Quest; import net.runelite.api.QuestState; import net.runelite.api.Skill; +import net.runelite.client.plugins.microbot.shortestpath.Transport; import net.runelite.client.plugins.microbot.shortestpath.TransportVarPlayer; import net.runelite.client.plugins.microbot.shortestpath.TransportVarbit; import net.runelite.client.plugins.microbot.util.magic.Runes; @@ -118,6 +119,26 @@ public void requiredSkillChangeStillInvalidates() { hashWithLevels(sortedSkillOrdinals, after)); } + @Test + public void requiredSpecialLevelChangeInvalidates() { + int[] tracked = new int[]{ + Transport.TOTAL_LEVEL_INDEX, + Transport.COMBAT_LEVEL_INDEX, + Transport.QUEST_POINTS_INDEX, + }; + int[] before = new int[Transport.REQUIREMENT_LEVEL_COUNT]; + before[Transport.TOTAL_LEVEL_INDEX] = 2000; + before[Transport.COMBAT_LEVEL_INDEX] = 39; + before[Transport.QUEST_POINTS_INDEX] = 100; + + for (int ordinal : tracked) { + int[] after = before.clone(); + after[ordinal]++; + assertNotEquals("special requirement changes must invalidate ordinal " + ordinal, + hashWithLevels(tracked, before), hashWithLevels(tracked, after)); + } + } + /** * A cooldown gate must not churn the cache while it ticks. {@code COOLDOWN_MINUTES} compares * against wall-clock minutes, so hashing its raw varplayer value invalidated the transport cache diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderHomeTeleportTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderHomeTeleportTest.java new file mode 100644 index 00000000000..58db9b8d384 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderHomeTeleportTest.java @@ -0,0 +1,39 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.TransportExecutionRegistry; +import net.runelite.client.plugins.microbot.shortestpath.TransportType; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportPlanningPolicy; +import org.junit.Test; + +import java.util.Collections; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class PathfinderHomeTeleportTest +{ + @Test + public void everyRegisteredHomeTeleportIsZeroRuneUsable() + { + PathfinderConfig config = new PathfinderConfig( + null, Collections.emptyMap(), Collections.emptyList(), null, null, + Rs2TransportPlanningPolicy.INSTANCE); + + for (TransportExecutionRegistry.HomeTeleport homeTeleport + : TransportExecutionRegistry.HomeTeleport.values()) + { + assertTrue(homeTeleport.getDisplayName(), + config.isTeleportationSpellUsable(spell(homeTeleport.getDisplayName()))); + } + assertFalse(config.isTeleportationSpellUsable(spell("Unknown Home Teleport"))); + } + + private static Transport spell(String displayInfo) + { + return new Transport( + null, new WorldPoint(3200, 3200, 0), displayInfo, + TransportType.TELEPORTATION_SPELL, false, 1); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderItemRequirementTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderItemRequirementTest.java new file mode 100644 index 00000000000..b3fe8f1694d --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderItemRequirementTest.java @@ -0,0 +1,87 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder; + +import net.runelite.client.plugins.microbot.shortestpath.TeleportationItem; +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.TransportItemRequirement; +import net.runelite.client.plugins.microbot.shortestpath.TransportType; +import org.junit.Test; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class PathfinderItemRequirementTest { + private static final List REQUIREMENTS = List.of( + new TransportItemRequirement(Map.of(100, 2, 101, 3)), + new TransportItemRequirement(Map.of(200, 1))); + + @Test + public void everyAndGroupMustBeSatisfied() { + assertTrue(PathfinderConfig.meetsItemRequirements(REQUIREMENTS, itemId -> { + if (itemId == 100) return 2; + if (itemId == 200) return 1; + return 0; + })); + + assertFalse(PathfinderConfig.meetsItemRequirements(REQUIREMENTS, itemId -> + itemId == 100 ? 2 : 0)); + } + + @Test + public void alternativeQuantitiesAreEvaluatedIndependently() { + assertTrue(PathfinderConfig.meetsItemRequirements(REQUIREMENTS, itemId -> { + if (itemId == 101) return 3; + if (itemId == 200) return 1; + return 0; + })); + assertFalse(PathfinderConfig.meetsItemRequirements(REQUIREMENTS, itemId -> { + if (itemId == 101) return 2; + if (itemId == 200) return 1; + return 0; + })); + } + + @Test + public void noRequirementsAreSatisfied() { + assertTrue(PathfinderConfig.meetsItemRequirements(List.of(), itemId -> 0)); + assertTrue(PathfinderConfig.meetsItemRequirements(null, itemId -> 0)); + } + + @Test + public void permanentItemPolicyKeepsOnlyInfiniteQuetzalWhistles() { + Set originlessTransports = Transport.loadAllFromResources().get(null); + assertNotNull(originlessTransports); + List whistles = originlessTransports.stream() + .filter(transport -> transport.getType() == TransportType.TELEPORTATION_ITEM) + .filter(transport -> transport.getDisplayInfo() != null + && transport.getDisplayInfo().startsWith("Quetzal whistle:")) + .collect(Collectors.toList()); + + assertEquals(28, whistles.size()); + List permanent = whistles.stream() + .filter(transport -> PathfinderConfig.isTeleportationItemAllowedByPolicy( + TeleportationItem.INVENTORY_NON_CONSUMABLE, + transport.isConsumable())) + .collect(Collectors.toList()); + + assertEquals(14, permanent.size()); + assertTrue(permanent.stream().noneMatch(Transport::isConsumable)); + assertTrue(permanent.stream().allMatch(transport -> + transport.getItemRequirements().size() == 1 + && transport.getItemRequirements().get(0).getItemIds().equals(Set.of(33120)))); + assertTrue(whistles.stream().allMatch(transport -> + PathfinderConfig.isTeleportationItemAllowedByPolicy( + TeleportationItem.INVENTORY, + transport.isConsumable()))); + assertTrue(whistles.stream().noneMatch(transport -> + PathfinderConfig.isTeleportationItemAllowedByPolicy( + TeleportationItem.NONE, + transport.isConsumable()))); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderPathMaterializationTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderPathMaterializationTest.java new file mode 100644 index 00000000000..6dce71ff12c --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderPathMaterializationTest.java @@ -0,0 +1,107 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil; +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.TransportType; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +public class PathfinderPathMaterializationTest +{ + @Test + public void completedRoutePreservesExactTransportAndMetrics() + { + WorldPoint start = new WorldPoint(3222, 3218, 0); + WorldPoint destination = new WorldPoint(3222, 9618, 0); + Transport exact = new Transport( + start, destination, "synthetic", TransportType.TRANSPORT, false, + "Climb-down", "Tunnel", 1001, 3); + PathfinderConfig config = new PathfinderConfig( + SplitFlagMap.fromResources(), new HashMap<>(), Collections.emptyList(), null, null); + + Pathfinder completed = Pathfinder.completedRoute( + config, + start, + Set.of(destination), + List.of(start, destination), + List.of(exact), + PathTerminationReason.TARGET_REACHED, + 3L, + 1234L, + 7L, + 2L, + 5L); + + assertTrue(completed.isDone()); + assertEquals(List.of(start, destination), completed.getPath()); + assertEquals(1, completed.getPathEdges().size()); + assertSame(exact, completed.getPathEdges().get(0).getTransport()); + assertEquals(PathTerminationReason.TARGET_REACHED, completed.getTerminationReason()); + assertEquals(3L, completed.getSelectedPathCost()); + assertEquals(1234L, completed.getStats().getElapsedTimeNanos()); + assertEquals(7, completed.getStats().getNodesChecked()); + assertEquals(2, completed.getStats().getTransportsChecked()); + assertEquals(5L, completed.getStats().getLiveCollisionEdgesChecked()); + } + + @Test + public void newerBestNodeRematerializesAfterAnEarlierLiveRead() throws Exception + { + int start = WorldPointUtil.packWorldPoint(new WorldPoint(3000, 3200, 0)); + Pathfinder pathfinder = new Pathfinder( + mock(PathfinderConfig.class), start, Collections.singleton(start)); + + Node first = new Node(new WorldPoint(3002, 3200, 0), + new Node(new WorldPoint(3001, 3200, 0), + new Node(new WorldPoint(3000, 3200, 0), null))); + setBestLastNode(pathfinder, first); + markLegacyPathDirtyIfPresent(pathfinder); + assertEquals(3, pathfinder.getPath().size()); + + Node latest = new Node(new WorldPoint(3005, 3200, 0), + new Node(new WorldPoint(3004, 3200, 0), + new Node(new WorldPoint(3003, 3200, 0), first))); + setBestLastNode(pathfinder, latest); + + List latestPath = pathfinder.getPath(); + assertEquals("a live reader must not leave the completed route on an older node", + 6, latestPath.size()); + assertEquals("typed edges and path points must describe the same node chain", + latestPath.size() - 1, pathfinder.getPathEdges().size()); + } + + private static void setBestLastNode(Pathfinder pathfinder, Node node) throws Exception + { + Field field = Pathfinder.class.getDeclaredField("bestLastNode"); + field.setAccessible(true); + field.set(pathfinder, node); + } + + /** + * Models the old dirty-flag implementation so this regression would fail before identity invalidation. + */ + private static void markLegacyPathDirtyIfPresent(Pathfinder pathfinder) throws Exception + { + try + { + Field field = Pathfinder.class.getDeclaredField("pathNeedsUpdate"); + field.setAccessible(true); + field.setBoolean(pathfinder, true); + } + catch (NoSuchFieldException ignored) + { + // Current implementation invalidates by Node identity and has no dirty flag. + } + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderSpecialRequirementTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderSpecialRequirementTest.java new file mode 100644 index 00000000000..a07db45665e --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderSpecialRequirementTest.java @@ -0,0 +1,41 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder; + +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class PathfinderSpecialRequirementTest { + @Test + public void specialLevelsParticipateInTransportAvailability() { + int[] required = new int[Transport.REQUIREMENT_LEVEL_COUNT]; + required[Transport.TOTAL_LEVEL_INDEX] = 2000; + required[Transport.COMBAT_LEVEL_INDEX] = 40; + required[Transport.QUEST_POINTS_INDEX] = 100; + + int[] current = new int[Transport.REQUIREMENT_LEVEL_COUNT]; + current[Transport.TOTAL_LEVEL_INDEX] = 2000; + current[Transport.COMBAT_LEVEL_INDEX] = 40; + current[Transport.QUEST_POINTS_INDEX] = 100; + + assertTrue(PathfinderConfig.meetsRequiredLevels(required, current)); + + current[Transport.COMBAT_LEVEL_INDEX] = 39; + assertFalse(PathfinderConfig.meetsRequiredLevels(required, current)); + current[Transport.COMBAT_LEVEL_INDEX] = 40; + current[Transport.TOTAL_LEVEL_INDEX] = 1999; + assertFalse(PathfinderConfig.meetsRequiredLevels(required, current)); + current[Transport.TOTAL_LEVEL_INDEX] = 2000; + current[Transport.QUEST_POINTS_INDEX] = 99; + assertFalse(PathfinderConfig.meetsRequiredLevels(required, current)); + } + + @Test + public void malformedLevelArraysFailClosed() { + assertFalse(PathfinderConfig.meetsRequiredLevels( + new int[Transport.REQUIREMENT_LEVEL_COUNT], + new int[Transport.REQUIREMENT_LEVEL_COUNT - 1])); + assertFalse(PathfinderConfig.meetsRequiredLevels(null, new int[Transport.REQUIREMENT_LEVEL_COUNT])); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderTerminationReasonTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderTerminationReasonTest.java new file mode 100644 index 00000000000..f1cb8f8fa75 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderTerminationReasonTest.java @@ -0,0 +1,166 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.TransportType; +import net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.Collections; +import java.util.concurrent.ConcurrentHashMap; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class PathfinderTerminationReasonTest +{ + private static final int START = WorldPointUtil.packWorldPoint(new WorldPoint(3200, 3200, 0)); + private static final int TARGET = WorldPointUtil.packWorldPoint(new WorldPoint(3201, 3200, 0)); + private static final int FAR_TARGET = WorldPointUtil.packWorldPoint(new WorldPoint(6000, 3200, 0)); + + @BeforeClass + public static void initializeCollisionExtents() + { + // VisitedTiles uses the resource-derived global region extents even when its CollisionMap is mocked. + SplitFlagMap.fromResources(); + } + + @Test + public void exactTargetReportsReached() + { + Scenario scenario = scenario(10_000L); + Pathfinder pathfinder = new Pathfinder(scenario.config, START, Collections.singleton(START)); + + pathfinder.run(); + + assertEquals(PathTerminationReason.TARGET_REACHED, pathfinder.getTerminationReason()); + assertTrue(pathfinder.isDone()); + } + + @Test + public void drainedFrontierReportsSearchExhausted() + { + Scenario scenario = scenario(10_000L); + when(scenario.map.getNeighbors(any(Node.class), any(VisitedTiles.class), + eq(scenario.config), anySet())).thenReturn(Collections.emptyList()); + Pathfinder pathfinder = new Pathfinder(scenario.config, START, Collections.singleton(TARGET)); + + pathfinder.run(); + + assertEquals(PathTerminationReason.SEARCH_EXHAUSTED, pathfinder.getTerminationReason()); + assertTrue(pathfinder.isDone()); + } + + @Test + public void elapsedCutoffReportsCutoffReached() + { + Scenario scenario = scenario(-1L); + Pathfinder pathfinder = new Pathfinder(scenario.config, START, Collections.singleton(TARGET)); + + pathfinder.run(); + + assertEquals(PathTerminationReason.CUTOFF_REACHED, pathfinder.getTerminationReason()); + assertTrue(pathfinder.isDone()); + } + + @Test + public void bidirectionalDrainedFrontiersReportSearchExhausted() + { + Scenario scenario = scenario(10_000L); + when(scenario.map.getNeighbors(any(Node.class), any(VisitedTiles.class), + eq(scenario.config), anySet())).thenReturn(Collections.emptyList()); + when(scenario.map.getReverseNeighbors(any(Node.class), any(VisitedTiles.class), + eq(scenario.config), anySet(), anyMap())).thenReturn(Collections.emptyList()); + Pathfinder pathfinder = new Pathfinder( + scenario.config, START, Collections.singleton(FAR_TARGET)); + + pathfinder.run(); + + assertEquals(PathTerminationReason.SEARCH_EXHAUSTED, pathfinder.getTerminationReason()); + assertTrue(pathfinder.isDone()); + } + + @Test + public void cancellationReportsCancelled() + { + Scenario scenario = scenario(10_000L); + Pathfinder pathfinder = new Pathfinder(scenario.config, START, Collections.singleton(TARGET)); + pathfinder.cancel(); + + pathfinder.run(); + + assertEquals(PathTerminationReason.CANCELLED, pathfinder.getTerminationReason()); + assertFalse(pathfinder.isDone()); + } + + @Test + public void caughtPlannerExceptionReportsFailed() + { + Scenario scenario = scenario(10_000L); + when(scenario.map.getNeighbors(any(Node.class), any(VisitedTiles.class), + eq(scenario.config), anySet())).thenThrow(new IllegalStateException("synthetic planner failure")); + Pathfinder pathfinder = new Pathfinder(scenario.config, START, Collections.singleton(TARGET)); + + pathfinder.run(); + + assertEquals(PathTerminationReason.FAILED, pathfinder.getTerminationReason()); + assertTrue("the worker stopped even though planning failed", pathfinder.isDone()); + } + + @Test + public void reverseChainRetainsForwardTransportIdentity() + { + WorldPoint start = new WorldPoint(3200, 3200, 0); + WorldPoint meeting = new WorldPoint(3201, 3200, 0); + WorldPoint goal = new WorldPoint(3201, 3200, 1); + Transport stairs = new Transport( + meeting, goal, "Upper floor", TransportType.TRANSPORT, false, + "Climb-up", "Staircase", 16671); + + Node forwardStart = new Node(start, null); + Node forwardMeeting = new Node(meeting, forwardStart); + Node backwardGoal = new Node(goal, null); + Node backwardMeeting = new TransportNode(meeting, backwardGoal, 1, stairs); + + java.util.List edges = + PathEdge.fromBidirectionalChains(forwardMeeting, backwardMeeting); + + assertEquals(2, edges.size()); + assertFalse(edges.get(0).isTransport()); + assertTrue(edges.get(1).isTransport()); + assertEquals(meeting, edges.get(1).getFrom()); + assertEquals(goal, edges.get(1).getTo()); + assertSame(stairs, edges.get(1).getTransport()); + } + + private static Scenario scenario(long cutoffMillis) + { + PathfinderConfig config = mock(PathfinderConfig.class); + CollisionMap map = mock(CollisionMap.class); + when(config.getMap()).thenReturn(map); + when(config.getCalculationCutoffMillis()).thenReturn(cutoffMillis); + when(config.getTransports()).thenReturn(new ConcurrentHashMap<>()); + return new Scenario(config, map); + } + + private static final class Scenario + { + private final PathfinderConfig config; + private final CollisionMap map; + + private Scenario(PathfinderConfig config, CollisionMap map) + { + this.config = config; + this.map = map; + } + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportPlanningPolicyTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportPlanningPolicyTest.java new file mode 100644 index 00000000000..9a9eb407c1f --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportPlanningPolicyTest.java @@ -0,0 +1,63 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.TransportType; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.HashMap; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class TransportPlanningPolicyTest +{ + @Test + public void localCoreRetainsInjectedAdmissionAndZeroRunePolicies() throws Exception + { + Transport admitted = transport("Allowed"); + Transport rejected = transport("Rejected"); + Transport home = transport("Home"); + TransportPlanningPolicy policy = new TransportPlanningPolicy() + { + @Override + public boolean isAdmitted(Transport transport) + { + return transport != rejected; + } + + @Override + public boolean isZeroRuneSpell(Transport transport) + { + return transport == home; + } + }; + PathfinderConfig config = new PathfinderConfig( + SplitFlagMap.fromResources(), new HashMap<>(), Collections.emptyList(), null, null, policy); + + Field field = PathfinderConfig.class.getDeclaredField("transportPlanningPolicy"); + field.setAccessible(true); + TransportPlanningPolicy installed = (TransportPlanningPolicy) field.get(config); + + assertSame(policy, installed); + assertTrue(installed.isAdmitted(admitted)); + assertFalse(installed.isAdmitted(rejected)); + assertTrue(installed.isZeroRuneSpell(home)); + } + + private static Transport transport(String displayInfo) + { + return new Transport( + new WorldPoint(3200, 3200, 0), + new WorldPoint(3200, 3201, 0), + displayInfo, + TransportType.TRANSPORT, + false, + "Open", + "Door", + 1); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/TestRunnerPluginTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/TestRunnerPluginTest.java new file mode 100644 index 00000000000..e60abe0d501 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/TestRunnerPluginTest.java @@ -0,0 +1,40 @@ +package net.runelite.client.plugins.microbot.testing; + +import net.runelite.api.GameState; +import net.runelite.client.plugins.PluginDescriptor; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class TestRunnerPluginTest { + + @Test + public void runnerStartsBeforePersistedTestTargets() { + PluginDescriptor descriptor = TestRunnerPlugin.class.getAnnotation(PluginDescriptor.class); + assertTrue(descriptor.alwaysOn()); + assertTrue(descriptor.priority()); + } + + @Test + public void loggedInStateAloneIsNotPlayable() { + assertFalse(TestRunnerPlugin.isClientReady(GameState.LOGGED_IN, false, false)); + } + + @Test + public void welcomeScreenBlocksTargetPluginStartup() { + assertFalse(TestRunnerPlugin.isClientReady(GameState.LOGGED_IN, true, true)); + } + + @Test + public void loggedInPlayerWithoutWelcomeScreenIsPlayable() { + assertTrue(TestRunnerPlugin.isClientReady(GameState.LOGGED_IN, true, false)); + } + + @Test + public void localPlayerDoesNotMakeOtherGameStatesPlayable() { + assertFalse(TestRunnerPlugin.isClientReady(GameState.LOGIN_SCREEN, true, false)); + assertFalse(TestRunnerPlugin.isClientReady(GameState.LOADING, true, false)); + assertFalse(TestRunnerPlugin.isClientReady(GameState.HOPPING, true, false)); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/webwalker/F2PWebWalkerRouteTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/webwalker/F2PWebWalkerRouteTest.java new file mode 100644 index 00000000000..7fe7c8d2751 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/testing/webwalker/F2PWebWalkerRouteTest.java @@ -0,0 +1,110 @@ +package net.runelite.client.plugins.microbot.testing.webwalker; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportExecutor; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class F2PWebWalkerRouteTest { + + @Test + public void varrockSewerRegressionHasAStableSurfaceOrigin() { + F2PWebWalkerRoute route = F2PWebWalkerRoute.selected("F2P-17").get(0); + + assertEquals(new WorldPoint(3236, 3458, 0), route.start); + assertEquals(new WorldPoint(3237, 9858, 0), route.destination); + assertEquals(5, route.repetitions); + assertFalse("the route must not become a no-op after an earlier run ends underground", + route.currentLocationStart); + } + + @Test + public void canoeSelectionGateRouteIsExplicitAndRequiresFiveSelections() { + F2PWebWalkerRoute route = F2PWebWalkerRoute.selected("F2P-18").get(0); + + assertEquals(new WorldPoint(3243, 3237, 0), route.start); + assertEquals(new WorldPoint(3199, 3344, 0), route.destination); + assertEquals(3, route.repetitions); + assertEquals(Rs2TransportExecutor.CANOE, route.expectedShadowExecutor); + assertEquals(5, route.minimumExpectedShadowExecutions); + assertEquals(10, route.forcedBankRouteComparisons); + assertEquals(10, route.minimumExpectedBankRouteFromBankComparisons); + assertEquals(10, route.minimumExpectedBankRouteFromBankItemGatedComparisons); + assertFalse("resource-gated evidence route must not run in the default fresh-account suite", + F2PWebWalkerRoute.selected("all").contains(route)); + } + + @Test + public void shipSelectionGateRouteIsExplicitAndRequiresThreeTerminalSelections() { + F2PWebWalkerRoute route = F2PWebWalkerRoute.selected("F2P-19").get(0); + + assertEquals(new WorldPoint(3029, 3217, 0), route.start); + assertEquals(new WorldPoint(2956, 3146, 0), route.destination); + assertEquals(2, route.repetitions); + assertEquals(Rs2TransportExecutor.TERMINAL_TRAVEL, route.expectedShadowExecutor); + assertEquals(3, route.minimumExpectedShadowExecutions); + assertFalse("fare-gated evidence route must not run in the default fresh-account suite", + F2PWebWalkerRoute.selected("all").contains(route)); + } + + @Test + public void activeReplanSelectionGateRouteContributesTwelveObservedReplans() { + F2PWebWalkerRoute route = F2PWebWalkerRoute.selected("F2P-20").get(0); + + assertEquals(new WorldPoint(3029, 3217, 0), route.start); + assertEquals(new WorldPoint(2946, 3368, 0), route.destination); + assertEquals(12, route.forcedActiveReplans); + assertEquals(12, route.minimumExpectedActiveReplanComparisons); + assertTrue("Musa Point setup must retain the proven reverse ship", route.forceShips); + assertFalse("evidence-injection route must not run in the default fresh-account suite", + F2PWebWalkerRoute.selected("all").contains(route)); + } + + @Test + public void recoveryReplanSelectionGateRouteRequiresRecoveredArrivals() { + F2PWebWalkerRoute route = F2PWebWalkerRoute.selected("F2P-21").get(0); + + assertEquals(new WorldPoint(3029, 3217, 0), route.start); + assertEquals(new WorldPoint(2957, 3214, 0), route.destination); + assertEquals(3, route.repetitions); + assertEquals(2, route.forcedRecoveryReplans); + assertEquals(2, route.forcedSetupRecoveryReplans); + assertEquals(10, route.minimumExpectedRecoveryReplanComparisons); + assertEquals(5, route.minimumExpectedRecoveryArrivals); + assertTrue(route.requireF2PWorld); + assertTrue(route.forceNoAgilityShortcuts); + assertTrue(route.forceNoTeleports); + assertFalse("recovery evidence route must not run in the default fresh-account suite", + F2PWebWalkerRoute.selected("all").contains(route)); + } + + @Test + public void membersSliceIsExplicitAndNeverPartOfTheDefaultF2pSuite() { + assertEquals(4, F2PWebWalkerRoute.selected("members").size()); + + F2PWebWalkerRoute ferry = F2PWebWalkerRoute.selected("P2P-01").get(0); + assertTrue(ferry.requireMembersWorld); + assertFalse(ferry.requireF2PWorld); + assertEquals(Rs2TransportExecutor.TERMINAL_TRAVEL, ferry.expectedShadowExecutor); + assertEquals(5, ferry.minimumExpectedShadowExecutions); + + F2PWebWalkerRoute glider = F2PWebWalkerRoute.selected("P2P-02").get(0); + assertTrue(glider.requireMembersWorld); + assertEquals(Rs2TransportExecutor.GNOME_GLIDER, glider.expectedShadowExecutor); + + F2PWebWalkerRoute spiritTree = F2PWebWalkerRoute.selected("P2P-03").get(0); + assertTrue(spiritTree.requireMembersWorld); + assertEquals(Rs2TransportExecutor.SPIRIT_TREE, spiritTree.expectedShadowExecutor); + + F2PWebWalkerRoute membersShip = F2PWebWalkerRoute.selected("P2P-04").get(0); + assertTrue(membersShip.requireMembersWorld); + assertEquals(Rs2TransportExecutor.TERMINAL_TRAVEL, + membersShip.expectedShadowExecutor); + + assertTrue(F2PWebWalkerRoute.selected("all").stream() + .noneMatch(route -> route.requireMembersWorld)); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/LocalPlannerComparisonMain.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/LocalPlannerComparisonMain.java new file mode 100644 index 00000000000..73dce392252 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/LocalPlannerComparisonMain.java @@ -0,0 +1,643 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.TransportItemRequirement; +import net.runelite.client.plugins.microbot.shortestpath.TransportType; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathfinderConfig; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.SplitFlagMap; + +import java.io.IOException; +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryPoolMXBean; +import java.lang.management.MemoryType; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Emits local planner results for the opt-in dual-engine comparison harness. */ +public final class LocalPlannerComparisonMain +{ + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final boolean EMBEDDED_UPSTREAM = Boolean.getBoolean( + "microbot.planner.embedded-upstream"); + + private LocalPlannerComparisonMain() + { + } + + public static void main(String[] args) throws Exception + { + if (args.length != 2) + { + throw new IllegalArgumentException("expected "); + } + Path corpusPath = Path.of(args[0]).toAbsolutePath().normalize(); + Path outputPath = Path.of(args[1]).toAbsolutePath().normalize(); + PlannerCorpus corpus = readCorpus(corpusPath); + List results = new ArrayList<>(); + for (PlannerCase plannerCase : corpus.cases) + { + results.add(run(plannerCase)); + } + PlannerRun run = new PlannerRun( + corpus.schemaVersion, + EMBEDDED_UPSTREAM ? "shortest-path-upstream-embedded" : "microbot-local", + System.getProperty("microbot.planner.revision", "unknown"), + results); + Files.createDirectories(outputPath.getParent()); + Files.writeString(outputPath, GSON.toJson(run) + System.lineSeparator(), + StandardCharsets.UTF_8); + } + + private static PlannerCorpus readCorpus(Path path) throws IOException + { + PlannerCorpus corpus = GSON.fromJson(Files.readString(path, StandardCharsets.UTF_8), + PlannerCorpus.class); + if (corpus == null || corpus.schemaVersion != 3 || corpus.cases == null) + { + throw new IllegalArgumentException("unsupported or incomplete planner corpus"); + } + return corpus; + } + + private static PlannerCaseResult run(PlannerCase plannerCase) throws Exception + { + if (!"STATIC_COLLISION_ONLY".equals(plannerCase.policy.transportMode) + && !"EXPLICIT_CATALOG".equals(plannerCase.policy.transportMode) + && !"BANK_AWARE_EXPLICIT_CATALOG".equals(plannerCase.policy.transportMode)) + { + return PlannerCaseResult.unsupported(plannerCase.id, + "unsupported transport policy: " + plannerCase.policy.transportMode); + } + if (plannerCase.policy.cutoffMillis <= 0 || plannerCase.policy.cutoffMillis % 600L != 0) + { + throw new IllegalArgumentException( + "comparison cutoff must be a positive whole number of game ticks"); + } + + Catalog catalog = Catalog.from(plannerCase); + WorldPoint start = plannerCase.start.toWorldPoint(); + WorldPoint target = plannerCase.target.toWorldPoint(); + resetHeapPeaks(); + long heapBefore = usedHeap(); + SearchOutcome outcome = "BANK_AWARE_EXPLICIT_CATALOG".equals( + plannerCase.policy.transportMode) + ? searchWithBankDetours(plannerCase, catalog, start, target) + : search(newConfig(plannerCase, catalog.withoutBankByOrigin, false), start, target, false); + long peakHeapDelta = Math.max(0L, peakHeap() - heapBefore); + WorldPoint endpoint = outcome.path.isEmpty() + ? null : outcome.path.get(outcome.path.size() - 1); + return PlannerCaseResult.supported( + plannerCase.id, + outcome.termination, + outcome.reached, + Point.from(endpoint), + outcome.path.size(), + outcome.cost, + outcome.nodesChecked, + outcome.transportsChecked, + outcome.elapsedNanos, + peakHeapDelta, + selectedTransports(outcome.edges, catalog), + outcome.bankVisited); + } + + private static PathfinderConfig newConfig( + PlannerCase plannerCase, Map> activeCatalog, + boolean useBankItems) throws Exception + { + PathfinderConfig config = new PathfinderConfig( + SplitFlagMap.fromResources(), activeCatalog, Collections.emptyList(), null, null); + config.getTransports().putAll(activeCatalog); + for (Map.Entry> entry : activeCatalog.entrySet()) + { + config.getTransportsPacked().put( + net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil.packWorldPoint(entry.getKey()), + entry.getValue()); + } + setField(config, "calculationCutoffMillis", plannerCase.policy.cutoffMillis); + setField(config, "avoidWilderness", plannerCase.policy.avoidWilderness); + config.setUseBankItems(useBankItems); + return config; + } + + private static SearchOutcome search( + PathfinderConfig config, WorldPoint start, WorldPoint target, boolean bankVisited) + { + Rs2RouteRequest request = Rs2PathApi.resolvePolicy( + Rs2RouteRequest.to(start, target) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + config); + Rs2RoutePlanner planner = EMBEDDED_UPSTREAM + ? Rs2PathApi.upstreamPlanner() : Rs2PathApi.localPlanner(config); + Rs2RouteResult result = planner.plan( + request, Rs2PathApi.resolvePlanningSnapshot(request, config)); + List path = result.getPath(); + List pathEdges = result.getSteps(); + Rs2RouteMetrics metrics = result.getMetrics(); + WorldPoint endpoint = path.isEmpty() ? null : path.get(path.size() - 1); + boolean reached = endpoint != null && endpoint.equals(target); + long reconstructedCost = pathCost(pathEdges, request.getPolicy().orElseThrow()); + if (metrics.getPathCost() != reconstructedCost) + { + throw new IllegalStateException( + "local selected cost differs from reconstructed edge cost: " + start + " -> " + target); + } + return new SearchOutcome( + path, + pathEdges, + result.getTerminationReason().name(), + reached, + reconstructedCost, + metrics.getNodesChecked(), + metrics.getTransportsChecked(), + metrics.getSearchNanos(), + bankVisited); + } + + private static SearchOutcome searchWithBankDetours( + PlannerCase plannerCase, Catalog catalog, WorldPoint start, WorldPoint target) throws Exception + { + List definitions = plannerCase.bankLocations == null + ? Collections.emptyList() : plannerCase.bankLocations; + if (definitions.isEmpty()) + { + throw new IllegalArgumentException( + "bank-aware comparison requires at least one bank: " + plannerCase.id); + } + + SearchOutcome direct = search( + newConfig(plannerCase, catalog.withoutBankByOrigin, false), start, target, false); + SearchOutcome chosen = direct; + long totalNodes = availableMetric(direct.nodesChecked); + long totalTransports = availableMetric(direct.transportsChecked); + long totalElapsed = availableMetric(direct.elapsedNanos); + for (Point definition : definitions) + { + WorldPoint bank = definition.toWorldPoint(); + SearchOutcome toBank = search( + newConfig(plannerCase, catalog.withoutBankByOrigin, false), start, bank, false); + totalNodes += availableMetric(toBank.nodesChecked); + totalTransports += availableMetric(toBank.transportsChecked); + totalElapsed += availableMetric(toBank.elapsedNanos); + if (!toBank.reached) + { + continue; + } + SearchOutcome fromBank = search( + newConfig(plannerCase, catalog.withBankByOrigin, true), bank, target, true); + totalNodes += availableMetric(fromBank.nodesChecked); + totalTransports += availableMetric(fromBank.transportsChecked); + totalElapsed += availableMetric(fromBank.elapsedNanos); + if (!fromBank.reached) + { + continue; + } + SearchOutcome bankRoute = SearchOutcome.combine(toBank, fromBank); + if (!chosen.reached || bankRoute.cost < chosen.cost) + { + chosen = bankRoute; + } + } + return chosen.withMetrics(totalNodes, totalTransports, totalElapsed); + } + + private static long availableMetric(long value) + { + return value < 0L ? 0L : value; + } + + private static void setField(PathfinderConfig config, String name, Object value) throws Exception + { + Field field = PathfinderConfig.class.getDeclaredField(name); + field.setAccessible(true); + field.set(config, value); + } + + private static long pathCost(List path, Rs2RoutePolicy policy) + { + if (path == null) + { + return -1L; + } + long cost = 0L; + for (Rs2RouteStep edge : path) + { + if (edge.isTransport()) + { + Rs2TransportEdge transport = edge.getTransport().orElseThrow(); + cost += transport.getDuration(); + if (transport.isTeleport()) + { + cost += policy.getDistanceBeforeUsingTeleport(); + } + } + else + { + cost += net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil.distanceBetween( + edge.getFrom(), edge.getTo()); + } + } + return cost; + } + + private static List selectedTransports( + List path, Catalog catalog) + { + List selected = new ArrayList<>(); + for (Rs2RouteStep edge : path) + { + if (!edge.isTransport()) + { + continue; + } + Rs2TransportEdge transport = edge.getTransport().orElseThrow(); + Object sourceIdentity = transport.getSourceIdentity(); + String id = sourceIdentity instanceof Transport + ? catalog.ids.get((Transport) sourceIdentity) + : null; + if (id == null) + { + throw new IllegalStateException("selected transport is not from the explicit corpus catalog: " + + transport); + } + selected.add(new SelectedTransport(id, Point.from(edge.getFrom()), Point.from(edge.getTo()), + transport.getType().name(), transport.getDuration())); + } + return Collections.unmodifiableList(selected); + } + + private static void resetHeapPeaks() + { + for (MemoryPoolMXBean pool : ManagementFactory.getMemoryPoolMXBeans()) + { + if (pool.getType() == MemoryType.HEAP) + { + pool.resetPeakUsage(); + } + } + } + + private static long usedHeap() + { + return ManagementFactory.getMemoryMXBean().getHeapMemoryUsage().getUsed(); + } + + private static long peakHeap() + { + long peak = 0L; + for (MemoryPoolMXBean pool : ManagementFactory.getMemoryPoolMXBeans()) + { + if (pool.getType() == MemoryType.HEAP && pool.getPeakUsage() != null) + { + peak += Math.max(0L, pool.getPeakUsage().getUsed()); + } + } + return peak; + } + + private static final class SearchOutcome + { + private final List path; + private final List edges; + private final String termination; + private final boolean reached; + private final long cost; + private final long nodesChecked; + private final long transportsChecked; + private final long elapsedNanos; + private final boolean bankVisited; + + private SearchOutcome(List path, List edges, String termination, + boolean reached, long cost, long nodesChecked, long transportsChecked, + long elapsedNanos, boolean bankVisited) + { + this.path = List.copyOf(path); + this.edges = List.copyOf(edges); + this.termination = termination; + this.reached = reached; + this.cost = cost; + this.nodesChecked = nodesChecked; + this.transportsChecked = transportsChecked; + this.elapsedNanos = elapsedNanos; + this.bankVisited = bankVisited; + } + + private static SearchOutcome combine(SearchOutcome toBank, SearchOutcome fromBank) + { + if (toBank.path.isEmpty() || fromBank.path.isEmpty() + || !toBank.path.get(toBank.path.size() - 1).equals(fromBank.path.get(0))) + { + throw new IllegalArgumentException("bank route legs are not contiguous"); + } + List path = new ArrayList<>(toBank.path); + path.addAll(fromBank.path.subList(1, fromBank.path.size())); + List edges = new ArrayList<>(toBank.edges); + edges.addAll(fromBank.edges); + return new SearchOutcome( + path, + edges, + fromBank.termination, + fromBank.reached, + Math.addExact(toBank.cost, fromBank.cost), + availableMetric(toBank.nodesChecked) + availableMetric(fromBank.nodesChecked), + availableMetric(toBank.transportsChecked) + + availableMetric(fromBank.transportsChecked), + availableMetric(toBank.elapsedNanos) + availableMetric(fromBank.elapsedNanos), + true); + } + + private SearchOutcome withMetrics(long nodes, long transports, long elapsed) + { + return new SearchOutcome(path, edges, termination, reached, cost, + nodes, transports, elapsed, bankVisited); + } + } + + private static final class PlannerCorpus + { + private int schemaVersion; + private List cases; + } + + private static final class PlannerCase + { + private String id; + private Point start; + private Point target; + private PlannerPolicy policy; + private List transports = Collections.emptyList(); + private List bankLocations = Collections.emptyList(); + private List inventoryItems = Collections.emptyList(); + private List equipmentItems = Collections.emptyList(); + private List bankItems = Collections.emptyList(); + } + + private static final class PlannerPolicy + { + private String transportMode; + private boolean avoidWilderness; + private long cutoffMillis; + } + + private static final class PlannerTransport + { + private String id; + private Point origin; + private Point destination; + private String type; + private int duration; + private String displayInfo; + private String availability = "ALWAYS"; + private String items; + } + + private static final class PlannerItem + { + private int id; + private int quantity; + } + + private static final class Catalog + { + private final Map> withoutBankByOrigin = new HashMap<>(); + private final Map> withBankByOrigin = new HashMap<>(); + private final IdentityHashMap ids = new IdentityHashMap<>(); + + private static Catalog from(PlannerCase plannerCase) throws Exception + { + Catalog catalog = new Catalog(); + List definitions = plannerCase.transports == null + ? Collections.emptyList() : plannerCase.transports; + if ("STATIC_COLLISION_ONLY".equals(plannerCase.policy.transportMode) + && !definitions.isEmpty()) + { + throw new IllegalArgumentException("static-only case has a transport catalog: " + + plannerCase.id); + } + Set seenIds = new java.util.HashSet<>(); + for (PlannerTransport definition : definitions) + { + if (definition.id == null || !seenIds.add(definition.id)) + { + throw new IllegalArgumentException("missing or duplicate transport id in " + + plannerCase.id + ": " + definition.id); + } + if (definition.origin == null || definition.destination == null + || definition.type == null || definition.duration < 0) + { + throw new IllegalArgumentException("incomplete transport " + definition.id + + " in " + plannerCase.id); + } + WorldPoint origin = definition.origin.toWorldPoint(); + Transport transport = new Transport(origin, definition.destination.toWorldPoint(), + definition.displayInfo, TransportType.valueOf(definition.type), false, + definition.duration); + applyItemRequirements(transport, definition.items); + if (!"ALWAYS".equals(definition.availability) + && !"AFTER_BANK".equals(definition.availability)) + { + throw new IllegalArgumentException("unsupported transport availability for " + + definition.id + ": " + definition.availability); + } + if (hasRequiredItems(transport, plannerCase, true)) + { + catalog.withBankByOrigin + .computeIfAbsent(origin, ignored -> new java.util.LinkedHashSet<>()) + .add(transport); + } + if ("ALWAYS".equals(definition.availability) + && hasRequiredItems(transport, plannerCase, false)) + { + catalog.withoutBankByOrigin + .computeIfAbsent(origin, ignored -> new java.util.LinkedHashSet<>()) + .add(transport); + } + catalog.ids.put(transport, definition.id); + } + return catalog; + } + + private static void applyItemRequirements(Transport transport, String items) + throws Exception + { + if (items == null || items.isBlank()) + { + return; + } + Method setter = Transport.class.getDeclaredMethod( + "setItemRequirements", List.class); + setter.setAccessible(true); + setter.invoke(transport, TransportItemRequirement.parseRequirements(items)); + } + + private static boolean hasRequiredItems( + Transport transport, PlannerCase plannerCase, boolean includeBank) + { + Map available = availableItems(plannerCase, includeBank); + return TransportItemRequirement.selectProviders( + transport.getItemRequirements(), + itemId -> available.getOrDefault(itemId, 0), + itemId -> available.getOrDefault(itemId, 0) > 0, + itemId -> available.getOrDefault(itemId, 0) > 0).isPresent(); + } + + private static Map availableItems( + PlannerCase plannerCase, boolean includeBank) + { + Map available = new HashMap<>(); + addItems(available, plannerCase.inventoryItems, "inventory"); + addItems(available, plannerCase.equipmentItems, "equipment"); + if (includeBank) + { + addItems(available, plannerCase.bankItems, "bank"); + } + return available; + } + + private static void addItems( + Map available, List items, String source) + { + if (items == null) + { + return; + } + for (PlannerItem item : items) + { + if (item == null || item.id <= 0 || item.quantity <= 0) + { + throw new IllegalArgumentException("invalid " + source + " item state"); + } + available.merge(item.id, item.quantity, Math::addExact); + } + } + + } + + private static final class Point + { + private int x; + private int y; + private int plane; + + private WorldPoint toWorldPoint() + { + return new WorldPoint(x, y, plane); + } + + private static Point from(WorldPoint point) + { + if (point == null) + { + return null; + } + Point value = new Point(); + value.x = point.getX(); + value.y = point.getY(); + value.plane = point.getPlane(); + return value; + } + } + + private static final class PlannerRun + { + private final int schemaVersion; + private final String engine; + private final String revision; + private final List cases; + + private PlannerRun(int schemaVersion, String engine, String revision, + List cases) + { + this.schemaVersion = schemaVersion; + this.engine = engine; + this.revision = revision; + this.cases = cases; + } + } + + private static final class PlannerCaseResult + { + private final String id; + private final boolean supported; + private final String unsupportedReason; + private final String termination; + private final boolean reached; + private final Point endpoint; + private final int pathLength; + private final long pathCost; + private final long nodesChecked; + private final long transportsChecked; + private final long elapsedNanos; + private final long peakHeapDeltaBytes; + private final List selectedTransports; + private final boolean bankVisited; + + private PlannerCaseResult(String id, boolean supported, String unsupportedReason, + String termination, boolean reached, Point endpoint, int pathLength, long pathCost, + long nodesChecked, long transportsChecked, long elapsedNanos, long peakHeapDeltaBytes, + List selectedTransports, boolean bankVisited) + { + this.id = id; + this.supported = supported; + this.unsupportedReason = unsupportedReason; + this.termination = termination; + this.reached = reached; + this.endpoint = endpoint; + this.pathLength = pathLength; + this.pathCost = pathCost; + this.nodesChecked = nodesChecked; + this.transportsChecked = transportsChecked; + this.elapsedNanos = elapsedNanos; + this.peakHeapDeltaBytes = peakHeapDeltaBytes; + this.selectedTransports = selectedTransports; + this.bankVisited = bankVisited; + } + + private static PlannerCaseResult unsupported(String id, String reason) + { + return new PlannerCaseResult(id, false, reason, null, false, null, + 0, -1L, -1L, -1L, -1L, -1L, Collections.emptyList(), false); + } + + private static PlannerCaseResult supported(String id, String termination, + boolean reached, Point endpoint, int pathLength, long pathCost, long nodesChecked, + long transportsChecked, long elapsedNanos, long peakHeapDeltaBytes, + List selectedTransports, boolean bankVisited) + { + return new PlannerCaseResult(id, true, null, termination, reached, endpoint, + pathLength, pathCost, nodesChecked, transportsChecked, elapsedNanos, + peakHeapDeltaBytes, selectedTransports, bankVisited); + } + } + + private static final class SelectedTransport + { + private final String id; + private final Point from; + private final Point to; + private final String type; + private final int duration; + + private SelectedTransport(String id, Point from, Point to, String type, int duration) + { + this.id = id; + this.from = from; + this.to = to; + this.type = type; + this.duration = duration; + } + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2HotAirBalloonTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2HotAirBalloonTest.java new file mode 100644 index 00000000000..be0924e07ba --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2HotAirBalloonTest.java @@ -0,0 +1,51 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.gameval.InterfaceID; +import net.runelite.api.gameval.ObjectID; +import net.runelite.client.plugins.microbot.shortestpath.TransportExecutionRegistry; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class Rs2HotAirBalloonTest +{ + @Test + public void everyRegisteredDestinationHasTheCanonicalMapButton() + { + assertEquals(InterfaceID.ZepBalloonMap.BTN_CAST, + Rs2HotAirBalloon.destinationButton( + TransportExecutionRegistry.BalloonDestination.CASTLE_WARS)); + assertEquals(InterfaceID.ZepBalloonMap.BTN_GNO, + Rs2HotAirBalloon.destinationButton( + TransportExecutionRegistry.BalloonDestination.GRAND_TREE)); + assertEquals(InterfaceID.ZepBalloonMap.BTN_CRAFT, + Rs2HotAirBalloon.destinationButton( + TransportExecutionRegistry.BalloonDestination.CRAFTING_GUILD)); + assertEquals(InterfaceID.ZepBalloonMap.BTN_ENT, + Rs2HotAirBalloon.destinationButton( + TransportExecutionRegistry.BalloonDestination.ENTRANA)); + assertEquals(InterfaceID.ZepBalloonMap.BTN_TAV, + Rs2HotAirBalloon.destinationButton( + TransportExecutionRegistry.BalloonDestination.TAVERLEY)); + assertEquals(InterfaceID.ZepBalloonMap.BTN_VARR, + Rs2HotAirBalloon.destinationButton( + TransportExecutionRegistry.BalloonDestination.VARROCK)); + assertEquals(-1, Rs2HotAirBalloon.destinationButton(null)); + } + + @Test + public void basketLookupAcceptsBaseAndUnlockedStationTransforms() + { + assertTrue(Rs2HotAirBalloon.isBasketObjectId(ObjectID.ZEP_BASKET_ENTRANA)); + assertTrue(Rs2HotAirBalloon.isBasketObjectId(ObjectID.ZEP_BASKET)); + assertTrue(Rs2HotAirBalloon.isBasketObjectId(ObjectID.ZEP_MULTI_BASKET_ENTRANA)); + assertTrue(Rs2HotAirBalloon.isBasketObjectId(ObjectID.ZEP_MULTI_BASKET_TAV)); + assertTrue(Rs2HotAirBalloon.isBasketObjectId(ObjectID.ZEP_MULTI_BASKET_CAST)); + assertTrue(Rs2HotAirBalloon.isBasketObjectId(ObjectID.ZEP_MULTI_BASKET_GNO)); + assertTrue(Rs2HotAirBalloon.isBasketObjectId(ObjectID.ZEP_MULTI_BASKET_CRAFT)); + assertTrue(Rs2HotAirBalloon.isBasketObjectId(ObjectID.ZEP_MULTI_BASKET_VARR)); + assertFalse(Rs2HotAirBalloon.isBasketObjectId(ObjectID.ZEP_BALLOON)); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2PathApiPlanningTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2PathApiPlanningTest.java new file mode 100644 index 00000000000..ced78e832bd --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2PathApiPlanningTest.java @@ -0,0 +1,1432 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.Client; +import net.runelite.api.WorldType; +import net.runelite.client.plugins.microbot.shortestpath.ShortestPathPlugin; +import net.runelite.client.plugins.microbot.shortestpath.PlannerSelectionMode; +import net.runelite.client.plugins.microbot.shortestpath.PrimitiveIntHashMap; +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.TransportType; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.CollisionMap; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Node; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathfinderConfig; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.SplitFlagMap; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.VisitedTiles; +import net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.EnumSet; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class Rs2PathApiPlanningTest +{ + /** Covers two sequential canary cutoffs and cold production-catalog initialization in a full suite. */ + private static final long ACTIVE_ROUTE_TEST_TIMEOUT_SECONDS = 30L; + private static PathfinderConfig config; + + @BeforeClass + public static void createIsolatedConfig() throws Exception + { + config = new PathfinderConfig( + SplitFlagMap.fromResources(), new HashMap<>(), Collections.emptyList(), null, null); + setCalculationCutoff(config); + } + + private static void setCalculationCutoff(PathfinderConfig pathfinderConfig) throws Exception + { + Field cutoff = PathfinderConfig.class.getDeclaredField("calculationCutoffMillis"); + cutoff.setAccessible(true); + cutoff.setLong(pathfinderConfig, 10_000L); + } + + private static void setPlannerMode( + PathfinderConfig pathfinderConfig, PlannerSelectionMode mode) throws Exception + { + Field field = PathfinderConfig.class.getDeclaredField("plannerSelectionMode"); + field.setAccessible(true); + field.set(pathfinderConfig, mode); + } + + private static PathfinderConfig f2pConfig() throws Exception + { + Client client = mock(Client.class); + when(client.getWorldType()).thenReturn(EnumSet.noneOf(WorldType.class)); + PathfinderConfig pathfinderConfig = new PathfinderConfig( + SplitFlagMap.fromResources(), new HashMap<>(), Collections.emptyList(), client, null); + setCalculationCutoff(pathfinderConfig); + return pathfinderConfig; + } + + private static PathfinderConfig membersConfig() throws Exception + { + Client client = mock(Client.class); + when(client.getWorldType()).thenReturn(EnumSet.of(WorldType.MEMBERS)); + PathfinderConfig pathfinderConfig = new PathfinderConfig( + SplitFlagMap.fromResources(), new HashMap<>(), Collections.emptyList(), client, null); + setCalculationCutoff(pathfinderConfig); + return pathfinderConfig; + } + + private static void restoreProperty(String key, String value) + { + if (value == null) + { + System.clearProperty(key); + } + else + { + System.setProperty(key, value); + } + } + + @Test + public void synchronousPlanReturnsImmutableCompletedRoute() + { + WorldPoint start = new WorldPoint(3222, 3218, 0); + WorldPoint target = new WorldPoint(3232, 3218, 0); + Rs2RouteRequest request = Rs2RouteRequest.to(start, target) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER); + + Rs2RouteResult result = Rs2PathApi.planWithConfig(request, config); + + assertTrue("isolated pathfinder search must terminate", result.isSearchCompleted()); + assertEquals(Rs2RouteTermination.TARGET_REACHED, result.getTerminationReason()); + assertTrue("short Lumbridge walk must reach its exact target", result.isTargetReached(0)); + assertEquals(target, result.getEndpoint().orElse(null)); + assertEquals(result.getPath().size() - 1, result.getSteps().size()); + assertTrue(result.getSteps().stream().noneMatch(Rs2RouteStep::isTransport)); + assertTrue("search timing should be captured", result.getSearchNanos() > 0); + Rs2RouteMetrics metrics = result.getMetrics(); + assertEquals(result.getSearchNanos(), metrics.getSearchNanos()); + assertTrue("local planner must expose selected path cost", metrics.hasPathCost()); + assertEquals("ten-tile straight walk must cost ten", 10L, metrics.getPathCost()); + assertTrue("local planner must expose explored walking nodes", metrics.hasNodesChecked()); + assertTrue(metrics.getNodesChecked() > 0); + assertTrue("local planner must expose checked transport count", metrics.hasTransportsChecked()); + assertEquals(0L, metrics.getTransportsChecked()); + try + { + result.getPath().add(start); + fail("result path must be immutable"); + } + catch (UnsupportedOperationException expected) + { + // expected + } + try + { + result.getSteps().clear(); + fail("result steps must be immutable"); + } + catch (UnsupportedOperationException expected) + { + // expected + } + } + + @Test + public void localPlannerReceivesAnExplicitImmutablePolicySnapshot() + { + WorldPoint start = new WorldPoint(3222, 3218, 0); + WorldPoint target = new WorldPoint(3232, 3218, 0); + Rs2RouteRequest unresolved = Rs2RouteRequest.to(start, target) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER); + + assertTrue(unresolved.getPolicy().isEmpty()); + Rs2RouteRequest resolved = Rs2PathApi.resolvePolicy(unresolved, config); + Rs2RoutePolicy policy = resolved.getPolicy().orElseThrow(AssertionError::new); + + assertEquals(config.isUseBankItems(), policy.isUseBankItems()); + assertEquals(config.isAvoidWilderness(), policy.isAvoidWilderness()); + assertEquals(config.isAvoidDangerousNpcs(), policy.isAvoidDangerousNpcs()); + assertEquals(config.isIgnoreTeleportAndItems(), policy.isIgnoreTeleportAndItems()); + assertEquals(config.getCalculationCutoffMillis(), policy.getCalculationCutoffMillis()); + assertTrue(policy.getEnabledTransportTypes().contains(Rs2TransportType.TRANSPORT)); + try + { + policy.getEnabledTransportTypes().clear(); + fail("resolved transport policy must be immutable"); + } + catch (UnsupportedOperationException expected) + { + // expected + } + + Rs2RoutePlanner planner = Rs2PathApi.localPlanner(config); + assertEquals("microbot-local", planner.getEngineId()); + try + { + planner.plan(unresolved, Rs2PathApi.resolvePlanningSnapshot(resolved, config)); + fail("an engine must not receive a request backed by mutable globals"); + } + catch (IllegalArgumentException expected) + { + // expected + } + } + + @Test + public void pinnedUpstreamAdapterMatchesProductionBoundaryForStaticWalk() + { + WorldPoint start = new WorldPoint(3222, 3218, 0); + WorldPoint target = new WorldPoint(3232, 3218, 0); + Rs2RouteRequest request = Rs2PathApi.resolvePolicy( + Rs2RouteRequest.to(start, target) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + config); + Rs2PlanningSnapshot snapshot = Rs2PathApi.resolvePlanningSnapshot(request, config); + + Rs2RouteResult local = Rs2PathApi.localPlanner(config).plan(request, snapshot); + Rs2RoutePlanner upstreamPlanner = Rs2PathApi.upstreamPlanner(); + Rs2RouteResult upstream = upstreamPlanner.plan(request, snapshot); + Rs2PlannerShadowComparison comparison = Rs2PlannerShadowComparison.compare( + upstreamPlanner.getEngineId(), + Rs2PlannerShadowContext.from( + Rs2PlannerShadowContext.Invocation.SYNCHRONOUS_QUERY, + false, + request, + local), + local, + upstream); + + assertEquals(Rs2PlannerShadowComparison.Status.MATCH, comparison.getStatus()); + assertTrue(comparison.getShadowEngineId().contains(UpstreamRoutePlanner.REVISION)); + assertEquals(target, upstream.getEndpoint().orElse(null)); + assertEquals(10L, upstream.getMetrics().getPathCost()); + } + + @Test + public void packagedUpstreamCoreHasNoSecondRuneLitePluginOwner() + { + assertFalse(shortestpath.ShortestPathPlugin.class.isAnnotationPresent( + net.runelite.client.plugins.PluginDescriptor.class)); + assertTrue("upstream core must resolve the pinned root collision archive", + shortestpath.ShortestPathPlugin.class.getResource("/collision-map.zip") != null); + } + + @Test + public void pinnedUpstreamAdapterRetainsExactAmbiguousTransportIdentity() throws Exception + { + WorldPoint origin = new WorldPoint(3222, 3218, 0); + WorldPoint destination = new WorldPoint(3222, 9618, 0); + Transport slow = new Transport( + origin, destination, "slow", TransportType.TRANSPORT, false, + "Climb-down", "Tunnel", 1001, 9); + Transport fast = new Transport( + origin, destination, "fast", TransportType.TRANSPORT, false, + "Climb-down", "Tunnel", 1002, 3); + PathfinderConfig transportConfig = configWithTransports( + origin, new LinkedHashSet<>(List.of(slow, fast))); + Rs2RouteRequest request = Rs2PathApi.resolvePolicy( + Rs2RouteRequest.to(origin, destination) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + transportConfig); + Rs2PlanningSnapshot snapshot = Rs2PathApi.resolvePlanningSnapshot(request, transportConfig); + + Rs2RouteResult local = Rs2PathApi.localPlanner(transportConfig).plan(request, snapshot); + Rs2RouteResult upstream = Rs2PathApi.upstreamPlanner().plan(request, snapshot); + + assertSame(fast, local.getTransportSteps().get(0).getTransport() + .orElseThrow(AssertionError::new).getSourceIdentity()); + assertSame(fast, upstream.getTransportSteps().get(0).getTransport() + .orElseThrow(AssertionError::new).getSourceIdentity()); + Pathfinder materialized = Rs2PathApi.materializeUpstreamRoute( + upstream, transportConfig); + List selections = + Rs2PathApi.getTransportSelections(materialized, upstream.getPath()); + assertEquals(1, selections.size()); + assertSame("materialization must preserve the exact executable catalog object", + fast, selections.get(0).getLocalExecutionTransport()); + assertEquals(Rs2PlannerShadowComparison.Status.MATCH, + Rs2PlannerShadowComparison.compare( + "upstream", + Rs2PlannerShadowContext.from( + Rs2PlannerShadowContext.Invocation.SYNCHRONOUS_QUERY, + false, + request, + local), + local, + upstream).getStatus()); + } + + @Test + public void pinnedUpstreamAdapterMatchesLocalPlannerForEdgevilleBankCanoeLeg() throws Exception + { + WorldPoint start = new WorldPoint(3094, 3492, 0); + WorldPoint target = new WorldPoint(3199, 3344, 0); + Map> canoes = new HashMap<>(); + for (Map.Entry> entry + : Transport.loadAllFromResources().entrySet()) + { + if (entry.getKey() == null) + { + continue; + } + Set selected = new LinkedHashSet<>(); + for (Transport transport : entry.getValue()) + { + if (transport.getType() == TransportType.CANOE) + { + selected.add(transport); + } + } + if (!selected.isEmpty()) + { + canoes.put(entry.getKey(), selected); + } + } + PathfinderConfig transportConfig = configWithTransportCatalog(canoes); + transportConfig.setUseBankItems(true); + Rs2RouteRequest request = Rs2PathApi.resolvePolicy( + Rs2RouteRequest.to(start, target) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER) + .withPurpose(Rs2RouteRequest.Purpose.BANK_ROUTE_FROM_BANK), + transportConfig); + Rs2PlanningSnapshot snapshot = Rs2PathApi.resolvePlanningSnapshot(request, transportConfig); + + Rs2RouteResult local = Rs2PathApi.localPlanner(transportConfig).plan(request, snapshot); + Rs2RouteResult upstream = Rs2PathApi.upstreamPlanner().plan(request, snapshot); + + assertEquals(Rs2RouteTermination.TARGET_REACHED, local.getTerminationReason()); + assertEquals(Rs2RouteTermination.TARGET_REACHED, upstream.getTerminationReason()); + assertEquals("the real bank-to-target leg must have the same route cost", + local.getMetrics().getPathCost(), upstream.getMetrics().getPathCost()); + assertEquals("the real bank-to-target leg must select the same exact canoe edge", + local.getTransportSteps().stream() + .map(step -> step.getTransport().orElseThrow(AssertionError::new).getSourceIdentity()) + .collect(java.util.stream.Collectors.toList()), + upstream.getTransportSteps().stream() + .map(step -> step.getTransport().orElseThrow(AssertionError::new).getSourceIdentity()) + .collect(java.util.stream.Collectors.toList())); + } + + @Test + public void pinnedUpstreamAdapterConsumesImmutableCollisionOverride() + { + WorldPoint start = new WorldPoint(3222, 3218, 0); + WorldPoint target = new WorldPoint(3232, 3218, 0); + Rs2RouteRequest request = Rs2PathApi.resolvePolicy( + Rs2RouteRequest.to(start, target) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + config); + Rs2PlanningSnapshot base = Rs2PathApi.resolvePlanningSnapshot(request, config); + Rs2PlanningSnapshot closedArea = new Rs2PlanningSnapshot( + base.getPolicy(), + base.getAdmittedTransports(), + (x, y, plane, flag) -> plane == start.getPlane() ? Boolean.FALSE : null, + Collections.emptySet(), + packed -> false); + + Rs2RouteResult result = Rs2PathApi.upstreamPlanner().plan(request, closedArea); + + assertFalse(result.isTargetReached(0)); + assertEquals(Rs2RouteTermination.SEARCH_EXHAUSTED, result.getTerminationReason()); + } + + @Test + public void shadowFailurePublishesOnlyTheFailureType() + { + WorldPoint start = new WorldPoint(3222, 3218, 0); + WorldPoint target = new WorldPoint(3232, 3218, 0); + Rs2RouteRequest request = Rs2PathApi.resolvePolicy( + Rs2RouteRequest.to(start, target) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + config); + Rs2RouteResult local = Rs2PathApi.localPlanner(config).plan( + request, Rs2PathApi.resolvePlanningSnapshot(request, config)); + Rs2PlannerShadowComparison comparison = Rs2PlannerShadowComparison.failed( + "upstream", + Rs2PlannerShadowContext.from( + Rs2PlannerShadowContext.Invocation.SYNCHRONOUS_QUERY, + false, + request, + local), + local, + new IllegalStateException("sensitive runtime detail")); + + assertEquals(Rs2PlannerShadowComparison.Status.FAILED, comparison.getStatus()); + assertEquals("IllegalStateException", comparison.getFailureType()); + assertFalse(comparison.getFailureType().contains("sensitive")); + } + + @Test + public void selectedTransportIsPreservedAsOwnedImmutableStep() throws Exception + { + WorldPoint origin = new WorldPoint(3222, 3218, 0); + WorldPoint destination = new WorldPoint(3222, 3218, 1); + Transport stairs = new Transport( + origin, destination, "Upper floor", TransportType.TRANSPORT, false, + "Climb-up", "Staircase", 16671); + PathfinderConfig transportConfig = configWithTransport(origin, stairs); + + Rs2RouteResult result = Rs2PathApi.planWithConfig( + Rs2RouteRequest.to(origin, destination) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + transportConfig); + + assertEquals(Rs2RouteTermination.TARGET_REACHED, result.getTerminationReason()); + assertEquals(java.util.List.of(origin, destination), result.getPath()); + assertEquals(1, result.getSteps().size()); + Rs2RouteStep step = result.getSteps().get(0); + assertTrue(step.isTransport()); + Rs2TransportEdge edge = step.getTransport().orElseThrow(AssertionError::new); + assertEquals(Rs2TransportType.TRANSPORT, edge.getType()); + assertEquals(origin, edge.getOrigin()); + assertEquals(destination, edge.getDestination()); + assertEquals("Climb-up", edge.getAction()); + assertEquals("Staircase", edge.getTarget()); + assertEquals(16671, edge.getObjectId()); + assertFalse(edge.isTeleport()); + assertSame("the local adapter must retain exact source identity opaquely", + stairs, edge.getSourceIdentity()); + + Transport indistinguishableReplacement = new Transport( + origin, destination, "Upper floor", TransportType.TRANSPORT, false, + "Climb-up", "Staircase", 16671); + transportConfig.getTransports().put(origin, Set.of(indistinguishableReplacement)); + assertEquals("catalog refresh must not alter the selected immutable edge", + "Staircase", edge.getTarget()); + assertEquals(16671, edge.getObjectId()); + } + + @Test + public void activeExecutorSelectionUsesExactPlannerChoiceWithoutCatalogRematch() throws Exception + { + WorldPoint origin = new WorldPoint(3222, 3218, 0); + WorldPoint destination = new WorldPoint(3222, 9618, 0); + Transport slow = new Transport( + origin, destination, "slow shared edge", TransportType.TRANSPORT, false, + "Climb-down", "Tunnel", 1001, 9); + Transport fast = new Transport( + origin, destination, "fast shared edge", TransportType.TRANSPORT, false, + "Climb-down", "Tunnel", 1002, 3); + PathfinderConfig transportConfig = configWithTransports( + origin, new LinkedHashSet<>(List.of(slow, fast))); + Pathfinder pathfinder = new Pathfinder(transportConfig, origin, Set.of(destination)); + pathfinder.run(); + Transport replacement = new Transport( + origin, destination, "replacement shared edge", TransportType.TRANSPORT, false, + "Climb-down", "Tunnel", 1003, 1); + transportConfig.getTransports().put(origin, Set.of(replacement)); + + List selections = + Rs2PathApi.getTransportSelections(pathfinder, pathfinder.getPath()); + + assertEquals(1, selections.size()); + Rs2PathApi.ActiveTransportSelection selected = selections.get(0); + assertEquals(0, selected.getPathIndex()); + assertSame("local execution adapter must retain the exact selected object", fast, + selected.getLocalExecutionTransport()); + assertEquals(Rs2TransportExecutor.OBJECT, selected.getExecutor()); + assertTrue(selected.isExecutable()); + assertEquals("fast shared edge", selected.getEdge().getDisplayInfo()); + assertTrue("a stale/different route must not inherit the selection", + Rs2PathApi.getTransportSelections(pathfinder, List.of(origin)).isEmpty()); + } + + @Test + public void balloonRouteRetainsItsDedicatedRuntimeExecutor() throws Exception + { + WorldPoint origin = new WorldPoint(2461, 3111, 0); + WorldPoint destination = new WorldPoint(3299, 3482, 0); + Transport balloon = new Transport( + origin, destination, "Varrock", TransportType.HOT_AIR_BALLOON, true, + "Use", "Basket", 19129, 7); + PathfinderConfig transportConfig = configWithTransport(origin, balloon); + + Rs2RouteResult result = Rs2PathApi.planWithConfig( + Rs2RouteRequest.to(origin, destination) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + transportConfig); + + assertEquals(Rs2RouteTermination.TARGET_REACHED, result.getTerminationReason()); + assertEquals(List.of(origin, destination), result.getPath()); + Rs2TransportEdge edge = result.getSteps().get(0).getTransport() + .orElseThrow(AssertionError::new); + assertEquals(Rs2TransportType.HOT_AIR_BALLOON, edge.getType()); + assertEquals(Rs2TransportExecutor.HOT_AIR_BALLOON, edge.getExecutor()); + assertEquals("Varrock", edge.getDisplayInfo()); + } + + @Test + public void catalogQueriesHideConcreteMutableTransportGraph() + { + WorldPoint origin = new WorldPoint(3200, 3200, 0); + WorldPoint destination = new WorldPoint(3200, 3200, 1); + Transport stairs = new Transport( + origin, destination, "Upper floor", TransportType.TRANSPORT, false, + "Climb-up", "Staircase", 16671); + Map> catalog = new HashMap<>(); + catalog.put(origin, new LinkedHashSet<>(List.of(stairs))); + + assertTrue(Rs2PathApi.hasCatalogTransportOrigin(catalog, origin)); + assertTrue(Rs2PathApi.hasCatalogTransportEdge(catalog, origin, destination)); + assertFalse(Rs2PathApi.hasCatalogTransportEdge( + catalog, origin, new WorldPoint(3201, 3200, 0))); + List edges = Rs2PathApi.getCatalogTransportEdges(catalog, origin); + assertEquals(1, edges.size()); + assertEquals(destination, edges.get(0).getDestination()); + assertEquals("Staircase", edges.get(0).getTarget()); + + catalog.get(origin).clear(); + assertEquals("the returned catalog view must not alias the mutable graph", 1, edges.size()); + try + { + edges.clear(); + fail("catalog edge views must be immutable"); + } + catch (UnsupportedOperationException expected) + { + // expected + } + } + + @Test + public void bidirectionalTransportRouteRetainsSearchCost() throws Exception + { + WorldPoint origin = new WorldPoint(3222, 3218, 0); + WorldPoint destination = new WorldPoint(3222, 9618, 0); + Transport tunnel = new Transport( + origin, destination, "Synthetic long-band transition", + TransportType.TRANSPORT, false, 7); + PathfinderConfig transportConfig = configWithTransport(origin, tunnel); + + Rs2RouteResult result = Rs2PathApi.planWithConfig( + Rs2RouteRequest.to(origin, destination) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + transportConfig); + + assertEquals(Rs2RouteTermination.TARGET_REACHED, result.getTerminationReason()); + assertEquals(java.util.List.of(origin, destination), result.getPath()); + assertTrue(result.getSteps().get(0).isTransport()); + assertEquals("selected transport duration must be the joined route cost", + 7L, result.getMetrics().getPathCost()); + } + + @Test + public void requestDefensivelyCopiesMultipleTargets() + { + WorldPoint start = new WorldPoint(3222, 3218, 0); + WorldPoint first = new WorldPoint(3232, 3218, 0); + WorldPoint second = new WorldPoint(3222, 3228, 0); + Set mutableTargets = new LinkedHashSet<>(Set.of(first, second)); + + Rs2RouteRequest request = Rs2RouteRequest.toAny(start, mutableTargets); + mutableTargets.clear(); + + assertEquals(Set.of(first, second), request.getTargets()); + assertEquals(Rs2RouteRequest.RefreshPolicy.IF_TRANSPORTS_EMPTY, request.getRefreshPolicy()); + assertFalse(request.getUseBankItems() != null); + } + + @Test + public void bankPolicyForcesRefreshWithoutExposingConfig() + { + Rs2RouteRequest request = Rs2RouteRequest.to( + new WorldPoint(3222, 3218, 0), new WorldPoint(3232, 3218, 0)) + .withBankItems(true); + + assertEquals(Boolean.TRUE, request.getUseBankItems()); + assertEquals(Rs2RouteRequest.RefreshPolicy.ALWAYS, request.getRefreshPolicy()); + } + + @Test + public void publicPlanRestoresTemporaryBankPolicy() throws Exception + { + RecordingPathfinderConfig recording = new RecordingPathfinderConfig(); + setCalculationCutoff(recording); + PathfinderConfig original = ShortestPathPlugin.pathfinderConfig; + WorldPoint refreshTarget = new WorldPoint(3232, 3218, 0); + try + { + ShortestPathPlugin.pathfinderConfig = recording; + Rs2RouteResult result = Rs2PathApi.plan( + Rs2RouteRequest.to( + new WorldPoint(3222, 3218, 0), + refreshTarget) + .withRefreshTarget(refreshTarget) + .withBankItems(true)); + + assertTrue(result.isTargetReached(0)); + assertFalse("shared config must be restored after bank-aware planning", + recording.isUseBankItems()); + assertEquals("refresh must observe the temporary policy and then its restoration", + java.util.List.of(Boolean.TRUE, Boolean.FALSE), recording.refreshPolicies); + assertEquals("policy restoration must retain the caller's refresh target", + java.util.List.of(refreshTarget, refreshTarget), recording.refreshTargets); + } + finally + { + ShortestPathPlugin.pathfinderConfig = original; + } + } + + @Test + public void unchangedBankPolicyDoesNotPerformARedundantRestoreRefresh() throws Exception + { + RecordingPathfinderConfig recording = new RecordingPathfinderConfig(); + setCalculationCutoff(recording); + PathfinderConfig original = ShortestPathPlugin.pathfinderConfig; + WorldPoint target = new WorldPoint(3232, 3218, 0); + try + { + ShortestPathPlugin.pathfinderConfig = recording; + Rs2PathApi.plan( + Rs2RouteRequest.to(new WorldPoint(3222, 3218, 0), target) + .withRefreshTarget(target) + .withBankItems(false)); + + assertEquals(java.util.List.of(Boolean.FALSE), recording.refreshPolicies); + assertEquals(java.util.List.of(target), recording.refreshTargets); + } + finally + { + ShortestPathPlugin.pathfinderConfig = original; + } + } + + @Test + public void namedRuntimePolicyOperationsOwnMutableConfiguration() + { + PathfinderConfig original = ShortestPathPlugin.pathfinderConfig; + PathfinderConfig recording = mock(PathfinderConfig.class); + WorldPoint origin = new WorldPoint(3200, 3200, 0); + WorldPoint destination = new WorldPoint(3201, 3200, 0); + WorldPoint target = new WorldPoint(3232, 3218, 0); + when(recording.isAvoidDangerousNpcs()).thenReturn(true); + when(recording.isDangerousAdjacentTile(WorldPointUtil.packWorldPoint(origin))).thenReturn(true); + when(recording.isUseSpiritTrees()).thenReturn(true); + when(recording.learnBlockedEdge(origin, destination, "stable failure")).thenReturn(true); + try + { + ShortestPathPlugin.pathfinderConfig = recording; + + assertTrue(Rs2PathApi.shouldAvoidDangerousTile(origin)); + assertTrue(Rs2PathApi.isSpiritTreeTravelEnabled()); + assertTrue(Rs2PathApi.learnBlockedEdge(origin, destination, "stable failure")); + assertTrue(Rs2PathApi.refreshPlanningConfiguration()); + assertTrue(Rs2PathApi.invalidateTransportRefreshCache()); + assertTrue(Rs2PathApi.prepareInventoryOnlyRoute(target)); + + verify(recording).learnBlockedEdge(origin, destination, "stable failure"); + verify(recording).refresh((WorldPoint) null); + verify(recording).invalidateTransportRefreshCache(); + verify(recording).setUseBankItems(false); + verify(recording).refresh(target); + } + finally + { + ShortestPathPlugin.pathfinderConfig = original; + } + } + + @Test + public void teleportItemClassificationIncludesCatalogAndCompatibilityItems() + { + PathfinderConfig original = ShortestPathPlugin.pathfinderConfig; + PathfinderConfig recording = mock(PathfinderConfig.class); + Transport teleport = new Transport( + new WorldPoint(3210, 3210, 0), + "Synthetic teleport", + TransportType.TELEPORTATION_ITEM, + false, + 0, + Set.of(Set.of(1234))); + Map> catalog = new HashMap<>(); + catalog.put(null, Set.of(teleport)); + when(recording.getAllTransports()).thenReturn(catalog); + try + { + ShortestPathPlugin.pathfinderConfig = recording; + + assertTrue(Rs2PathApi.isTeleportItem(1234, 5678)); + assertTrue(Rs2PathApi.isTeleportItem(5678, 5678)); + assertFalse(Rs2PathApi.isTeleportItem(9999, 5678)); + } + finally + { + ShortestPathPlugin.pathfinderConfig = original; + } + } + + @Test + public void caveRouteSelectionChecksEveryRequestedTarget() + { + Pathfinder normal = mock(Pathfinder.class); + Pathfinder walkingOnly = mock(Pathfinder.class); + WorldPoint start = new WorldPoint(3200, 3200, 0); + WorldPoint firstTarget = new WorldPoint(3300, 3300, 0); + WorldPoint reachedSecondTarget = new WorldPoint(3202, 3200, 0); + when(normal.getPath()).thenReturn(List.of( + start, + new WorldPoint(3201, 3201, 0), + new WorldPoint(3202, 3201, 0), + new WorldPoint(3203, 3201, 0))); + when(walkingOnly.getPath()).thenReturn(List.of( + start, + new WorldPoint(3201, 3200, 0), + reachedSecondTarget)); + + Pathfinder selected = Rs2PathApi.selectCaveRoute( + normal, + walkingOnly, + new LinkedHashSet<>(List.of(firstTarget, reachedSecondTarget)), + 0); + + assertSame("a reachable non-first target must qualify the walking-only route", + walkingOnly, selected); + } + + @Test(expected = IllegalArgumentException.class) + public void activeRouteRejectsSynchronousShadowInvocation() + { + Rs2PathApi.restartActiveRoute( + Rs2RouteRequest.to( + new WorldPoint(3222, 3218, 0), + new WorldPoint(3232, 3218, 0)), + false, + 0, + Rs2PlannerShadowContext.Invocation.SYNCHRONOUS_QUERY); + } + + @Test + public void canarySelectsRouteShapeOnlySemanticMatchAndRejectsCostDivergence() + throws Exception + { + PathfinderConfig f2pConfig = f2pConfig(); + WorldPoint start = new WorldPoint(3200, 3200, 0); + WorldPoint target = new WorldPoint(3202, 3200, 0); + Rs2RouteRequest request = Rs2PathApi.resolvePolicy( + Rs2RouteRequest.to(start, target) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + f2pConfig); + Rs2RouteResult local = new Rs2RouteResult( + start, + Set.of(target), + List.of(start, new WorldPoint(3201, 3200, 0), target), + List.of( + Rs2RouteStep.walk(start, new WorldPoint(3201, 3200, 0)), + Rs2RouteStep.walk(new WorldPoint(3201, 3200, 0), target)), + Rs2RouteTermination.TARGET_REACHED, + new Rs2RouteMetrics(10L, 2L, 3L, 0L)); + WorldPoint alternate = new WorldPoint(3201, 3201, 0); + Rs2RouteResult equalCostAlternate = new Rs2RouteResult( + start, + Set.of(target), + List.of(start, alternate, target), + List.of(Rs2RouteStep.walk(start, alternate), Rs2RouteStep.walk(alternate, target)), + Rs2RouteTermination.TARGET_REACHED, + new Rs2RouteMetrics(9L, 2L, 2L, 0L)); + Rs2PlannerShadowContext context = Rs2PlannerShadowContext.from( + Rs2PlannerShadowContext.Invocation.SYNCHRONOUS_QUERY, + false, + request, + local); + + Rs2PlannerShadowComparison match = Rs2PlannerShadowComparison.compare( + "upstream", context, local, equalCostAlternate); + assertEquals(Rs2PlannerShadowComparison.Status.MATCH, match.getStatus()); + assertFalse(match.isPathMatches()); + assertTrue(Rs2PathApi.shouldSelectUpstream(match)); + + Rs2RouteResult higherCost = new Rs2RouteResult( + start, + Set.of(target), + equalCostAlternate.getPath(), + equalCostAlternate.getSteps(), + Rs2RouteTermination.TARGET_REACHED, + new Rs2RouteMetrics(9L, 3L, 2L, 0L)); + Rs2PlannerShadowComparison divergence = Rs2PlannerShadowComparison.compare( + "upstream", context, local, higherCost); + assertEquals(Rs2PlannerShadowComparison.Status.DIVERGENCE, divergence.getStatus()); + assertFalse(Rs2PathApi.shouldSelectUpstream(divergence)); + } + + @Test + public void f2pCanaryEligibilityUsesResolvedWorldPolicy() throws Exception + { + PathfinderConfig f2pConfig = f2pConfig(); + Rs2RouteRequest f2p = Rs2PathApi.resolvePolicy( + Rs2RouteRequest.to( + new WorldPoint(3200, 3200, 0), new WorldPoint(3201, 3200, 0)), + f2pConfig); + assertTrue(Rs2PathApi.isF2pCanary( + PlannerSelectionMode.UPSTREAM_F2P_CANARY, f2p)); + + Client membersClient = mock(Client.class); + when(membersClient.getWorldType()).thenReturn(EnumSet.of(WorldType.MEMBERS)); + PathfinderConfig membersConfig = new PathfinderConfig( + SplitFlagMap.fromResources(), new HashMap<>(), Collections.emptyList(), + membersClient, null); + setCalculationCutoff(membersConfig); + Rs2RouteRequest members = Rs2PathApi.resolvePolicy( + Rs2RouteRequest.to( + new WorldPoint(3200, 3200, 0), new WorldPoint(3201, 3200, 0)), + membersConfig); + assertFalse(Rs2PathApi.isF2pCanary( + PlannerSelectionMode.UPSTREAM_F2P_CANARY, members)); + assertFalse(Rs2PathApi.isF2pCanary(PlannerSelectionMode.SHADOW, f2p)); + } + + @Test + public void activeRouteRemainsCalculatingUntilSelectionFutureCompletes() + { + Pathfinder originalPathfinder = Rs2PathApi.getPathfinder(); + Future originalFuture = Rs2PathApi.getPathfinderFuture(); + WorldPoint start = new WorldPoint(3222, 3218, 0); + WorldPoint target = new WorldPoint(3232, 3218, 0); + Pathfinder completed = new Pathfinder(config, start, target); + completed.run(); + Future selectionFuture = mock(Future.class); + when(selectionFuture.isDone()).thenReturn(false); + try + { + Rs2PathApi.setPathfinder(completed); + Rs2PathApi.setPathfinderFuture(selectionFuture); + assertTrue(Rs2PathApi.getActiveRouteStatus().isCalculating()); + assertTrue(Rs2PathApi.getActiveRoute().isEmpty()); + + when(selectionFuture.isDone()).thenReturn(true); + assertTrue(Rs2PathApi.getActiveRouteStatus().isReady()); + assertEquals(target, Rs2PathApi.getActiveRoute() + .flatMap(Rs2RouteResult::getEndpoint).orElse(null)); + } + finally + { + Rs2PathApi.setPathfinder(originalPathfinder); + Rs2PathApi.setPathfinderFuture(originalFuture); + } + } + + @Test + public void activeF2pCanarySelectsPinnedUpstreamRoute() throws Exception + { + PathfinderConfig activeConfig = f2pConfig(); + setPlannerMode(activeConfig, PlannerSelectionMode.UPSTREAM_F2P_CANARY); + String originalFailure = System.getProperty( + "microbot.test.walker.forceUpstreamPlannerFailure"); + System.clearProperty("microbot.test.walker.forceUpstreamPlannerFailure"); + + PathfinderConfig originalConfig = ShortestPathPlugin.pathfinderConfig; + Pathfinder originalPathfinder = Rs2PathApi.getPathfinder(); + Future originalFuture = Rs2PathApi.getPathfinderFuture(); + ExecutorService originalExecutor = Rs2PathApi.getPathfindingExecutor(); + ExecutorService activeExecutor = Executors.newSingleThreadExecutor(); + Rs2PlannerShadowStats before = Rs2PathApi.getShadowStats(); + WorldPoint target = new WorldPoint(3232, 3218, 0); + try + { + ShortestPathPlugin.pathfinderConfig = activeConfig; + Rs2PathApi.setPathfindingExecutor(activeExecutor); + assertTrue(Rs2PathApi.restartActiveRoute( + Rs2RouteRequest.to(new WorldPoint(3222, 3218, 0), target) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + false, + 0)); + assertTrue(Rs2PathApi.isActiveRouteComparisonEligible()); + long routeGeneration = Rs2PathApi.getActiveRouteStatus().getGeneration(); + assertTrue(Rs2PathApi.isActiveRouteComparisonEligible(routeGeneration)); + // The canary runs the local and upstream planners sequentially. Each inherits the + // 10-second calculation cutoff, so the lifecycle bound must cover both under a busy suite. + Rs2PathApi.getPathfinderFuture().get( + ACTIVE_ROUTE_TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + assertTrue(Rs2PathApi.getActiveRouteStatus().isReady()); + assertEquals(target, Rs2PathApi.getActiveRoute() + .flatMap(Rs2RouteResult::getEndpoint).orElse(null)); + Rs2PlannerShadowStats after = Rs2PathApi.getShadowStats(); + assertEquals(before.getSubmitted() + 1, after.getSubmitted()); + assertEquals(before.getCompleted() + 1, after.getCompleted()); + assertEquals(before.getUpstreamCanarySelections() + 1, + after.getUpstreamCanarySelections()); + assertEquals(before.getLocalFallbackDivergences(), + after.getLocalFallbackDivergences()); + assertEquals(before.getLocalFallbackFailures(), after.getLocalFallbackFailures()); + assertEquals(before.getCanaryPerformance().getPlanningSamples() + 1, + after.getCanaryPerformance().getPlanningSamples()); + assertEquals(before.getCanaryPerformance().getUpstreamSearchSamples() + 1, + after.getCanaryPerformance().getUpstreamSearchSamples()); + assertTrue(after.getCanaryPerformance().getPlanningNanosTotal() + > before.getCanaryPerformance().getPlanningNanosTotal()); + assertTrue(after.getCanaryPerformance().getLocalSearchNanosTotal() + > before.getCanaryPerformance().getLocalSearchNanosTotal()); + assertTrue(after.getCanaryPerformance().getUpstreamSearchNanosTotal() + > before.getCanaryPerformance().getUpstreamSearchNanosTotal()); + } + finally + { + activeExecutor.shutdownNow(); + Rs2PathApi.setPathfinder(originalPathfinder); + Rs2PathApi.setPathfinderFuture(originalFuture); + Rs2PathApi.setPathfindingExecutor(originalExecutor); + ShortestPathPlugin.pathfinderConfig = originalConfig; + restoreProperty("microbot.test.walker.forceUpstreamPlannerFailure", originalFailure); + } + } + + @Test + public void activeF2pCanaryFallsBackOnForcedUpstreamFailure() throws Exception + { + PathfinderConfig activeConfig = f2pConfig(); + setPlannerMode(activeConfig, PlannerSelectionMode.UPSTREAM_F2P_CANARY); + String originalTestMode = System.getProperty("microbot.test.mode"); + String originalFailure = System.getProperty( + "microbot.test.walker.forceUpstreamPlannerFailure"); + System.setProperty("microbot.test.mode", "true"); + System.setProperty("microbot.test.walker.forceUpstreamPlannerFailure", "true"); + + PathfinderConfig originalConfig = ShortestPathPlugin.pathfinderConfig; + Pathfinder originalPathfinder = Rs2PathApi.getPathfinder(); + Future originalFuture = Rs2PathApi.getPathfinderFuture(); + ExecutorService originalExecutor = Rs2PathApi.getPathfindingExecutor(); + ExecutorService activeExecutor = Executors.newSingleThreadExecutor(); + Rs2PlannerShadowStats before = Rs2PathApi.getShadowStats(); + WorldPoint target = new WorldPoint(3232, 3218, 0); + try + { + ShortestPathPlugin.pathfinderConfig = activeConfig; + Rs2PathApi.setPathfindingExecutor(activeExecutor); + assertTrue(Rs2PathApi.restartActiveRoute( + Rs2RouteRequest.to(new WorldPoint(3222, 3218, 0), target) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + false, + 0)); + assertTrue(Rs2PathApi.isActiveRouteComparisonEligible()); + Rs2PathApi.getPathfinderFuture().get( + ACTIVE_ROUTE_TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + assertTrue("the local rollback route must remain executable", + Rs2PathApi.getActiveRouteStatus().isReady()); + assertEquals(target, Rs2PathApi.getActiveRoute() + .flatMap(Rs2RouteResult::getEndpoint).orElse(null)); + Rs2PlannerShadowStats after = Rs2PathApi.getShadowStats(); + assertEquals(before.getFailures() + 1, after.getFailures()); + assertEquals(before.getLocalFallbackFailures() + 1, + after.getLocalFallbackFailures()); + assertEquals(before.getUpstreamCanarySelections(), + after.getUpstreamCanarySelections()); + assertEquals(before.getCanaryPerformance().getPlanningSamples() + 1, + after.getCanaryPerformance().getPlanningSamples()); + assertEquals(before.getCanaryPerformance().getUpstreamSearchSamples(), + after.getCanaryPerformance().getUpstreamSearchSamples()); + } + finally + { + activeExecutor.shutdownNow(); + Rs2PathApi.setPathfinder(originalPathfinder); + Rs2PathApi.setPathfinderFuture(originalFuture); + Rs2PathApi.setPathfindingExecutor(originalExecutor); + ShortestPathPlugin.pathfinderConfig = originalConfig; + restoreProperty("microbot.test.mode", originalTestMode); + restoreProperty("microbot.test.walker.forceUpstreamPlannerFailure", originalFailure); + } + } + + @Test + public void activeF2pCaveCanaryCountsBothLocalCandidateSearches() throws Exception + { + PathfinderConfig activeConfig = f2pConfig(); + setPlannerMode(activeConfig, PlannerSelectionMode.UPSTREAM_F2P_CANARY); + String originalFailure = System.getProperty( + "microbot.test.walker.forceUpstreamPlannerFailure"); + System.clearProperty("microbot.test.walker.forceUpstreamPlannerFailure"); + PathfinderConfig originalConfig = ShortestPathPlugin.pathfinderConfig; + Pathfinder originalPathfinder = Rs2PathApi.getPathfinder(); + Future originalFuture = Rs2PathApi.getPathfinderFuture(); + Rs2PlannerShadowStats before = Rs2PathApi.getShadowStats(); + try + { + ShortestPathPlugin.pathfinderConfig = activeConfig; + assertTrue(Rs2PathApi.restartActiveRoute( + Rs2RouteRequest.to( + new WorldPoint(3222, 3218, 0), new WorldPoint(3232, 3218, 0)) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + true, + 0)); + + Rs2PlannerShadowStats after = Rs2PathApi.getShadowStats(); + Rs2PlannerShadowComparison comparison = Rs2PathApi.getLastShadowComparison() + .orElseThrow(AssertionError::new); + long localPlanningDelta = after.getCanaryPerformance().getLocalSearchNanosTotal() + - before.getCanaryPerformance().getLocalSearchNanosTotal(); + assertEquals(before.getCanaryPerformance().getPlanningSamples() + 1, + after.getCanaryPerformance().getPlanningSamples()); + assertTrue("cave timing must include the unselected local candidate search", + localPlanningDelta > comparison.getLocalSearchNanos()); + } + finally + { + Rs2PathApi.setPathfinder(originalPathfinder); + Rs2PathApi.setPathfinderFuture(originalFuture); + ShortestPathPlugin.pathfinderConfig = originalConfig; + restoreProperty("microbot.test.walker.forceUpstreamPlannerFailure", originalFailure); + } + } + + @Test + public void activeMembersRouteInF2pCanaryDoesNotPolluteExecutionEvidence() throws Exception + { + PathfinderConfig activeConfig = membersConfig(); + setPlannerMode(activeConfig, PlannerSelectionMode.UPSTREAM_F2P_CANARY); + PathfinderConfig originalConfig = ShortestPathPlugin.pathfinderConfig; + Pathfinder originalPathfinder = Rs2PathApi.getPathfinder(); + Future originalFuture = Rs2PathApi.getPathfinderFuture(); + ExecutorService originalExecutor = Rs2PathApi.getPathfindingExecutor(); + ExecutorService activeExecutor = Executors.newSingleThreadExecutor(); + Rs2PlannerShadowStats before = Rs2PathApi.getShadowStats(); + try + { + ShortestPathPlugin.pathfinderConfig = activeConfig; + Rs2PathApi.setPathfindingExecutor(activeExecutor); + assertTrue(Rs2PathApi.restartActiveRoute( + Rs2RouteRequest.to( + new WorldPoint(3222, 3218, 0), new WorldPoint(3232, 3218, 0)) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + false, + 0)); + assertFalse(Rs2PathApi.isActiveRouteComparisonEligible()); + assertFalse(Rs2PathApi.isActiveRouteComparisonEligible( + Rs2PathApi.getActiveRouteStatus().getGeneration())); + Rs2PathApi.getPathfinderFuture().get( + ACTIVE_ROUTE_TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + Rs2PlannerShadowStats afterRoute = Rs2PathApi.getShadowStats(); + assertEquals(before.getSubmitted(), afterRoute.getSubmitted()); + assertEquals(before.getCanaryPerformance().getPlanningSamples(), + afterRoute.getCanaryPerformance().getPlanningSamples()); + Rs2PathApi.recordShadowWalkerOutcome(WalkerState.ARRIVED, false, false); + Rs2PlannerShadowStats afterOutcome = Rs2PathApi.getShadowStats(); + assertEquals(before.getExecution().getArrived(), + afterOutcome.getExecution().getArrived()); + } + finally + { + activeExecutor.shutdownNow(); + Rs2PathApi.setPathfinder(originalPathfinder); + Rs2PathApi.setPathfinderFuture(originalFuture); + Rs2PathApi.setPathfindingExecutor(originalExecutor); + ShortestPathPlugin.pathfinderConfig = originalConfig; + } + } + + @Test + public void activeLocalRouteDoesNotAdmitComparisonExecutionEvidence() throws Exception + { + PathfinderConfig activeConfig = f2pConfig(); + setPlannerMode(activeConfig, PlannerSelectionMode.LOCAL); + PathfinderConfig originalConfig = ShortestPathPlugin.pathfinderConfig; + Pathfinder originalPathfinder = Rs2PathApi.getPathfinder(); + Future originalFuture = Rs2PathApi.getPathfinderFuture(); + ExecutorService originalExecutor = Rs2PathApi.getPathfindingExecutor(); + ExecutorService activeExecutor = Executors.newSingleThreadExecutor(); + Rs2PlannerShadowStats before = Rs2PathApi.getShadowStats(); + try + { + ShortestPathPlugin.pathfinderConfig = activeConfig; + Rs2PathApi.setPathfindingExecutor(activeExecutor); + assertTrue(Rs2PathApi.restartActiveRoute( + Rs2RouteRequest.to( + new WorldPoint(3222, 3218, 0), new WorldPoint(3232, 3218, 0)) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + false, + 0)); + assertFalse(Rs2PathApi.isActiveRouteComparisonEligible()); + assertFalse(Rs2PathApi.isActiveRouteComparisonEligible( + Rs2PathApi.getActiveRouteStatus().getGeneration())); + Rs2PathApi.getPathfinderFuture().get( + ACTIVE_ROUTE_TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertEquals(before.getSubmitted(), Rs2PathApi.getShadowStats().getSubmitted()); + assertEquals(before.getCanaryPerformance().getPlanningSamples(), + Rs2PathApi.getShadowStats().getCanaryPerformance().getPlanningSamples()); + } + finally + { + activeExecutor.shutdownNow(); + Rs2PathApi.setPathfinder(originalPathfinder); + Rs2PathApi.setPathfinderFuture(originalFuture); + Rs2PathApi.setPathfindingExecutor(originalExecutor); + ShortestPathPlugin.pathfinderConfig = originalConfig; + } + } + + @Test + public void activeWalkerRoutePublishesPinnedUpstreamShadowEvidence() throws Exception + { + PathfinderConfig activeConfig = new PathfinderConfig( + SplitFlagMap.fromResources(), new HashMap<>(), Collections.emptyList(), null, null); + setCalculationCutoff(activeConfig); + setPlannerMode(activeConfig, PlannerSelectionMode.SHADOW); + + PathfinderConfig originalConfig = ShortestPathPlugin.pathfinderConfig; + Pathfinder originalPathfinder = Rs2PathApi.getPathfinder(); + Future originalFuture = Rs2PathApi.getPathfinderFuture(); + ExecutorService originalExecutor = Rs2PathApi.getPathfindingExecutor(); + Field lastComparison = Rs2PathApi.class.getDeclaredField("lastShadowComparison"); + lastComparison.setAccessible(true); + Object originalComparison = lastComparison.get(null); + Rs2PlannerShadowStats statsBefore = Rs2PathApi.getShadowStats(); + ExecutorService activeExecutor = Executors.newSingleThreadExecutor(); + WorldPoint start = new WorldPoint(3222, 3218, 0); + WorldPoint target = new WorldPoint(3232, 3218, 0); + try + { + ShortestPathPlugin.pathfinderConfig = activeConfig; + Rs2PathApi.setPathfindingExecutor(activeExecutor); + lastComparison.set(null, null); + + assertTrue(Rs2PathApi.restartActiveRoute( + Rs2RouteRequest.to(start, target) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + false, + 0)); + Rs2PathApi.getPathfinderFuture().get( + ACTIVE_ROUTE_TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + Rs2PlannerShadowComparison comparison = null; + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (comparison == null && System.nanoTime() < deadline) + { + comparison = Rs2PathApi.getLastShadowComparison().orElse(null); + Thread.yield(); + } + + assertTrue("active route must publish a completed shadow comparison", + comparison != null); + assertEquals(Rs2PlannerShadowComparison.Status.MATCH, comparison.getStatus()); + assertTrue(comparison.getShadowEngineId().contains(UpstreamRoutePlanner.REVISION)); + assertEquals(Rs2PlannerShadowContext.Invocation.ACTIVE_ROUTE, + comparison.getContext().getInvocation()); + assertTrue(comparison.getContext().getCoverage().contains( + Rs2PlannerShadowContext.Coverage.SURFACE_COORDINATES_ONLY)); + assertTrue(comparison.getLocalSearchNanos() > 0L); + Rs2PlannerShadowStats statsAfter = Rs2PathApi.getShadowStats(); + assertEquals(statsBefore.getSubmitted() + 1, statsAfter.getSubmitted()); + assertEquals(statsBefore.getCompleted() + 1, statsAfter.getCompleted()); + assertEquals(statsBefore.getMatches() + 1, statsAfter.getMatches()); + assertEquals( + statsBefore.getCoverage().get(Rs2PlannerShadowContext.Coverage.ACTIVE_ROUTE) + .getMatches() + 1, + statsAfter.getCoverage().get(Rs2PlannerShadowContext.Coverage.ACTIVE_ROUTE) + .getMatches()); + assertEquals( + statsBefore.getCoverage().get( + Rs2PlannerShadowContext.Coverage.SURFACE_COORDINATES_ONLY).getMatches() + 1, + statsAfter.getCoverage().get( + Rs2PlannerShadowContext.Coverage.SURFACE_COORDINATES_ONLY).getMatches()); + assertEquals(0L, statsAfter.getPending()); + assertTrue(Rs2PathApi.isActiveRouteComparisonEligible()); + Rs2PathApi.recordShadowWalkerOutcome(WalkerState.ARRIVED, true, true); + Rs2PlannerShadowStats executionAfter = Rs2PathApi.getShadowStats(); + assertEquals(statsAfter.getExecution().getArrived() + 1, + executionAfter.getExecution().getArrived()); + assertEquals(statsAfter.getExecution().getRecoveryArrived() + 1, + executionAfter.getExecution().getRecoveryArrived()); + Rs2PathApi.setPathfinder(null); + assertTrue("route replacement must invalidate the previous latest evidence", + Rs2PathApi.getLastShadowComparison().isEmpty()); + } + finally + { + activeExecutor.shutdownNow(); + Rs2PathApi.setPathfinder(originalPathfinder); + Rs2PathApi.setPathfinderFuture(originalFuture); + Rs2PathApi.setPathfindingExecutor(originalExecutor); + ShortestPathPlugin.pathfinderConfig = originalConfig; + lastComparison.set(null, originalComparison); + } + } + + @Test + public void cancelAndClearActiveRouteOwnsConcretePlannerCancellation() + { + Pathfinder originalPathfinder = Rs2PathApi.getPathfinder(); + Future originalFuture = Rs2PathApi.getPathfinderFuture(); + Pathfinder active = mock(Pathfinder.class); + Future future = mock(Future.class); + when(future.isDone()).thenReturn(false); + try + { + Rs2PathApi.setPathfinder(active); + Rs2PathApi.setPathfinderFuture(future); + + Rs2PathApi.cancelAndClearActiveRoute(); + + verify(active).cancel(); + verify(future).cancel(true); + assertNull(Rs2PathApi.getPathfinder()); + assertNull(Rs2PathApi.getPathfinderFuture()); + } + finally + { + Rs2PathApi.setPathfinder(originalPathfinder); + Rs2PathApi.setPathfinderFuture(originalFuture); + } + } + + @Test + public void activeRouteStatusDefensivelySnapshotsCalculatingPlanner() + { + Pathfinder originalPathfinder = Rs2PathApi.getPathfinder(); + Pathfinder active = mock(Pathfinder.class); + WorldPoint start = new WorldPoint(3200, 3200, 0); + WorldPoint target = new WorldPoint(3210, 3200, 0); + List partialPath = new ArrayList<>(List.of(start, new WorldPoint(3201, 3200, 0))); + Set targets = new LinkedHashSet<>(Set.of(target)); + when(active.isDone()).thenReturn(false); + when(active.getStart()).thenReturn(start); + when(active.getTargets()).thenReturn(targets); + when(active.getPath()).thenReturn(partialPath); + try + { + long before = Rs2PathApi.getActiveRouteStatus().getGeneration(); + Rs2PathApi.setPathfinder(active); + + Rs2ActiveRouteStatus status = Rs2PathApi.getActiveRouteStatus(); + + assertEquals(Rs2ActiveRouteStatus.Phase.CALCULATING, status.getPhase()); + assertTrue(status.isPresent()); + assertTrue(status.isCalculating()); + assertTrue(status.getGeneration() > before); + assertEquals(start, status.getStart().orElse(null)); + assertEquals(Set.of(target), status.getTargets()); + assertEquals(partialPath, status.getRawPath()); + assertEquals(status.getRawPath(), status.getWalkablePath()); + partialPath.clear(); + targets.clear(); + assertEquals(2, status.getRawPath().size()); + assertEquals(Set.of(target), status.getTargets()); + try + { + status.getRawPath().clear(); + fail("active route path must be immutable"); + } + catch (UnsupportedOperationException expected) + { + // expected + } + } + finally + { + Rs2PathApi.setPathfinder(originalPathfinder); + } + } + + @Test + public void activeRouteStatusPublishesReadyMetricsWithoutPlannerType() + { + Pathfinder originalPathfinder = Rs2PathApi.getPathfinder(); + WorldPoint start = new WorldPoint(3222, 3218, 0); + WorldPoint target = new WorldPoint(3232, 3218, 0); + Pathfinder active = new Pathfinder(config, start, target); + active.run(); + try + { + Rs2PathApi.setPathfinder(active); + + Rs2ActiveRouteStatus status = Rs2PathApi.getActiveRouteStatus(); + + assertTrue(status.isReady()); + assertEquals(Rs2RouteTermination.TARGET_REACHED, + status.getTerminationReason().orElse(null)); + assertEquals(target, status.getEndpoint().orElse(null)); + Rs2RouteMetrics metrics = status.getMetrics().orElseThrow(AssertionError::new); + assertTrue(metrics.hasSearchNanos()); + assertTrue(metrics.getNodesChecked() > 0); + assertEquals(10L, metrics.getPathCost()); + } + finally + { + Rs2PathApi.setPathfinder(originalPathfinder); + } + } + + @Test + public void requestRejectsEmptyTargetsAndResultRejectsNegativeTolerance() + { + WorldPoint start = new WorldPoint(3222, 3218, 0); + try + { + Rs2RouteRequest.toAny(start, Collections.emptySet()); + fail("empty targets must be rejected"); + } + catch (IllegalArgumentException expected) + { + // expected + } + + Rs2RouteResult result = Rs2PathApi.planWithConfig( + Rs2RouteRequest.to(start, new WorldPoint(3232, 3218, 0)) + .withRefreshPolicy(Rs2RouteRequest.RefreshPolicy.NEVER), + config); + try + { + result.isTargetReached(-1); + fail("negative tolerance must be rejected"); + } + catch (IllegalArgumentException expected) + { + // expected + } + } + + @Test + public void failedPlannerIsNotReportedAsCompleted() + { + PathfinderConfig failingConfig = mock(PathfinderConfig.class); + CollisionMap failingMap = mock(CollisionMap.class); + when(failingConfig.getMap()).thenReturn(failingMap); + when(failingConfig.getCalculationCutoffMillis()).thenReturn(10_000L); + when(failingConfig.getEnabledTransportTypes()).thenReturn(Collections.emptySet()); + when(failingConfig.getRestrictedPointsPacked()).thenReturn(Collections.emptySet()); + when(failingConfig.getTeleportationItemPolicy()).thenReturn( + net.runelite.client.plugins.microbot.shortestpath.TeleportationItem.NONE); + when(failingConfig.getLiveCollisionOverlay()).thenReturn( + new net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionOverlay()); + when(failingMap.getNeighbors(any(Node.class), any(VisitedTiles.class), + eq(failingConfig), anySet())).thenThrow(new IllegalStateException("synthetic planner failure")); + + Rs2RouteResult result = Rs2PathApi.planWithConfig( + Rs2RouteRequest.to( + new WorldPoint(3222, 3218, 0), + new WorldPoint(3232, 3218, 0)), + failingConfig); + + assertEquals(Rs2RouteTermination.FAILED, result.getTerminationReason()); + assertFalse("a caught planner failure must not look completed", result.isSearchCompleted()); + assertEquals(Math.max(0, result.getPath().size() - 1), result.getSteps().size()); + } + + @Test + public void ownedItemRequirementDefensivelyCopiesAlternatives() + { + Map mutable = new HashMap<>(); + mutable.put(1, 2); + mutable.put(2, 2); + Rs2TransportItemRequirement requirement = new Rs2TransportItemRequirement(mutable); + mutable.clear(); + + assertEquals(Map.of(1, 2, 2, 2), requirement.getAlternatives()); + assertTrue(requirement.isSatisfiedBy(itemId -> itemId == 2 ? 2 : 0)); + assertFalse(requirement.isSatisfiedBy(itemId -> 1)); + try + { + requirement.getAlternatives().put(3, 2); + fail("owned item alternatives must be immutable"); + } + catch (UnsupportedOperationException expected) + { + // expected + } + } + + @Test + public void routeMetricsDistinguishUnavailableFromZero() + { + Rs2RouteMetrics metrics = new Rs2RouteMetrics( + Rs2RouteMetrics.UNAVAILABLE, + Rs2RouteMetrics.UNAVAILABLE, + 0L, + Rs2RouteMetrics.UNAVAILABLE); + + assertFalse(metrics.hasSearchNanos()); + assertEquals(Rs2RouteMetrics.UNAVAILABLE, metrics.getSearchNanos()); + assertFalse(metrics.hasPathCost()); + assertEquals(Rs2RouteMetrics.UNAVAILABLE, metrics.getPathCost()); + assertTrue(metrics.hasNodesChecked()); + assertEquals(0L, metrics.getNodesChecked()); + assertFalse(metrics.hasTransportsChecked()); + try + { + new Rs2RouteMetrics(-2L, 0L, 0L, 0L); + fail("negative metrics other than UNAVAILABLE must be rejected"); + } + catch (IllegalArgumentException expected) + { + // expected + } + } + + @SuppressWarnings("unchecked") + private static PathfinderConfig configWithTransport(WorldPoint origin, Transport transport) throws Exception + { + return configWithTransports(origin, Set.of(transport)); + } + + @SuppressWarnings("unchecked") + private static PathfinderConfig configWithTransports( + WorldPoint origin, Set transports) throws Exception + { + return configWithTransportCatalog(Map.of(origin, transports)); + } + + @SuppressWarnings("unchecked") + private static PathfinderConfig configWithTransportCatalog( + Map> catalog) throws Exception + { + PathfinderConfig pathfinderConfig = new PathfinderConfig( + SplitFlagMap.fromResources(), catalog, + Collections.emptyList(), null, null); + setCalculationCutoff(pathfinderConfig); + + Field transportsField = PathfinderConfig.class.getDeclaredField("transports"); + transportsField.setAccessible(true); + Map> activeTransports = + (Map>) transportsField.get(pathfinderConfig); + activeTransports.putAll(catalog); + + Field packedField = PathfinderConfig.class.getDeclaredField("transportsPacked"); + packedField.setAccessible(true); + PrimitiveIntHashMap> packed = + (PrimitiveIntHashMap>) packedField.get(pathfinderConfig); + for (Map.Entry> entry : catalog.entrySet()) + { + packed.put(WorldPointUtil.packWorldPoint(entry.getKey()), entry.getValue()); + } + return pathfinderConfig; + } + + private static final class RecordingPathfinderConfig extends PathfinderConfig + { + private final java.util.List refreshPolicies = new ArrayList<>(); + private final java.util.List refreshTargets = new ArrayList<>(); + + private RecordingPathfinderConfig() + { + super(SplitFlagMap.fromResources(), new HashMap<>(), Collections.emptyList(), null, null); + } + + @Override + public void refresh(WorldPoint target) + { + refreshPolicies.add(isUseBankItems()); + refreshTargets.add(target); + } + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerShadowContextTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerShadowContextTest.java new file mode 100644 index 00000000000..97217dc3537 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2PlannerShadowContextTest.java @@ -0,0 +1,213 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; +import org.junit.Test; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class Rs2PlannerShadowContextTest +{ + @Test + public void classifiesReplanUndergroundTransportAndResolvedPolicyWithoutCoordinates() + { + WorldPoint start = new WorldPoint(2876, 9878, 0); + WorldPoint target = new WorldPoint(2820, 9882, 0); + Rs2RoutePolicy policy = policy(true, true); + Rs2RouteRequest request = Rs2RouteRequest.to(start, target) + .withPurpose(Rs2RouteRequest.Purpose.BANK_ROUTE_FROM_BANK) + .withPolicy(policy); + Rs2TransportEdge transport = new Rs2TransportEdge( + start, + target, + Rs2TransportType.TRANSPORT, + Rs2TransportExecutor.OBJECT, + Rs2TerminalTravelMode.UNSUPPORTED, + "test", + "Climb", + "Stairs", + 1, + 1, + false, + false, + true, + 0, + "Coins", + 30, + Collections.emptyList(), + true, + true, + true, + null); + Rs2RouteResult result = new Rs2RouteResult( + start, + Set.of(target), + List.of(start, target), + List.of(Rs2RouteStep.transport(start, target, transport)), + Rs2RouteTermination.TARGET_REACHED, + new Rs2RouteMetrics(10L, 1L, 2L, 1L, 4L)); + + Rs2PlannerShadowContext context = Rs2PlannerShadowContext.from( + Rs2PlannerShadowContext.Invocation.RECOVERY_REPLAN, true, request, result); + + assertEquals(Rs2PlannerShadowContext.Invocation.RECOVERY_REPLAN, + context.getInvocation()); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.RECOVERY_REPLAN)); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.UNDERGROUND_COORDINATES)); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.WALKING_ONLY_SELECTED)); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.USES_TRANSPORT)); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.MEMBERS_WORLD_POLICY)); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.SELECTS_MEMBERS_TRANSPORT)); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.SELECTS_ITEM_GATED_TRANSPORT)); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.SELECTS_SKILL_GATED_TRANSPORT)); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.SELECTS_QUEST_GATED_TRANSPORT)); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.SELECTS_STATE_GATED_TRANSPORT)); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.SELECTS_NON_ITEM_REQUIREMENT_GATED_TRANSPORT)); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.BANK_ITEMS_ENABLED)); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.BANK_ROUTE_FROM_BANK)); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.BANK_ROUTE_FROM_BANK_SELECTS_ITEM_GATED_TRANSPORT)); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.LIVE_COLLISION_ENABLED)); + assertTrue(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.LIVE_COLLISION_CONSULTED)); + assertEquals(Set.of(Rs2TransportExecutor.OBJECT), context.getTransportExecutors()); + assertEquals(Set.of(Rs2TransportType.TRANSPORT), context.getTransportTypes()); + assertFalse(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.SURFACE_COORDINATES_ONLY)); + } + + @Test + public void f2pWalkingRouteDoesNotClaimMembersOrRequirementEvidence() + { + WorldPoint start = new WorldPoint(3222, 3218, 0); + WorldPoint target = new WorldPoint(3223, 3218, 0); + Rs2RouteRequest request = Rs2RouteRequest.to(start, target) + .withPolicy(policy(false, false, false)); + Rs2RouteResult result = new Rs2RouteResult( + start, + Set.of(target), + List.of(start, target), + List.of(Rs2RouteStep.walk(start, target)), + Rs2RouteTermination.TARGET_REACHED, + new Rs2RouteMetrics(10L, 1L, 2L, 0L)); + + Rs2PlannerShadowContext context = Rs2PlannerShadowContext.from( + Rs2PlannerShadowContext.Invocation.ACTIVE_ROUTE, false, request, result); + + assertFalse(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.MEMBERS_WORLD_POLICY)); + assertFalse(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.SELECTS_MEMBERS_TRANSPORT)); + assertFalse(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.SELECTS_SKILL_GATED_TRANSPORT)); + assertFalse(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.SELECTS_QUEST_GATED_TRANSPORT)); + assertFalse(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.SELECTS_STATE_GATED_TRANSPORT)); + assertFalse(context.getCoverage().contains( + Rs2PlannerShadowContext.Coverage.SELECTS_NON_ITEM_REQUIREMENT_GATED_TRANSPORT)); + } + + @Test(expected = UnsupportedOperationException.class) + public void coverageIsImmutable() + { + WorldPoint start = new WorldPoint(3222, 3218, 0); + WorldPoint target = new WorldPoint(3232, 3218, 0); + Rs2RouteRequest request = Rs2RouteRequest.to(start, target) + .withPolicy(policy(false, false)); + Rs2RouteResult result = new Rs2RouteResult( + start, + Set.of(target), + List.of(start, target), + List.of(Rs2RouteStep.walk(start, target)), + Rs2RouteTermination.TARGET_REACHED, + new Rs2RouteMetrics(10L, 10L, 11L, 0L)); + Rs2PlannerShadowContext context = Rs2PlannerShadowContext.from( + Rs2PlannerShadowContext.Invocation.SYNCHRONOUS_QUERY, + false, + request, + result); + + context.getCoverage().add(Rs2PlannerShadowContext.Coverage.USES_TRANSPORT); + } + + @Test + public void equalSemanticRoutesRetainExactShapeDifferenceAsDiagnostic() + { + WorldPoint start = new WorldPoint(3222, 3218, 0); + WorldPoint target = new WorldPoint(3224, 3220, 0); + WorldPoint localMid = new WorldPoint(3223, 3219, 0); + WorldPoint shadowMid = new WorldPoint(3223, 3220, 0); + Rs2RouteRequest request = Rs2RouteRequest.to(start, target) + .withPolicy(policy(false, false)); + Rs2RouteResult local = walkingResult(start, localMid, target); + Rs2RouteResult shadow = walkingResult(start, shadowMid, target); + Rs2PlannerShadowComparison comparison = Rs2PlannerShadowComparison.compare( + "candidate", + Rs2PlannerShadowContext.from( + Rs2PlannerShadowContext.Invocation.SYNCHRONOUS_QUERY, + false, + request, + local), + local, + shadow); + + assertEquals(Rs2PlannerShadowComparison.Status.MATCH, comparison.getStatus()); + assertFalse(comparison.isPathMatches()); + } + + private static Rs2RouteResult walkingResult( + WorldPoint start, WorldPoint middle, WorldPoint target) + { + return new Rs2RouteResult( + start, + Set.of(target), + List.of(start, middle, target), + List.of(Rs2RouteStep.walk(start, middle), Rs2RouteStep.walk(middle, target)), + Rs2RouteTermination.TARGET_REACHED, + new Rs2RouteMetrics(10L, 2L, 3L, 0L)); + } + + private static Rs2RoutePolicy policy(boolean bankItems, boolean liveCollision) + { + return policy(bankItems, liveCollision, true); + } + + private static Rs2RoutePolicy policy( + boolean bankItems, boolean liveCollision, boolean membersWorld) + { + return new Rs2RoutePolicy( + bankItems, + true, + false, + false, + false, + membersWorld, + liveCollision, + 10_000L, + 0, + Rs2RoutePolicy.TeleportationItemMode.NONE, + EnumSet.allOf(Rs2TransportType.class), + Collections.emptySet()); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerUnitTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerUnitTest.java index fae13426568..64051e5ae1f 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerUnitTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerUnitTest.java @@ -6,9 +6,9 @@ import net.runelite.api.WallObject; import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.InterfaceID; import net.runelite.client.plugins.microbot.shortestpath.Transport; import net.runelite.client.plugins.microbot.shortestpath.TransportType; -import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -20,6 +20,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.TimeoutException; import java.util.function.Predicate; import static org.junit.Assert.assertEquals; @@ -32,7 +33,6 @@ import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** @@ -47,6 +47,312 @@ */ public class Rs2WalkerUnitTest { + @Test + public void teleportItemLeafActionSupportsNestedUpstreamLabels() { + assertEquals("rimmington", + Rs2Walker.teleportItemLeafAction("Max cape: POH Portals: Rimmington")); + assertEquals("fishing guild", + Rs2Walker.teleportItemLeafAction("Max cape: Fishing Teleports: Fishing Guild")); + assertEquals("teleport", + Rs2Walker.teleportItemLeafAction("Quest point cape: Teleport")); + assertEquals("chronicle", Rs2Walker.teleportItemLeafAction("Chronicle")); + assertEquals("", Rs2Walker.teleportItemLeafAction(null)); + } + + @Test + public void teleportWildernessLimitIsInclusiveWithoutOffByOne() { + assertTrue(Rs2Walker.isTeleportAllowedAtWildernessLevel(20, 20)); + assertFalse(Rs2Walker.isTeleportAllowedAtWildernessLevel(21, 20)); + } + + @Test + public void quetzalDestinationLabelsUseCurrentLandingAndMapText() { + assertEquals("Quetzacalli Gorge", + Rs2Walker.quetzalMapLabelForDestination(new WorldPoint(1510, 3222, 0))); + assertEquals("Cam Torum", + Rs2Walker.quetzalMapLabelForDestination(new WorldPoint(1446, 3108, 0))); + } + + @Test + public void terminalTravelTransport_onlyMatchesShipNpcAndBoat() { + assertTrue(Rs2Walker.isTerminalTravelTransport(TransportType.SHIP)); + assertTrue(Rs2Walker.isTerminalTravelTransport(TransportType.NPC)); + assertTrue(Rs2Walker.isTerminalTravelTransport(TransportType.BOAT)); + + assertFalse(Rs2Walker.isTerminalTravelTransport(TransportType.CHARTER_SHIP)); + assertFalse(Rs2Walker.isTerminalTravelTransport(TransportType.TRANSPORT)); + assertFalse(Rs2Walker.isTerminalTravelTransport(null)); + } + + @Test + public void terminalNpcInteractionCandidates_onlyFallbackForLegacyShipLabels() { + assertEquals(Arrays.asList("Musa Point", "Travel"), + Rs2Walker.terminalNpcInteractionCandidates(TransportType.SHIP, "Musa Point")); + assertEquals(Collections.singletonList("Travel"), + Rs2Walker.terminalNpcInteractionCandidates(TransportType.SHIP, "Travel")); + assertEquals(Collections.singletonList("Talk-to"), + Rs2Walker.terminalNpcInteractionCandidates(TransportType.SHIP, "Talk-to")); + assertEquals(Collections.singletonList("Follow"), + Rs2Walker.terminalNpcInteractionCandidates(TransportType.NPC, "Follow")); + assertTrue(Rs2Walker.terminalNpcInteractionCandidates(TransportType.NPC, null).isEmpty()); + } + + @Test + public void terminalTravelAttempt_isOncePerExactEdgeUntilWalkStateReset() { + Transport ship = portSarimToMusaShip(); + + assertTrue(Rs2Walker.markTerminalTravelAttempt(ship)); + assertFalse(Rs2Walker.markTerminalTravelAttempt(ship)); + + Rs2Walker.clearWalkerDedupeForTesting(); + assertTrue(Rs2Walker.markTerminalTravelAttempt(ship)); + } + + @Test + public void terminalTravelLanding_acceptsExactOrImmediateContinuationOnly() { + Transport ship = portSarimToMusaShip(); + WorldPoint modernGroundLanding = new WorldPoint(2956, 3146, 0); + List modernPath = Arrays.asList( + ship.getOrigin(), + ship.getDestination(), + modernGroundLanding); + + assertTrue(Rs2Walker.hasReachedTerminalTravelLanding( + ship, modernPath, 1, ship.getDestination())); + assertTrue(Rs2Walker.hasReachedTerminalTravelLanding( + ship, modernPath, 1, modernGroundLanding)); + assertFalse("standing at the origin is not a completed trip", + Rs2Walker.hasReachedTerminalTravelLanding(ship, modernPath, 1, ship.getOrigin())); + + List loopingPath = Arrays.asList( + ship.getOrigin(), + ship.getDestination(), + new WorldPoint(2957, 3143, 1), + modernGroundLanding); + assertFalse("an arbitrary later path point must not prove terminal arrival", + Rs2Walker.hasReachedTerminalTravelLanding(ship, loopingPath, 1, modernGroundLanding)); + assertFalse(Rs2Walker.hasReachedTerminalTravelLanding( + ship, modernPath, 1, new WorldPoint(3200, 3200, 0))); + } + + private static Transport portSarimToMusaShip() { + return new Transport( + new WorldPoint(3029, 3217, 0), + new WorldPoint(2956, 3143, 1), + "Musa Point", + TransportType.SHIP, + false, + "Musa Point", + "Captain Tobias", + 3644, + 10); + } + + @Test + public void terminalTravelObjectCandidate_matchesConfiguredSemanticTargetNearOrigin() { + Transport ferry = new Transport( + new WorldPoint(3271, 3144, 0), + new WorldPoint(3148, 2843, 0), + "", + TransportType.BOAT, + true, + "Board", + "Ferry", + 41311, + 8); + + assertTrue(Rs2Walker.isTerminalTravelObjectCompositionCandidate( + ferry, + ferry.getOrigin(), + "Ferry", + new String[]{"Board"})); + assertTrue("nearby multi-tile object anchors remain eligible", + Rs2Walker.isTerminalTravelObjectCompositionCandidate( + ferry, + new WorldPoint(3273, 3144, 0), + "Ferry", + new String[]{"Board"})); + assertFalse(Rs2Walker.isTerminalTravelObjectCompositionCandidate( + ferry, ferry.getOrigin(), "Boat", new String[]{"Board"})); + assertFalse(Rs2Walker.isTerminalTravelObjectCompositionCandidate( + ferry, ferry.getOrigin(), "Ferry", new String[]{"Travel"})); + assertFalse(Rs2Walker.isTerminalTravelObjectCompositionCandidate( + ferry, new WorldPoint(3275, 3144, 0), "Ferry", new String[]{"Board"})); + + Transport ordinaryObject = new Transport( + ferry.getOrigin(), ferry.getDestination(), "", TransportType.TRANSPORT, + true, "Board", "Ferry", 41311, 8); + assertFalse(Rs2Walker.isTerminalTravelObjectCompositionCandidate( + ordinaryObject, ferry.getOrigin(), "Ferry", new String[]{"Board"})); + } + + @Test + public void alKharidTollLanding_requiresExactSelectedDestination() { + Transport eastbound = new Transport( + new WorldPoint(3267, 3227, 0), + new WorldPoint(3268, 3227, 0), + "Gate", + TransportType.TRANSPORT, + false, + "Pay-toll(10gp)", + "Gate", + net.runelite.api.ObjectID.CITY_GATE_2786, + 2); + + assertTrue(Rs2Walker.hasReachedAlKharidTollDestination( + eastbound, eastbound.getDestination())); + assertFalse("the adjacent origin must never count as a crossing", + Rs2Walker.hasReachedAlKharidTollDestination(eastbound, eastbound.getOrigin())); + assertFalse(Rs2Walker.hasReachedAlKharidTollDestination( + eastbound, new WorldPoint(3268, 3228, 0))); + assertFalse(Rs2Walker.hasReachedAlKharidTollDestination(eastbound, null)); + } + + @Test + public void alKharidTollLanding_rejectsUnrelatedTransport() { + Transport door = new Transport( + new WorldPoint(3152, 3363, 0), + new WorldPoint(3153, 3363, 0), + "Door", + TransportType.TRANSPORT, + false, + "Open", + "Door", + 136); + + assertFalse(Rs2Walker.hasReachedAlKharidTollDestination( + door, door.getDestination())); + } + + @Test + public void alKharidTollSegment_matchesOnlyCrossGateEdges() { + assertTrue(Rs2Walker.isAlKharidTollGateSegment( + new WorldPoint(3267, 3227, 0), new WorldPoint(3268, 3227, 0))); + assertTrue(Rs2Walker.isAlKharidTollGateSegment( + new WorldPoint(3268, 3228, 0), new WorldPoint(3267, 3228, 0))); + + assertFalse("an along-gate step is not a crossing", + Rs2Walker.isAlKharidTollGateSegment( + new WorldPoint(3267, 3227, 0), new WorldPoint(3267, 3228, 0))); + assertFalse(Rs2Walker.isAlKharidTollGateSegment( + new WorldPoint(3267, 3227, 0), new WorldPoint(3268, 3227, 1))); + assertFalse(Rs2Walker.isAlKharidTollGateSegment( + new WorldPoint(3152, 3363, 0), new WorldPoint(3153, 3363, 0))); + } + + @Test + public void alKharidTollObjectCandidate_requiresGateActionAndSelectedEdgeLocation() { + Transport payToll = alKharidGateTransport("Pay-toll(10gp)"); + + assertTrue(Rs2Walker.isAlKharidTollGateCompositionCandidate( + payToll, + new WorldPoint(3268, 3227, 0), + "Gate", + new String[]{"Open", "Pay-toll(10gp)"})); + assertFalse("a stale id collision must not make an unrelated object eligible", + Rs2Walker.isAlKharidTollGateCompositionCandidate( + payToll, + new WorldPoint(3268, 3227, 0), + "Lever", + new String[]{"Pay-toll(10gp)"})); + assertFalse(Rs2Walker.isAlKharidTollGateCompositionCandidate( + payToll, + new WorldPoint(3268, 3227, 0), + "Gate", + new String[]{"Open"})); + assertFalse(Rs2Walker.isAlKharidTollGateCompositionCandidate( + payToll, + new WorldPoint(3269, 3227, 0), + "Gate", + new String[]{"Pay-toll(10gp)"})); + + Transport open = alKharidGateTransport("Open"); + assertTrue(Rs2Walker.isAlKharidTollGateCompositionCandidate( + open, + new WorldPoint(3267, 3228, 0), + "City gate", + new String[]{"Open"})); + } + + private static Transport alKharidGateTransport(String action) { + return new Transport( + new WorldPoint(3267, 3227, 0), + new WorldPoint(3268, 3227, 0), + "Gate", + TransportType.TRANSPORT, + false, + action, + "Gate", + net.runelite.api.ObjectID.CITY_GATE_2786, + 2); + } + + @Test + public void canoeStationsSelectTheirOwnMapInterfaceAndUnknownIdsFailClosed() { + assertEquals(InterfaceID.CanoeMapLum.MAIN_MAP, Rs2Walker.canoeMapMainComponentId(12163)); + assertEquals(InterfaceID.CanoeMapLum.DESTINATIONS, + Rs2Walker.canoeMapDestinationsComponentId(39638)); + assertEquals(InterfaceID.CanoeMapDougne.MAIN_MAP, + Rs2Walker.canoeMapMainComponentId(60845)); + assertEquals(InterfaceID.CanoeMapDougne.DESTINATIONS, + Rs2Walker.canoeMapDestinationsComponentId(60849)); + assertEquals(-1, Rs2Walker.canoeMapMainComponentId(99999)); + assertEquals(-1, Rs2Walker.canoeMapDestinationsComponentId(99999)); + } + + @Test + public void recoveryReplanTestHookIsTestOnlyTargetBoundAndOneShot() { + String previousTestMode = System.getProperty("microbot.test.mode"); + WorldPoint previousTarget = Rs2Walker.currentTarget; + try { + System.clearProperty("microbot.test.mode"); + Rs2Walker.currentTarget = new WorldPoint(3029, 3217, 0); + assertFalse(Rs2Walker.requestRecoveryReplanForTest()); + assertFalse(Rs2Walker.consumeRecoveryReplanForTest()); + + System.setProperty("microbot.test.mode", "true"); + Rs2Walker.currentTarget = null; + assertFalse(Rs2Walker.requestRecoveryReplanForTest()); + + Rs2Walker.currentTarget = new WorldPoint(3029, 3217, 0); + assertTrue(Rs2Walker.requestRecoveryReplanForTest()); + assertTrue(Rs2Walker.consumeRecoveryReplanForTest()); + assertFalse("one request must be consumed exactly once", + Rs2Walker.consumeRecoveryReplanForTest()); + } finally { + Rs2Walker.clearWalkerDedupeForTesting(); + Rs2Walker.currentTarget = previousTarget; + if (previousTestMode == null) { + System.clearProperty("microbot.test.mode"); + } else { + System.setProperty("microbot.test.mode", previousTestMode); + } + } + } + + @Test + public void clientThreadTimeoutDetectionWalksTheCauseChain() { + assertTrue(Rs2Walker.isClientThreadReadTimeout( + new RuntimeException("outer", new RuntimeException( + "Timed out waiting for client thread", new TimeoutException())))); + assertFalse(Rs2Walker.isClientThreadReadTimeout( + new RuntimeException("ordinary failure"))); + assertFalse(Rs2Walker.isClientThreadReadTimeout(null)); + } + + @Test + public void collisionFreeRouteIndexFallbackIsBoundedAndDistanceTagged() { + WorldPoint origin = new WorldPoint(3200, 3200, 2); + Map nearby = Rs2Walker.nearbyTilesIgnoringCollision(origin, 2); + + assertEquals(25, nearby.size()); + assertEquals(Integer.valueOf(0), nearby.get(origin)); + assertEquals(Integer.valueOf(2), nearby.get(new WorldPoint(3202, 3202, 2))); + assertFalse(nearby.containsKey(new WorldPoint(3203, 3200, 2))); + assertTrue(Rs2Walker.nearbyTilesIgnoringCollision(null, 2).isEmpty()); + assertTrue(Rs2Walker.nearbyTilesIgnoringCollision(origin, -1).isEmpty()); + } + @Before public void resetTelemetry() { Rs2Walker.clearWalkerDedupeForTesting(); @@ -1918,23 +2224,20 @@ public void telemetry_recordUnreachable_incrementsCounterAndSetsReason() { } @Test - public void telemetry_recordUnreachable_nullPathfinderDoesNotThrow() { + public void telemetry_recordUnreachable_nullMetricsDoesNotThrow() { Rs2Walker.Telemetry.recordUnreachable("partial-retries-exhausted", null, null, null, 0, 2, null); assertEquals(1, Rs2Walker.Telemetry.unreachableCount.get()); } @Test - public void telemetry_recordUnreachable_withPathfinderReadsStats() { - Pathfinder pathfinder = mock(Pathfinder.class); - Pathfinder.PathfinderStats stats = new Pathfinder.PathfinderStats(); - when(pathfinder.getStats()).thenReturn(stats); + public void telemetry_recordUnreachable_withRouteMetricsDoesNotLeakPlannerState() { + Rs2RouteMetrics metrics = new Rs2RouteMetrics(2_000_000L, 12L, 30L, 4L); Rs2Walker.Telemetry.recordUnreachable("no-walkable-path", new WorldPoint(3200, 3200, 0), new WorldPoint(3201, 3201, 0), - null, 0, 0, pathfinder); + null, 0, 0, metrics); - verify(pathfinder).getStats(); assertEquals(1, Rs2Walker.Telemetry.unreachableCount.get()); } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TargetWalkabilityPreflightTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TargetWalkabilityPreflightTest.java index 7e813a03831..a15e5465c45 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TargetWalkabilityPreflightTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TargetWalkabilityPreflightTest.java @@ -44,15 +44,15 @@ public void aTargetBuriedInRockIsRejected() { map.isBlocked(BURIED_IN_ROCK.getX(), BURIED_IN_ROCK.getY(), 0)); assertFalse("a goal with no walkable tile within the arrival distance must be rejected " + "before the walk starts", - Rs2Walker.hasWalkableTileWithin(map, BURIED_IN_ROCK, 5)); + Rs2PathApi.hasWalkableTileWithin(map, BURIED_IN_ROCK, 5)); } @Test public void legitimateUndergroundTargetsAreAccepted() { assertTrue("the last walkable tile in the corridor must still be accepted", - Rs2Walker.hasWalkableTileWithin(map, LAST_WALKABLE, 5)); + Rs2PathApi.hasWalkableTileWithin(map, LAST_WALKABLE, 5)); assertTrue("the Motherlode cave mouth is a valid destination and must not be rejected", - Rs2Walker.hasWalkableTileWithin(map, MLM_CAVE_MOUTH, 5)); + Rs2PathApi.hasWalkableTileWithin(map, MLM_CAVE_MOUTH, 5)); } /** @@ -65,22 +65,22 @@ public void unmappedRegionsAreNeverRejected() { assertFalse("precondition: this region genuinely has no collision data", map.hasRegion(offMap.getX(), offMap.getY())); assertTrue("no collision data must mean 'let the pathfinder try', not 'unreachable'", - Rs2Walker.hasWalkableTileWithin(map, offMap, 5)); + Rs2PathApi.hasWalkableTileWithin(map, offMap, 5)); assertTrue("a null map must never block a walk", - Rs2Walker.hasWalkableTileWithin(null, BURIED_IN_ROCK, 5)); + Rs2PathApi.hasWalkableTileWithin(null, BURIED_IN_ROCK, 5)); } /** A generous arrival distance reaches real ground, so the same goal becomes acceptable. */ @Test public void aLargeArrivalDistanceReachesWalkableGround() { - assertFalse(Rs2Walker.hasWalkableTileWithin(map, BURIED_IN_ROCK, 5)); + assertFalse(Rs2PathApi.hasWalkableTileWithin(map, BURIED_IN_ROCK, 5)); assertTrue("with a 40 tile tolerance the corridor is inside the search box", - Rs2Walker.hasWalkableTileWithin(map, BURIED_IN_ROCK, 40)); + Rs2PathApi.hasWalkableTileWithin(map, BURIED_IN_ROCK, 40)); } @Test public void theRejectionPointsAtTheNearestRealTile() { - WorldPoint nearest = Rs2Walker.nearestWalkableTile(map, BURIED_IN_ROCK, 48); + WorldPoint nearest = Rs2PathApi.nearestWalkableTile(map, BURIED_IN_ROCK, 48); assertNotNull("the warning must name a concrete tile so the coordinate can be corrected", nearest); assertFalse("the suggested tile must itself be walkable", diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TransportRouteAnalysisTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TransportRouteAnalysisTest.java new file mode 100644 index 00000000000..ec7c232ae04 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/TransportRouteAnalysisTest.java @@ -0,0 +1,115 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class TransportRouteAnalysisTest +{ + @Test + public void exactStepsRetainSelectedTransportIdentityAndOrder() + { + WorldPoint start = new WorldPoint(3200, 3200, 0); + WorldPoint directMiddle = new WorldPoint(3201, 3200, 0); + WorldPoint target = new WorldPoint(3202, 3200, 0); + WorldPoint bank = new WorldPoint(3199, 3200, 0); + WorldPoint landing = new WorldPoint(3000, 3000, 0); + Rs2TransportEdge directEdge = edge(directMiddle, target, "direct-selected"); + Rs2TransportEdge bankEdge = edge(null, landing, "bank-selected"); + + List directPath = new ArrayList<>(List.of(start, directMiddle, target)); + List directSteps = new ArrayList<>(List.of( + Rs2RouteStep.walk(start, directMiddle), + Rs2RouteStep.transport(directMiddle, target, directEdge))); + TransportRouteAnalysis analysis = new TransportRouteAnalysis( + directPath, + null, + bank, + List.of(start, bank), + List.of(bank, landing, target), + "test", + 2, + 3, + directSteps, + List.of(Rs2RouteStep.walk(start, bank)), + List.of( + Rs2RouteStep.transport(bank, landing, bankEdge), + Rs2RouteStep.walk(landing, target))); + + directPath.clear(); + directSteps.clear(); + + assertTrue(analysis.isDirectRouteStepsExact()); + assertTrue(analysis.isRouteToBankStepsExact()); + assertTrue(analysis.isRouteFromBankStepsExact()); + assertEquals(3, analysis.getDirectPath().size()); + assertSame(directEdge, analysis.getDirectTransportEdges().get(0)); + assertSame(bankEdge, analysis.getTransportEdgesFromBank().get(0)); + assertEquals(List.of(directEdge), analysis.getDirectTransportEdges()); + assertEquals(List.of(bankEdge), analysis.getBankingTransportEdges()); + + try + { + analysis.getRouteFromBankSteps().add(Rs2RouteStep.walk(landing, target)); + fail("exact route steps must be immutable"); + } + catch (UnsupportedOperationException expected) + { + // Expected. + } + } + + @Test + public void legacyConstructorsDoNotPretendReconstructedEdgesAreExact() + { + WorldPoint start = new WorldPoint(3200, 3200, 0); + WorldPoint target = new WorldPoint(3201, 3200, 0); + TransportRouteAnalysis analysis = new TransportRouteAnalysis( + List.of(start, target), null, null, List.of(), List.of(), "legacy"); + + assertFalse(analysis.isDirectRouteStepsExact()); + assertFalse(analysis.isRouteToBankStepsExact()); + assertFalse(analysis.isRouteFromBankStepsExact()); + assertTrue(analysis.getDirectTransportEdges().isEmpty()); + } + + @Test(expected = IllegalArgumentException.class) + public void exactStepsMustDescribeEveryPathEdge() + { + WorldPoint start = new WorldPoint(3200, 3200, 0); + WorldPoint target = new WorldPoint(3201, 3200, 0); + new TransportRouteAnalysis( + List.of(start, target), null, null, List.of(), List.of(), "invalid", 1, -1, + List.of(), List.of(), List.of()); + } + + private static Rs2TransportEdge edge(WorldPoint origin, WorldPoint destination, String displayInfo) + { + return new Rs2TransportEdge( + origin, + destination, + Rs2TransportType.TELEPORTATION_ITEM, + Rs2TransportExecutor.ITEM_TELEPORT, + Rs2TerminalTravelMode.UNSUPPORTED, + displayInfo, + "Teleport", + "test item", + -1, + 1, + true, + false, + false, + 0, + "", + 0, + List.of()); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/UpstreamRoutePlannerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/UpstreamRoutePlannerTest.java new file mode 100644 index 00000000000..1727d070665 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/UpstreamRoutePlannerTest.java @@ -0,0 +1,121 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; +import org.junit.Test; +import shortestpath.transport.TransportType; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +public class UpstreamRoutePlannerTest +{ + private static final WorldPoint ORIGIN = new WorldPoint(3200, 3200, 0); + private static final WorldPoint DESTINATION = new WorldPoint(3201, 3200, 0); + + @Test + public void anchoredTypeProjectionIsExplicitAndComplete() + { + Map expected = new EnumMap<>(Rs2TransportType.class); + expected.put(Rs2TransportType.TRANSPORT, TransportType.TRANSPORT); + expected.put(Rs2TransportType.AGILITY_SHORTCUT, TransportType.AGILITY_SHORTCUT); + expected.put(Rs2TransportType.GRAPPLE_SHORTCUT, TransportType.GRAPPLE_SHORTCUT); + expected.put(Rs2TransportType.BOAT, TransportType.BOAT); + expected.put(Rs2TransportType.CANOE, TransportType.CANOE); + expected.put(Rs2TransportType.CHARTER_SHIP, TransportType.CHARTER_SHIP); + expected.put(Rs2TransportType.SHIP, TransportType.SHIP); + expected.put(Rs2TransportType.FAIRY_RING, TransportType.FAIRY_RING); + expected.put(Rs2TransportType.QUETZAL, TransportType.QUETZAL); + expected.put(Rs2TransportType.QUETZAL_WHISTLE, TransportType.QUETZAL_WHISTLE); + expected.put(Rs2TransportType.GNOME_GLIDER, TransportType.GNOME_GLIDER); + expected.put(Rs2TransportType.MINECART, TransportType.MINECART); + expected.put(Rs2TransportType.POH, TransportType.TRANSPORT); + expected.put(Rs2TransportType.SPIRIT_TREE, TransportType.SPIRIT_TREE); + expected.put(Rs2TransportType.TELEPORTATION_BOX, TransportType.TELEPORTATION_BOX); + expected.put(Rs2TransportType.TELEPORTATION_LEVER, TransportType.TELEPORTATION_LEVER); + expected.put(Rs2TransportType.TELEPORTATION_PORTAL, TransportType.TELEPORTATION_PORTAL); + expected.put(Rs2TransportType.TELEPORTATION_PORTAL_POH, TransportType.TELEPORTATION_PORTAL_POH); + expected.put(Rs2TransportType.TELEPORTATION_MINIGAME, TransportType.TELEPORTATION_MINIGAME); + expected.put(Rs2TransportType.TELEPORTATION_ITEM, TransportType.TELEPORTATION_ITEM); + expected.put(Rs2TransportType.TELEPORTATION_SPELL, TransportType.TELEPORTATION_SPELL); + expected.put(Rs2TransportType.TELEPORTATION_SPELL_HOME, TransportType.TELEPORTATION_SPELL_HOME); + expected.put(Rs2TransportType.WILDERNESS_OBELISK, TransportType.WILDERNESS_OBELISK); + expected.put(Rs2TransportType.MAGIC_CARPET, TransportType.MAGIC_CARPET); + expected.put(Rs2TransportType.HOT_AIR_BALLOON, TransportType.HOT_AIR_BALLOON); + expected.put(Rs2TransportType.MAGIC_MUSHTREE, TransportType.MAGIC_MUSHTREE); + expected.put(Rs2TransportType.SEASONAL_TRANSPORT, TransportType.SEASONAL_TRANSPORTS); + expected.put(Rs2TransportType.NPC, TransportType.TRANSPORT); + + assertEquals("every supported boundary type must declare an anchored projection", + Rs2TransportType.values().length - 1, expected.size()); + for (Map.Entry entry : expected.entrySet()) + { + assertEquals(entry.getKey().name(), entry.getValue(), + UpstreamRoutePlanner.mapType(edge(ORIGIN, entry.getKey()))); + } + assertRejected(edge(ORIGIN, Rs2TransportType.UNKNOWN)); + } + + @Test + public void originlessProjectionOnlyAdmitsReviewedTeleportCategories() + { + Map expected = new EnumMap<>(Rs2TransportType.class); + expected.put(Rs2TransportType.QUETZAL_WHISTLE, TransportType.QUETZAL_WHISTLE); + expected.put(Rs2TransportType.SEASONAL_TRANSPORT, TransportType.TELEPORTATION_ITEM); + expected.put(Rs2TransportType.TELEPORTATION_ITEM, TransportType.TELEPORTATION_ITEM); + expected.put(Rs2TransportType.TELEPORTATION_MINIGAME, TransportType.TELEPORTATION_MINIGAME); + expected.put(Rs2TransportType.TELEPORTATION_SPELL, TransportType.TELEPORTATION_SPELL); + expected.put(Rs2TransportType.TELEPORTATION_SPELL_HOME, TransportType.TELEPORTATION_SPELL_HOME); + + for (Rs2TransportType type : Rs2TransportType.values()) + { + Rs2TransportEdge edge = edge(null, type); + if (expected.containsKey(type)) + { + assertEquals(type.name(), expected.get(type), UpstreamRoutePlanner.mapType(edge)); + } + else + { + assertRejected(edge); + } + } + } + + private static Rs2TransportEdge edge(WorldPoint origin, Rs2TransportType type) + { + return new Rs2TransportEdge( + origin, + DESTINATION, + type, + Rs2TransportExecutor.OBJECT, + Rs2TerminalTravelMode.UNSUPPORTED, + "test", + "Use", + "test", + 1, + 1, + origin == null, + false, + false, + 0, + "", + 0, + Collections.emptyList()); + } + + private static void assertRejected(Rs2TransportEdge edge) + { + try + { + UpstreamRoutePlanner.mapType(edge); + fail("expected unsupported projection for " + edge.getType()); + } + catch (IllegalArgumentException expected) + { + // expected + } + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/banking/BankedTransportItemPlanningTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/banking/BankedTransportItemPlanningTest.java index 0fc35b7b062..f2ec3d0dce1 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/banking/BankedTransportItemPlanningTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/banking/BankedTransportItemPlanningTest.java @@ -1,18 +1,29 @@ package net.runelite.client.plugins.microbot.util.walker.banking; import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.ItemID; import net.runelite.client.plugins.microbot.shortestpath.Transport; import net.runelite.client.plugins.microbot.shortestpath.TransportType; +import net.runelite.client.plugins.microbot.util.walker.Rs2TerminalTravelMode; +import net.runelite.client.plugins.microbot.util.walker.Rs2RouteStep; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportEdge; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportExecutor; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportItemRequirement; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportLoadout; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportType; +import net.runelite.client.plugins.microbot.util.walker.TransportRouteAnalysis; import org.junit.BeforeClass; import org.junit.Test; import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; /** @@ -54,6 +65,199 @@ private static List matching(String menuFragment) { .collect(Collectors.toList()); } + private static Rs2TransportEdge owned(Transport transport) { + List requirements = transport.getItemRequirements().stream() + .map(requirement -> new Rs2TransportItemRequirement( + requirement.getAlternatives(), + requirement.getStaffAlternatives(), + requirement.getOffhandAlternatives(), + requirement.isRuneOnly())) + .collect(Collectors.toList()); + return new Rs2TransportEdge( + transport.getOrigin(), + transport.getDestination(), + Rs2TransportType.valueOf(transport.getType().name()), + Rs2TransportExecutor.OBJECT, + Rs2TerminalTravelMode.UNSUPPORTED, + transport.getDisplayInfo(), + transport.getAction(), + transport.getName(), + transport.getObjectId(), + transport.getDuration(), + TransportType.isTeleport(transport.getType(), transport.getOrigin()), + transport.isConsumable(), + transport.isMembers(), + transport.getMaxWildernessLevel(), + transport.getCurrencyName(), + transport.getCurrencyAmount(), + requirements); + } + + private static Rs2TransportEdge sourceAwareSpellEdge() { + Rs2TransportItemRequirement fire = new Rs2TransportItemRequirement( + Map.of(ItemID.FIRERUNE, 2), + Set.of(ItemID.TWINFLAME_STAFF), + Set.of(), + true); + Rs2TransportItemRequirement water = new Rs2TransportItemRequirement( + Map.of(ItemID.WATERRUNE, 2), + Set.of(ItemID.TWINFLAME_STAFF), + Set.of(), + true); + Rs2TransportItemRequirement law = new Rs2TransportItemRequirement( + Map.of(ItemID.LAWRUNE, 2), Set.of(), Set.of(), true); + Rs2TransportItemRequirement banana = new Rs2TransportItemRequirement( + Map.of(ItemID.BANANA, 1)); + return new Rs2TransportEdge( + null, + new WorldPoint(2771, 9102, 0), + Rs2TransportType.TELEPORTATION_SPELL, + Rs2TransportExecutor.SPELL_TELEPORT, + Rs2TerminalTravelMode.UNSUPPORTED, + "Ape Atoll Teleport", + "Cast", + "", + -1, + 5, + true, + true, + true, + 20, + "", + 0, + List.of(fire, water, law, banana)); + } + + private static Rs2TransportEdge teleportEdge(WorldPoint destination) { + return new Rs2TransportEdge( + null, + destination, + Rs2TransportType.TELEPORTATION_ITEM, + Rs2TransportExecutor.ITEM_TELEPORT, + Rs2TerminalTravelMode.UNSUPPORTED, + "test teleport", + "Teleport", + "test item", + -1, + 1, + true, + false, + false, + 0, + "", + 0, + List.of()); + } + + @Test + public void bankDistanceUsesExactSelectedRouteStepForImmediateTeleport() { + WorldPoint bank = new WorldPoint(3200, 3200, 0); + WorldPoint landing = new WorldPoint(3000, 3000, 0); + WorldPoint tail = new WorldPoint(3001, 3000, 0); + WorldPoint target = new WorldPoint(3002, 3000, 0); + Rs2TransportEdge selected = teleportEdge(landing); + List path = List.of(bank, landing, tail, target); + List steps = List.of( + Rs2RouteStep.transport(bank, landing, selected), + Rs2RouteStep.walk(landing, tail), + Rs2RouteStep.walk(tail, target)); + + assertEquals("the bank-leg metric must use the exact route edge rather than rematching the catalog", + 4, Rs2WalkerBankingPlanner.effectiveDistanceFromBank(path, steps, 205)); + } + + @Test + public void bankDistanceWithoutSelectedTransportKeepsRawDistance() { + WorldPoint bank = new WorldPoint(3200, 3200, 0); + WorldPoint target = new WorldPoint(3201, 3200, 0); + List path = List.of(bank, target); + + assertEquals(1, Rs2WalkerBankingPlanner.effectiveDistanceFromBank( + path, List.of(Rs2RouteStep.walk(bank, target)), 1)); + } + + @Test + public void withdrawalPlanningUsesTheExactComparedBankRoute() { + WorldPoint start = new WorldPoint(3200, 3200, 0); + WorldPoint bank = new WorldPoint(3201, 3200, 0); + WorldPoint landing = new WorldPoint(3000, 3000, 0); + Rs2TransportEdge selected = teleportEdge(landing); + TransportRouteAnalysis analysis = new TransportRouteAnalysis( + List.of(start, bank), + null, + bank, + List.of(start, bank), + List.of(bank, landing), + "bank route selected", + 1, + 2, + List.of(Rs2RouteStep.walk(start, bank)), + List.of(Rs2RouteStep.walk(start, bank)), + List.of(Rs2RouteStep.transport(bank, landing, selected))); + + List required = + Rs2WalkerBankingPlanner.getRequiredTransportEdgesFromBank(analysis); + + assertEquals(1, required.size()); + assertSame("withdrawal planning must consume the transport selected by the compared bank leg", + selected, required.get(0)); + } + + @Test + public void sourceAwareSpellLoadoutWithdrawsAndEquipsOneCombinationStaff() { + Rs2TransportLoadout loadout = Rs2WalkerBankingPlanner.getMissingTransportEdgeLoadout( + List.of(sourceAwareSpellEdge()), + itemId -> itemId == ItemID.TWINFLAME_STAFF ? 1 + : itemId == ItemID.LAWRUNE ? 2 + : itemId == ItemID.BANANA ? 1 : 0, + ignored -> 0, + ignored -> 0, + itemId -> itemId == ItemID.LAWRUNE ? 2 + : itemId == ItemID.BANANA ? 1 : 0, + ignored -> false); + + assertTrue(loadout.isSatisfiable()); + assertEquals(Map.of( + ItemID.TWINFLAME_STAFF, 1, + ItemID.LAWRUNE, 2, + ItemID.BANANA, 1), loadout.getWithdrawals()); + assertEquals(List.of(ItemID.TWINFLAME_STAFF), loadout.getEquipmentItemIds()); + assertFalse(loadout.getWithdrawals().containsKey(ItemID.FIRERUNE)); + assertFalse(loadout.getWithdrawals().containsKey(ItemID.WATERRUNE)); + } + + @Test + public void carriedUnequippedStaffCreatesEquipActionWithoutStaffWithdrawal() { + Rs2TransportLoadout loadout = Rs2WalkerBankingPlanner.getMissingTransportEdgeLoadout( + List.of(sourceAwareSpellEdge()), + itemId -> itemId == ItemID.LAWRUNE ? 2 + : itemId == ItemID.BANANA ? 1 : 0, + itemId -> itemId == ItemID.TWINFLAME_STAFF ? 1 : 0, + ignored -> 0, + itemId -> itemId == ItemID.LAWRUNE ? 2 + : itemId == ItemID.BANANA ? 1 : 0, + ignored -> false); + + assertTrue(loadout.isSatisfiable()); + assertEquals(Map.of(ItemID.LAWRUNE, 2, ItemID.BANANA, 1), loadout.getWithdrawals()); + assertEquals(List.of(ItemID.TWINFLAME_STAFF), loadout.getEquipmentItemIds()); + } + + @Test + public void missingRuneProviderMakesTheLoadoutExplicitlyUnavailable() { + Rs2TransportLoadout loadout = Rs2WalkerBankingPlanner.getMissingTransportEdgeLoadout( + List.of(sourceAwareSpellEdge()), + itemId -> itemId == ItemID.LAWRUNE ? 2 + : itemId == ItemID.BANANA ? 1 : 0, + ignored -> 0, + ignored -> 0, + ignored -> 0, + ignored -> false); + + assertFalse(loadout.isSatisfiable()); + assertTrue(loadout.isEmpty()); + } + @Test public void itemGatedPlainTransportsNowQualifyForPlanning() { List itemGated = all.stream() @@ -72,6 +276,25 @@ public void itemGatedPlainTransportsNowQualifyForPlanning() { } } + @Test + public void itemGatedPlainTransportsSurviveTheActualPlanningFilter() { + Transport itemGated = all.stream() + .filter(t -> t.getType() == TransportType.TRANSPORT) + .filter(t -> t.getItemIdRequirements() != null && !t.getItemIdRequirements().isEmpty()) + .filter(t -> t.getCurrencyAmount() <= 0) + .findFirst() + .orElseThrow(() -> new AssertionError("catalog should contain an item-gated plain transport")); + + List filtered = Rs2WalkerBankingPlanner.applyTransportFiltering(List.of(itemGated)); + + assertEquals("the real banking filter must not discard the selected item-gated edge", + List.of(itemGated), filtered); + Rs2TransportEdge edge = owned(itemGated); + assertEquals("the immutable banking filter must retain the same selected edge", + List.of(edge), Rs2WalkerBankingPlanner.applyTransportEdgeFiltering(List.of(edge))); + assertTrue(Rs2WalkerBankingPlanner.planningCoversPlainTransportEdge(edge)); + } + /** A transport with no item and no currency requirement must stay out of planning. */ @Test public void unrestrictedTransportsAreStillIgnored() { @@ -118,6 +341,14 @@ public void pureCurrencyFaresEnterTheWithdrawalMap() { Rs2WalkerBankingPlanner.getMissingTransportItemIdsWithQuantities(java.util.List.of(one, one)); assertTrue("fares must sum across currency hops", summed.getOrDefault(net.runelite.api.gameval.ItemID.COINS, 0) >= one.getCurrencyAmount() * 2); + + Rs2TransportEdge edge = owned(one); + java.util.Map edgeSummed = + Rs2WalkerBankingPlanner.getMissingTransportEdgeItemIdsWithQuantities( + List.of(edge, edge), ignored -> 0, ignored -> 0); + assertEquals("immutable selected edges must sum the same fares", + one.getCurrencyAmount() * 2, + edgeSummed.getOrDefault(net.runelite.api.gameval.ItemID.COINS, 0).intValue()); } /** @@ -135,12 +366,65 @@ public void unbankedPurchasableItemFallsBackToItsFare() { .orElseThrow(() -> new AssertionError("catalog should contain the Shantay ticket row")); java.util.Map map = - Rs2WalkerBankingPlanner.getMissingTransportItemIdsWithQuantities(java.util.List.of(ticketRow)); + Rs2WalkerBankingPlanner.getMissingTransportItemIdsWithQuantities( + java.util.List.of(ticketRow), ignored -> 0); assertEquals("the planner must withdraw exactly one 5-coin fare", 5, map.getOrDefault(net.runelite.api.gameval.ItemID.COINS, 0).intValue()); assertFalse("the unbankable ticket itself must not be requested", map.containsKey(1854)); + + Rs2TransportEdge edge = owned(ticketRow); + java.util.Map edgeMap = + Rs2WalkerBankingPlanner.getMissingTransportEdgeItemIdsWithQuantities( + List.of(edge), ignored -> 0, ignored -> 0); + assertEquals("the immutable selected edge must preserve the purchasable fallback", + 5, edgeMap.getOrDefault(net.runelite.api.gameval.ItemID.COINS, 0).intValue()); + assertFalse(edgeMap.containsKey(1854)); + } + + @Test + public void legacyChargedItemVariantsRequestOnlyOneAlternative() { + Transport gamesNecklace = all.stream() + .filter(t -> t.getType() == TransportType.TELEPORTATION_ITEM) + .filter(t -> t.getDisplayInfo() != null + && t.getDisplayInfo().startsWith("Games necklace:")) + .findFirst() + .orElseThrow(() -> new AssertionError("catalog should contain Games necklace teleports")); + + assertEquals("legacy semicolon variants must be represented as one OR requirement", + 1, gamesNecklace.getItemRequirements().size()); + java.util.Map requested = + Rs2WalkerBankingPlanner.getMissingTransportItemIdsWithQuantities( + java.util.List.of(gamesNecklace), ignored -> 0); + + assertEquals("bank planning must request one charged variant, not every charge state", + 1, requested.size()); + assertEquals(1, requested.values().iterator().next().intValue()); + } + + @Test + public void symbolicCanoeAxeCollectionChoosesOneBankedAlternative() { + Transport canoe = all.stream() + .filter(t -> t.getType() == TransportType.CANOE) + .findFirst() + .orElseThrow(() -> new AssertionError("catalog should contain River Lum canoes")); + int crystalAxe = net.runelite.api.gameval.ItemID.CRYSTAL_AXE; + + assertEquals(12, canoe.getItemRequirements().get(0).getItemIds().size()); + java.util.Map requested = + Rs2WalkerBankingPlanner.getMissingTransportItemIdsWithQuantities( + java.util.List.of(canoe), itemId -> itemId == crystalAxe ? 1 : 0); + + assertEquals("bank planning should request the available axe, not every symbolic variant", + java.util.Map.of(crystalAxe, 1), requested); + + Rs2TransportEdge edge = owned(canoe); + java.util.Map edgeRequested = + Rs2WalkerBankingPlanner.getMissingTransportEdgeItemIdsWithQuantities( + List.of(edge), itemId -> itemId == crystalAxe ? 1 : 0, ignored -> 0); + assertEquals("immutable selected edges must preserve OR-alternative selection", + java.util.Map.of(crystalAxe, 1), edgeRequested); } /** Currency-bearing transports kept their existing eligibility. */ diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbeTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbeTest.java index 0c9c5126545..2feb2d9ac86 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbeTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorProbeTest.java @@ -1,13 +1,16 @@ package net.runelite.client.plugins.microbot.util.walker.door; -import net.runelite.client.plugins.microbot.shortestpath.Transport; -import net.runelite.client.plugins.microbot.shortestpath.TransportType; +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.util.walker.Rs2TerminalTravelMode; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportEdge; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportExecutor; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportType; import org.junit.Test; +import java.util.Collections; + import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; /** * Headless tests for {@link Rs2DoorProbe#isDoorLikeCatalogTransport} — whether a catalog transport is @@ -17,46 +20,59 @@ */ public class Rs2DoorProbeTest { - private static Transport transport(TransportType type, String name, String displayInfo, String action) { - Transport t = mock(Transport.class); - when(t.getType()).thenReturn(type); - when(t.getName()).thenReturn(name); - when(t.getDisplayInfo()).thenReturn(displayInfo); - when(t.getAction()).thenReturn(action); - return t; + private static Rs2TransportEdge transport( + Rs2TransportType type, String name, String displayInfo, String action) { + return new Rs2TransportEdge( + new WorldPoint(3200, 3200, 0), + new WorldPoint(3200, 3201, 0), + type, + Rs2TransportExecutor.OBJECT, + Rs2TerminalTravelMode.UNSUPPORTED, + displayInfo, + action, + name, + 1, + 1, + false, + false, + false, + 0, + "", + 0, + Collections.emptyList()); } @Test public void doorLikeByName() { assertTrue(Rs2DoorProbe.isDoorLikeCatalogTransport( - transport(TransportType.TRANSPORT, "Gate", null, "Open"))); + transport(Rs2TransportType.TRANSPORT, "Gate", null, "Open"))); } @Test public void doorLikeByDisplayInfo() { assertTrue(Rs2DoorProbe.isDoorLikeCatalogTransport( - transport(TransportType.TRANSPORT, "Anonymous object", "Large door", null))); + transport(Rs2TransportType.TRANSPORT, "Anonymous object", "Large door", null))); } @Test public void doorLikeByAction() { // Neutral name/display, but an "Open" action is a door-walk action -> classified door-like. assertTrue(Rs2DoorProbe.isDoorLikeCatalogTransport( - transport(TransportType.TRANSPORT, "Anonymous object", "Anonymous object", "Open"))); + transport(Rs2TransportType.TRANSPORT, "Anonymous object", "Anonymous object", "Open"))); } @Test public void genuineTransportIsNotDoorLike() { // A ladder with a Climb action is a real transport, not a door. assertFalse(Rs2DoorProbe.isDoorLikeCatalogTransport( - transport(TransportType.TRANSPORT, "Ladder", "Ladder", "Climb"))); + transport(Rs2TransportType.TRANSPORT, "Ladder", "Ladder", "Climb"))); } @Test public void nonTransportTypeIsNeverDoorLike() { // Only TRANSPORT-type rows are considered; an agility shortcut named "Gate" must not qualify. assertFalse(Rs2DoorProbe.isDoorLikeCatalogTransport( - transport(TransportType.AGILITY_SHORTCUT, "Gate", "Gate", "Open"))); + transport(Rs2TransportType.AGILITY_SHORTCUT, "Gate", "Gate", "Open"))); } @Test diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/obstacle/MineableResolverTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/obstacle/MineableResolverTest.java index e1e6d492e1d..ff0638ce1f5 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/obstacle/MineableResolverTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/obstacle/MineableResolverTest.java @@ -3,13 +3,10 @@ import net.runelite.api.TileObject; import net.runelite.api.coords.WorldPoint; import net.runelite.api.gameval.ObjectID; -import net.runelite.client.plugins.microbot.shortestpath.Transport; import org.junit.Test; -import java.util.Collections; import java.util.HashMap; import java.util.Map; -import java.util.Set; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -39,9 +36,9 @@ public boolean isReachable(WorldPoint tile) { return true; } - public Set transportsAt(WorldPoint tile) { - return Collections.emptySet(); - } + public boolean hasTransportAt(WorldPoint tile) { + return false; + } public TileObject objectAt(WorldPoint tile) { return objects.get(tile); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/obstacle/ObstacleRegistryTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/obstacle/ObstacleRegistryTest.java index 7f92fe1b363..4ce08410130 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/obstacle/ObstacleRegistryTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/obstacle/ObstacleRegistryTest.java @@ -2,12 +2,10 @@ import net.runelite.api.TileObject; import net.runelite.api.coords.WorldPoint; -import net.runelite.client.plugins.microbot.shortestpath.Transport; import org.junit.Test; import java.util.Arrays; import java.util.Collections; -import java.util.Set; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -33,9 +31,9 @@ public boolean isReachable(WorldPoint tile) { return false; } - public Set transportsAt(WorldPoint tile) { - return Collections.emptySet(); - } + public boolean hasTransportAt(WorldPoint tile) { + return false; + } public TileObject objectAt(WorldPoint tile) { return null; diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/obstacle/TransportResolverTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/obstacle/TransportResolverTest.java index e427c0c6a78..55078a760b0 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/obstacle/TransportResolverTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/obstacle/TransportResolverTest.java @@ -2,7 +2,6 @@ import net.runelite.api.TileObject; import net.runelite.api.coords.WorldPoint; -import net.runelite.client.plugins.microbot.shortestpath.Transport; import org.junit.Test; import java.util.Collections; @@ -12,7 +11,6 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.mock; /** * Headless tests for {@link TransportResolver} — the stepping-stone recovery fix expressed in the P2 @@ -29,8 +27,7 @@ private static WorldPoint wp(int x, int y) { /** Scene with a transport origin at {@code origin}, given player tile and reachable set. */ private static LiveScene scene(WorldPoint player, WorldPoint origin, Set reachable) { - final Set t = new HashSet<>(Collections.singletonList(mock(Transport.class))); - return new LiveScene() { + return new LiveScene() { public WorldPoint playerLocation() { return player; } @@ -39,8 +36,8 @@ public boolean isReachable(WorldPoint tile) { return reachable.contains(tile); } - public Set transportsAt(WorldPoint tile) { - return origin.equals(tile) ? t : Collections.emptySet(); + public boolean hasTransportAt(WorldPoint tile) { + return origin.equals(tile); } public TileObject objectAt(WorldPoint tile) { diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/RouteRecoveryTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/RouteRecoveryTest.java index eede470f62c..67234381341 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/RouteRecoveryTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/recovery/RouteRecoveryTest.java @@ -1,25 +1,22 @@ package net.runelite.client.plugins.microbot.util.walker.recovery; import net.runelite.api.coords.WorldPoint; -import net.runelite.client.plugins.microbot.shortestpath.Transport; import org.junit.Test; import java.util.Arrays; -import java.util.HashMap; import java.util.HashSet; import java.util.List; -import java.util.Map; import java.util.Set; +import java.util.function.Predicate; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; -import static org.mockito.Mockito.mock; /** * Headless scenario tests for {@link RouteRecovery} decisions — the foundation of the walker test harness. *

* Each test constructs a stuck situation entirely in memory — a raw path, the set of tiles reachable from - * the player, and the transports map — with no live client, and asserts the recovery decision. This is what + * the player, and a transport-origin predicate — with no live client, and asserts the recovery decision. This is what * turns "verify a recovery change with a 5-minute live walk" into "verify it in milliseconds", which is the * prerequisite for safely rewriting the walker's recovery/executor rather than patching it live. New * recovery decisions are extracted into {@code RouteRecovery} as pure functions and exercised here. @@ -38,10 +35,8 @@ private static List steppingStonePath() { wp(3153, 3363), wp(3152, 3363), wp(3151, 3363), wp(3150, 3363), wp(3149, 3363)); } - private static Map> transportAt(WorldPoint origin) { - Map> t = new HashMap<>(); - t.put(origin, new HashSet<>(Arrays.asList(mock(Transport.class)))); - return t; + private static Predicate transportAt(WorldPoint origin) { + return origin::equals; } @Test @@ -67,7 +62,7 @@ public void returnsNullWhenNoTransportOnRoute() { reachable.add(player); assertNull(RouteRecovery.findReachableTransportOriginAhead( - path, 0, player, reachable, new HashMap<>(), 15, 40)); + path, 0, player, reachable, ignored -> false, 15, 40)); } @Test diff --git a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt index a5a2e4ada81..b06d5fcac2a 100644 --- a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt +++ b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt @@ -75,6 +75,7 @@ net.runelite.client.plugins.microbot.api.tileobject.Rs2TileObjectCache#getStream net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.Client#isWidgetSelected(): boolean net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.GameObject#sizeX(): int net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.GameObject#sizeY(): int +net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.ObjectComposition#getImpostor(): ObjectComposition net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.ObjectComposition#getImpostorIds(): int[] net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.ObjectComposition#getName(): String @@ -193,6 +194,7 @@ net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#clickObject(T net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#clickObject(TileObject, String): boolean -> net.runelite.api.Client#isWidgetSelected(): boolean net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#clickObject(TileObject, String): boolean -> net.runelite.api.GameObject#sizeX(): int net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#clickObject(TileObject, String): boolean -> net.runelite.api.GameObject#sizeY(): int +net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#clickObject(TileObject, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#clickObject(TileObject, String): boolean -> net.runelite.api.ObjectComposition#getImpostor(): ObjectComposition net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#clickObject(TileObject, String): boolean -> net.runelite.api.ObjectComposition#getImpostorIds(): int[] net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#clickObject(TileObject, String): boolean -> net.runelite.api.ObjectComposition#getName(): String @@ -267,6 +269,7 @@ net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#getWallObject net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#getWorldArea(GameObject): WorldArea -> net.runelite.api.GameObject#sizeX(): int net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#getWorldArea(GameObject): WorldArea -> net.runelite.api.GameObject#sizeY(): int net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#getWorldArea(GameObject): WorldArea -> net.runelite.api.coords.WorldPoint#fromLocal(Client, LocalPoint): WorldPoint +net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#hasAction(ObjectComposition, String, boolean): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#hasLineOfSight(WorldPoint, TileObject): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#hasLineOfSight(WorldPoint, TileObject): boolean -> net.runelite.api.GameObject#sizeX(): int net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#hasLineOfSight(WorldPoint, TileObject): boolean -> net.runelite.api.GameObject#sizeY(): int @@ -311,7 +314,9 @@ net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#localPointFro net.runelite.client.plugins.microbot.util.gameobject.Rs2ObjectModel#(TileObject, Tile): void -> net.runelite.api.Client#getTickCount(): int net.runelite.client.plugins.microbot.util.gameobject.Rs2ObjectModel#(TileObject, Tile): void -> net.runelite.api.GameObject#sizeX(): int net.runelite.client.plugins.microbot.util.gameobject.Rs2ObjectModel#(TileObject, Tile): void -> net.runelite.api.GameObject#sizeY(): int +net.runelite.client.plugins.microbot.util.gameobject.Rs2ObjectModel#blocksLineOfSight(): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] net.runelite.client.plugins.microbot.util.gameobject.Rs2ObjectModel#blocksLineOfSight(): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.gameobject.Rs2ObjectModel#getActions(): String[] -> net.runelite.api.ObjectComposition#getActions(): String[] net.runelite.client.plugins.microbot.util.gameobject.Rs2ObjectModel#getCanonicalLocation(): WorldPoint -> net.runelite.api.Tile#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.gameobject.Rs2ObjectModel#getCanonicalLocation(): WorldPoint -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.gameobject.Rs2ObjectModel#getId(): int -> net.runelite.api.TileObject#getId(): int @@ -491,9 +496,9 @@ net.runelite.client.plugins.microbot.util.magic.Rs2Magic#canCast(MagicAction): b net.runelite.client.plugins.microbot.util.magic.Rs2Magic#cast(MagicAction, String, int): boolean -> net.runelite.api.widgets.Widget#getBounds(): Rectangle net.runelite.client.plugins.microbot.util.magic.Rs2Magic#castOn(MagicAction, Actor): boolean -> net.runelite.api.Actor#getLocalLocation(): LocalPoint net.runelite.client.plugins.microbot.util.magic.Rs2Magic#lambda$canCast$1(MagicAction, Widget): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int -net.runelite.client.plugins.microbot.util.magic.Rs2Magic#lambda$castOn$4(): boolean -> net.runelite.api.Client#isWidgetSelected(): boolean -net.runelite.client.plugins.microbot.util.magic.Rs2Magic#lambda$npcContact$14(Rectangle, String): boolean -> net.runelite.api.widgets.Widget#getBounds(): Rectangle -net.runelite.client.plugins.microbot.util.magic.Rs2Magic#lambda$npcContact$15(Rectangle, Rectangle, Widget): void -> net.runelite.api.widgets.Widget#getBounds(): Rectangle +net.runelite.client.plugins.microbot.util.magic.Rs2Magic#lambda$castOn$5(): boolean -> net.runelite.api.Client#isWidgetSelected(): boolean +net.runelite.client.plugins.microbot.util.magic.Rs2Magic#lambda$npcContact$15(Rectangle, String): boolean -> net.runelite.api.widgets.Widget#getBounds(): Rectangle +net.runelite.client.plugins.microbot.util.magic.Rs2Magic#lambda$npcContact$16(Rectangle, Rectangle, Widget): void -> net.runelite.api.widgets.Widget#getBounds(): Rectangle net.runelite.client.plugins.microbot.util.magic.Rs2Magic#npcContact(String): boolean -> net.runelite.api.widgets.Widget#getBounds(): Rectangle net.runelite.client.plugins.microbot.util.magic.Rs2Magic#quickCanCast(MagicAction): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int net.runelite.client.plugins.microbot.util.magic.Rs2Magic#quickCast(MagicAction): boolean -> net.runelite.api.widgets.Widget#getBounds(): Rectangle @@ -705,6 +710,7 @@ net.runelite.client.plugins.microbot.util.tileobject.Rs2TileObjectModel#click(St net.runelite.client.plugins.microbot.util.tileobject.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.Client#isWidgetSelected(): boolean net.runelite.client.plugins.microbot.util.tileobject.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.GameObject#sizeX(): int net.runelite.client.plugins.microbot.util.tileobject.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.GameObject#sizeY(): int +net.runelite.client.plugins.microbot.util.tileobject.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] net.runelite.client.plugins.microbot.util.tileobject.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.ObjectComposition#getImpostor(): ObjectComposition net.runelite.client.plugins.microbot.util.tileobject.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.ObjectComposition#getImpostorIds(): int[] net.runelite.client.plugins.microbot.util.tileobject.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.ObjectComposition#getName(): String @@ -744,6 +750,7 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getTile(WorldPoint): net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getTile(WorldPoint): Tile -> net.runelite.api.WorldView#isInstance(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getTile(WorldPoint): Tile -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getTransportsForPath(List, int, TransportType, boolean): List -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleCanoe(Transport): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleDoors(List, int, boolean): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleDoors(List, int, boolean): boolean -> net.runelite.api.ObjectComposition#getImpostorIds(): int[] net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleDoors(List, int, boolean): boolean -> net.runelite.api.ObjectComposition#getName(): String @@ -762,13 +769,14 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleObject(Transpor net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleObjectExceptions(Transport, TileObject): boolean -> net.runelite.api.TileObject#getId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleObjectExceptions(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleObjectExceptions(Transport, TileObject): boolean -> net.runelite.api.widgets.Widget#getItemId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleSelectedTransport(List, int, Rs2PathApi$ActiveTransportSelection): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleSelectedTransport(List, int, Rs2PathApi$ActiveTransportSelection): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleSelectedTransport(List, int, Rs2PathApi$ActiveTransportSelection): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleSelectedTransport(List, int, Rs2PathApi$ActiveTransportSelection): boolean -> net.runelite.api.Scene#isInstance(): boolean +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleSelectedTransport(List, int, Rs2PathApi$ActiveTransportSelection): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleSelectedTransport(List, int, Rs2PathApi$ActiveTransportSelection): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleSelectedTransport(List, int, Rs2PathApi$ActiveTransportSelection): boolean -> net.runelite.api.WorldView#getScene(): Scene net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleSpiritTree(Transport): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleTransports(List, int): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleTransports(List, int): boolean -> net.runelite.api.ObjectComposition#getName(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleTransports(List, int): boolean -> net.runelite.api.Scene#isInstance(): boolean -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleTransports(List, int): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleTransports(List, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleTransports(List, int): boolean -> net.runelite.api.WorldView#getScene(): Scene net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasDoorCandidateOnRawSegment(List, int): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasDoorCandidateOnRawSegment(List, int): boolean -> net.runelite.api.Scene#isInstance(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasDoorCandidateOnRawSegment(List, int): boolean -> net.runelite.api.WorldView#getScene(): Scene @@ -788,31 +796,36 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isPendingRouteDoorObj net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isPendingRouteDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isUnresolvedRouteDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, int): boolean -> net.runelite.api.ObjectComposition#getName(): String net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isUnresolvedRouteDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$getPointWithWallDistance$13(WorldView, int[][], WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$getPointWithWallDistance$14(WorldView, int[][], WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleFairyRing$192(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$161(Widget, Object[]): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$163(String): boolean -> net.runelite.api.widgets.Widget#getText(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$139(int, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$145(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$145(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$146(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$146(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$112(int, boolean, Integer, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$114(TileObject): boolean -> net.runelite.api.ObjectComposition#getName(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$116(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$117(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$118(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$119(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$121(int, List, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$122(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$153(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$154(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$processWalk$2(): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$processWalk$3(): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$getPointWithWallDistance$15(WorldView, int[][], WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$getPointWithWallDistance$16(WorldView, int[][], WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$185(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$188(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCanoe$190(Transport, String): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleFairyRing$210(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$179(Widget, Object[]): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$181(String): boolean -> net.runelite.api.widgets.Widget#getText(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$148(int, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$154(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$154(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$155(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$155(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$116(int, Integer, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$118(TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$118(TileObject): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$120(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$121(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$122(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$123(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$125(int, List, TileObject): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$125(int, List, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleSelectedTransport$126(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$162(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$163(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$processWalk$6(): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$processWalk$7(): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$resolveProbeGameObject$38(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$resolveProbeGameObject$39(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$tryHandleBlockingPathObjectsWithTimeout$69(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$tryHandleBlockingPathObjectsWithTimeout$68(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#localPointForWorld(WorldPoint): LocalPoint -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#localPointForWorld(WorldPoint): LocalPoint -> net.runelite.api.WorldView#isInstance(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2Walker#localPointForWorld(WorldPoint): LocalPoint -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint @@ -832,6 +845,7 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#resolveProbeGameObjec net.runelite.client.plugins.microbot.util.walker.Rs2Walker#setTarget(WorldPoint, String): void -> net.runelite.api.Client#getLocalPlayer(): Player net.runelite.client.plugins.microbot.util.walker.Rs2Walker#staminaThreshold(): int -> net.runelite.api.Client#getLocalPlayer(): Player net.runelite.client.plugins.microbot.util.walker.Rs2Walker#staminaThreshold(): int -> net.runelite.api.Player#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryHandleBlockingPathObjectsWithTimeout(List, int, int, int, long, Map): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryHandleBlockingPathObjectsWithTimeout(List, int, int, int, long, Map): boolean -> net.runelite.api.ObjectComposition#getName(): String net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryHandleDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, List, boolean): boolean -> net.runelite.api.ObjectComposition#getName(): String net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryResolveDoorBlockerLineOfSight(WorldPoint, List, int, int): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint @@ -861,8 +875,12 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkWithBankedTranspo net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkWithBankedTransportsAndStateLocked(WorldPoint, int, boolean): WalkerState -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkWithStateInternal(WorldPoint, int): WalkerState -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkWithStateInternal(WorldPoint, int): WalkerState -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint +net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorClassifier#doorCompositionSpecifiesOnlyCloseOrShut(ObjectComposition): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorClassifier#getDoorAction(ObjectComposition, List): String -> net.runelite.api.ObjectComposition#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorClassifier#isDoorComposition(ObjectComposition, List): boolean -> net.runelite.api.ObjectComposition#getActions(): String[] net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorClassifier#isDoorComposition(ObjectComposition, List): boolean -> net.runelite.api.ObjectComposition#getImpostorIds(): int[] net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorClassifier#isDoorComposition(ObjectComposition, List): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorClassifier#pickWalkDoorAction(ObjectComposition): String -> net.runelite.api.ObjectComposition#getActions(): String[] net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorDetection#isDoorLikeSceneObject(TileObject): boolean -> net.runelite.api.ObjectComposition#getName(): String net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorDetection#isDoorLikeSceneObject(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorGeometry#isDoorInteractionWithinRange(TileObject, WorldPoint, WorldPoint, WorldPoint, WorldPoint, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint diff --git a/runelite-client/src/upstreamPlanner/ADAPTER_PATCHES.md b/runelite-client/src/upstreamPlanner/ADAPTER_PATCHES.md new file mode 100644 index 00000000000..2a3ab28e8ab --- /dev/null +++ b/runelite-client/src/upstreamPlanner/ADAPTER_PATCHES.md @@ -0,0 +1,17 @@ +# Microbot adapter patches + +The production shadow adapter requires a deliberately small delta from the pinned core: + +- retain the exact selected `Transport` on each `PathStep` (the same patch used by the comparison harness); +- allow an immutable edge override for Microbot's pinned live-collision snapshot; +- allow an immutable walking-cost policy for dangerous-tile penalties; +- expose the transport-availability builder to the package-external adapter; +- replace the upstream plugin class with a resource/config compatibility anchor only. + +None of these hooks owns execution, reads Microbot globals, or changes queue ordering when its supplied +policy returns the default value. + +The reviewed patch budget is six modified upstream files and one adapter-added file. The offline vendored-core +checker rejects growth beyond that budget. Increasing either limit requires an ADR amendment that explains why +the hook cannot remain outside the core or be contributed upstream; changing only the digest is not sufficient +review for a larger long-lived fork. diff --git a/runelite-client/src/upstreamPlanner/LICENSE b/runelite-client/src/upstreamPlanner/LICENSE new file mode 100644 index 00000000000..54a15b462f3 --- /dev/null +++ b/runelite-client/src/upstreamPlanner/LICENSE @@ -0,0 +1,25 @@ +BSD 2-Clause License + +Copyright (c) , +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/runelite-client/src/upstreamPlanner/README.md b/runelite-client/src/upstreamPlanner/README.md new file mode 100644 index 00000000000..9233d89407b --- /dev/null +++ b/runelite-client/src/upstreamPlanner/README.md @@ -0,0 +1,9 @@ +# Pinned upstream planner core + +This source set contains the non-UI Java core from `Skretzo/shortest-path` at the exact revision in +`UPSTREAM_REVISION`. It deliberately excludes the upstream RuneLite plugin and overlays so Microbot ships +one plugin owner. `shortestpath.ShortestPathPlugin` is a compatibility anchor with no plugin descriptor. + +Keep upstream-derived files byte-identical except for changes listed in `ADAPTER_PATCHES.md`. The drift +checker verifies the pin and adapter patch surface. The classes are packaged in their original +`shortestpath.*` namespace so upstream diffs stay mechanical and reviewable. diff --git a/runelite-client/src/upstreamPlanner/UPSTREAM_REVISION b/runelite-client/src/upstreamPlanner/UPSTREAM_REVISION new file mode 100644 index 00000000000..3cb3807d16b --- /dev/null +++ b/runelite-client/src/upstreamPlanner/UPSTREAM_REVISION @@ -0,0 +1 @@ +ff8e961b32120175709df9630ece9468cc11347f diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/Destination.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/Destination.java new file mode 100644 index 00000000000..7fb87c9834f --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/Destination.java @@ -0,0 +1,266 @@ +package shortestpath; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Scanner; +import java.util.Set; + +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.Quest; +import net.runelite.api.Skill; +import shortestpath.transport.parser.FieldParser; +import shortestpath.transport.parser.QuestParser; +import shortestpath.transport.parser.SkillRequirementParser; +import shortestpath.transport.parser.VarRequirement; +import shortestpath.transport.parser.VarRequirementParser; + +/** + * Utility loader for destination coordinate sets grouped by feature category. + *

+ * Destination data is stored in tab-separated value (TSV) resources under + * {@code /destinations/**}. Each file's + * first non-empty line is treated as a header (leading comment markers + * {@code #} or {@code # } are stripped) and + * defines column names. Rows beginning with {@code #} or blank lines are + * ignored. Columns named "Destination" are + * parsed as space-delimited triples {@code (x y plane)} that are packed into + * single {@code int} values via + * {@link WorldPointUtil#packWorldPoint(int, int, int)}. + */ +@Slf4j +public class Destination +{ + private static final FieldParser SKILL_PARSER = new SkillRequirementParser(); + private static final FieldParser> QUEST_PARSER = new QuestParser(); + private static final FieldParser> VARBIT_PARSER = VarRequirementParser.forVarbits(); + private static final FieldParser> VARPLAYER_PARSER = VarRequirementParser.forVarPlayers(); + private static final String DELIM_COLUMN = "\t"; + private static final String PREFIX_COMMENT = "#"; + private static final String FILE_EXTENSION = "."; + private static final String DELIM_PATH = "/"; + private static final String DELIM = " "; + + /** + * Parses a TSV resource of destination coordinates and merges them into the provided map. + * The key in the destination map is derived from the directory component immediately preceding the file + * name (e.g., {@code /destinations/game_features/bank.tsv -> bank}). + * + * @param destinations accumulator map of category key to a set of packed world point integers. + * @param path classpath resource path to a TSV file beginning with a header line. + * @throws RuntimeException wrapping {@link IOException} if the resource cannot be read. + */ + private static void addDestinations(Map> destinations, String path) + { + try + { + String s = new String(Util.readAllBytes(Objects.requireNonNull(ShortestPathPlugin.class.getResourceAsStream(path))), + StandardCharsets.UTF_8); + Scanner scanner = new Scanner(s); + + // Header line is the first line in the file and will start with either '#' or + // '# ' + String[] headers = parse_header_line(scanner); + + String[] parts = path.replace(FILE_EXTENSION, DELIM_PATH).split(DELIM_PATH); + String entry = parts[parts.length - 2]; + + while (scanner.hasNextLine()) + { + String line = scanner.nextLine(); + + if (line.startsWith(PREFIX_COMMENT) || line.isBlank()) + { + continue; + } + + String[] fields = line.split(DELIM_COLUMN); + Map> fieldMap = new HashMap<>(); + for (int i = 0; i < headers.length; i++) + { + if (i < fields.length) + { + List values = fieldMap.getOrDefault(headers[i], new ArrayList<>()); + values.add(fields[i]); + fieldMap.put(headers[i], values); + } + } + for (String field : fieldMap.keySet()) + { + if ("Destination".equals(field)) + { + for (String value : fieldMap.get(field)) + { + try + { + String[] destinationArray = value.split(DELIM); + if (destinationArray.length == 3) + { + Set entryDestinations = destinations.getOrDefault(entry, new HashSet<>()); + entryDestinations.add(WorldPointUtil.packWorldPoint( + Integer.parseInt(destinationArray[0]), + Integer.parseInt(destinationArray[1]), + Integer.parseInt(destinationArray[2]))); + destinations.put(entry, entryDestinations); + } + } + catch (NumberFormatException e) + { + log.error("Invalid destination coordinate", e); + } + } + } + } + } + scanner.close(); + } + catch (IOException e) + { + throw new RuntimeException(e); + } + } + + private static String[] parse_header_line(Scanner scanner) + { + String headerLine = scanner.nextLine(); + headerLine = headerLine.startsWith(PREFIX_COMMENT + " ") + ? headerLine.replace(PREFIX_COMMENT + " ", PREFIX_COMMENT) + : headerLine; + headerLine = headerLine.startsWith(PREFIX_COMMENT) ? headerLine.replace(PREFIX_COMMENT, "") : headerLine; + return headerLine.split(DELIM_COLUMN); + } + + /** + * Loads a predefined subset of destination categories from bundled TSV resources. + * + * @return a map from destination category key to a set of packed world point integers. + */ + public static Map> loadAllFromResources() + { + Map> destinations = new HashMap<>(10); + addDestinations(destinations, "/destinations/game_features/altar.tsv"); + addDestinations(destinations, "/destinations/game_features/bank.tsv"); + addDestinations(destinations, "/destinations/training/anvil.tsv"); + addDestinations(destinations, "/destinations/shopping/apothecary.tsv"); + return destinations; + } + + /** + * Per-packed-point requirements for bank tiles, parsed from {@code bank.tsv} columns + * Skills, Quests, Varbits, and VarPlayers (same TSV format as transports). + * Tiles with no requirement rows behave as {@link DestinationRequirements#EMPTY}. + */ + public static Map loadBankRequirementsFromResources() + { + Map requirements = new HashMap<>(); + final String path = "/destinations/game_features/bank.tsv"; + final String DELIM_COLUMN = "\t"; + final String PREFIX_COMMENT = "#"; + final String DELIM = " "; + + try + { + String s = new String(Util.readAllBytes(Objects.requireNonNull(ShortestPathPlugin.class.getResourceAsStream(path))), StandardCharsets.UTF_8); + try (Scanner scanner = new Scanner(s)) + { + String[] headers = parse_header_line(scanner); + for (int i = 0; i < headers.length; i++) + { + headers[i] = headers[i].trim(); + } + + int destCol = indexOf(headers, "Destination"); + int skillsCol = indexOf(headers, "Skills"); + int questsCol = indexOf(headers, "Quests"); + int varbitsCol = indexOf(headers, "Varbits"); + int varPlayersCol = indexOf(headers, "VarPlayers"); + if (destCol < 0) + { + return requirements; + } + + while (scanner.hasNextLine()) + { + String line = scanner.nextLine(); + if (line.startsWith(PREFIX_COMMENT) || line.isBlank()) + { + continue; + } + String[] fields = line.split(DELIM_COLUMN, -1); + if (fields.length <= destCol) + { + continue; + } + String destField = fields[destCol].trim(); + String[] destinationArray = destField.split(DELIM); + if (destinationArray.length != 3) + { + continue; + } + int packed; + try + { + packed = WorldPointUtil.packWorldPoint( + Integer.parseInt(destinationArray[0]), + Integer.parseInt(destinationArray[1]), + Integer.parseInt(destinationArray[2])); + } + catch (NumberFormatException e) + { + log.error("Invalid destination coordinate in bank.tsv", e); + continue; + } + + String skillsStr = fieldAt(fields, skillsCol); + String questsStr = fieldAt(fields, questsCol); + String varbitsStr = fieldAt(fields, varbitsCol); + String varPlayersStr = fieldAt(fields, varPlayersCol); + + int[] skillLevels = skillsCol >= 0 ? SKILL_PARSER.parse(blankToNull(skillsStr)) : new int[Skill.values().length + 3]; + Set quests = questsCol >= 0 ? QUEST_PARSER.parse(blankToNull(questsStr)) : Set.of(); + Set varbits = varbitsCol >= 0 ? VARBIT_PARSER.parse(blankToNull(varbitsStr)) : Set.of(); + Set varPlayers = varPlayersCol >= 0 ? VARPLAYER_PARSER.parse(blankToNull(varPlayersStr)) : Set.of(); + + DestinationRequirements rowReq = new DestinationRequirements(skillLevels, quests, varbits, varPlayers); + requirements.merge(packed, rowReq, DestinationRequirements::merge); + } + } + } + catch (IOException e) + { + throw new RuntimeException(e); + } + return requirements; + } + + private static int indexOf(String[] headers, String name) + { + for (int i = 0; i < headers.length; i++) + { + if (name.equals(headers[i])) + { + return i; + } + } + return -1; + } + + private static String fieldAt(String[] fields, int col) + { + if (col < 0 || col >= fields.length) + { + return ""; + } + return fields[col]; + } + + private static String blankToNull(String s) + { + return s == null || s.isBlank() ? null : s; + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/DestinationRequirements.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/DestinationRequirements.java new file mode 100644 index 00000000000..2d3ab8d40bd --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/DestinationRequirements.java @@ -0,0 +1,97 @@ +package shortestpath; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import lombok.Getter; +import net.runelite.api.Quest; +import net.runelite.api.Skill; +import shortestpath.transport.parser.VarRequirement; + +/** + * Optional access requirements for a destination tile (e.g. bank booths). Empty requirements mean + * the destination is always usable when reached. + */ +public final class DestinationRequirements +{ + /** + * All-zero skill array; no skill requirements. + */ + public static final DestinationRequirements EMPTY = new DestinationRequirements(); + + @Getter + private final int[] skillLevels; + @Getter + private final Set quests; + @Getter + private final Set varbits; + @Getter + private final Set varPlayers; + + private DestinationRequirements() + { + this.skillLevels = new int[Skill.values().length + 3]; + this.quests = Collections.emptySet(); + this.varbits = Collections.emptySet(); + this.varPlayers = Collections.emptySet(); + } + + public DestinationRequirements( + int[] skillLevels, + Set quests, + Set varbits, + Set varPlayers) + { + this.skillLevels = skillLevels != null ? skillLevels : new int[Skill.values().length + 3]; + this.quests = quests != null ? quests : Collections.emptySet(); + this.varbits = varbits != null ? varbits : Collections.emptySet(); + this.varPlayers = varPlayers != null ? varPlayers : Collections.emptySet(); + } + + /** + * @return a merged requirement when the same tile appears on multiple rows (max skills, union sets). + */ + public static DestinationRequirements merge(DestinationRequirements a, DestinationRequirements b) + { + if (a == null || a.isEmpty()) + { + return b != null ? b : EMPTY; + } + if (b == null || b.isEmpty()) + { + return a; + } + int[] skills = new int[Skill.values().length + 3]; + for (int i = 0; i < skills.length; i++) + { + skills[i] = Math.max(a.skillLevels[i], b.skillLevels[i]); + } + Set q = new HashSet<>(a.quests); + q.addAll(b.quests); + Set vb = new HashSet<>(a.varbits); + vb.addAll(b.varbits); + Set vp = new HashSet<>(a.varPlayers); + vp.addAll(b.varPlayers); + return new DestinationRequirements(skills, q, vb, vp); + } + + public boolean isEmpty() + { + if (!quests.isEmpty()) + { + return false; + } + if (!varbits.isEmpty() || !varPlayers.isEmpty()) + { + return false; + } + for (int level : skillLevels) + { + if (level > 0) + { + return false; + } + } + return true; + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/ItemVariations.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/ItemVariations.java new file mode 100644 index 00000000000..2e65770c296 --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/ItemVariations.java @@ -0,0 +1,304 @@ +package shortestpath; + +import lombok.Getter; +import net.runelite.api.gameval.ItemID; + +public enum ItemVariations +{ + AIR_RUNE(ItemID.AIRRUNE, + ItemID.MISTRUNE, + ItemID.DUSTRUNE, + ItemID.SMOKERUNE), + ARDOUGNE_CLOAK(ItemID.ARDY_CAPE_EASY, + ItemID.ARDY_CAPE_MEDIUM, + ItemID.ARDY_CAPE_HARD, + ItemID.ARDY_CAPE_ELITE, + ItemID.SKILLCAPE_MAX_ARDY), + ASTRAL_RUNE(ItemID.ASTRALRUNE), + AXE(ItemID.BRONZE_AXE, + ItemID.IRON_AXE, + ItemID.STEEL_AXE, + ItemID.BLACK_AXE, + ItemID.MITHRIL_AXE, + ItemID.ADAMANT_AXE, + ItemID.RUNE_AXE, + ItemID.DRAGON_AXE, + ItemID.CRYSTAL_AXE, + ItemID.TRAIL_GILDED_AXE, + ItemID.INFERNAL_AXE, + ItemID._3A_AXE), + BANANA(ItemID.BANANA), + BLOOD_RUNE(ItemID.BLOODRUNE), + BROWN_APRON(ItemID.BROWN_APRON, + ItemID.GOLDEN_APRON, + ItemID.SKILLCAPE_CRAFTING, + ItemID.SKILLCAPE_CRAFTING_TRIMMED, + ItemID.SKILLCAPE_CRAFTING_HOOD), + BRYOPHYTAS_STAFF(ItemID.NATURE_STAFF_CHARGED), + CAPESLOT(ItemID.CASTLEWARS_HOOD_SARADOMIN_PRIZE, // TODO: also use slot or item category + ItemID.CASTLEWARS_HOOD_ZAMORAK_PRIZE), + CLIMBING_BOOTS(ItemID.DEATH_CLIMBINGBOOTS, + ItemID.CLIMBING_BOOTS_G), + COINS(ItemID.COINS), + CROSSBOW(ItemID.CROSSBOW, + ItemID.PHOENIX_CROSSBOW, + ItemID.DTTD_BONE_CROSSBOW, + ItemID.HUNTING_CROSSBOW, + ItemID.XBOWS_CROSSBOW_BRONZE, + ItemID.XBOWS_CROSSBOW_IRON, + ItemID.XBOWS_CROSSBOW_STEEL, + ItemID.XBOWS_CROSSBOW_MITHRIL, + ItemID.XBOWS_CROSSBOW_ADAMANTITE, + ItemID.XBOWS_CROSSBOW_RUNITE, + ItemID.XBOWS_CROSSBOW_DRAGON, + ItemID.DRAGONHUNTER_XBOW, + ItemID.BARROWS_KARIL_WEAPON, + ItemID.BARROWS_KARIL_WEAPON_BROKEN, + ItemID.BARROWS_KARIL_WEAPON_25, + ItemID.BARROWS_KARIL_WEAPON_50, + ItemID.BARROWS_KARIL_WEAPON_75, + ItemID.BARROWS_KARIL_WEAPON_100, + ItemID.ACB, + ItemID.ZARYTE_XBOW), + DRAMEN_STAFF(ItemID.DRAMEN_STAFF, + ItemID.DRAMEN_STAFF_AIR, + ItemID.DRAMEN_STAFF_FIRE, + ItemID.DRAMEN_STAFF_WATER, + ItemID.LUNAR_MOONCLAN_LIMINAL_STAFF), + DUST_BATTLESTAFF(ItemID.DUST_BATTLESTAFF, + ItemID.MYSTIC_DUST_BATTLESTAFF), + DUST_RUNE(ItemID.DUSTRUNE), + DUSTY_KEY(ItemID.DUSTY_KEY), + EARTH_RUNE(ItemID.EARTHRUNE, + ItemID.DUSTRUNE, + ItemID.MUDRUNE, + ItemID.LAVARUNE), + ECTO_TOKEN(ItemID.ECTOTOKEN), + FIRE_RUNE(ItemID.FIRERUNE, + ItemID.SMOKERUNE, + ItemID.STEAMRUNE, + ItemID.LAVARUNE), + GAMES_NECKLACE(ItemID.NECKLACE_OF_MINIGAMES_8, + ItemID.NECKLACE_OF_MINIGAMES_7, + ItemID.NECKLACE_OF_MINIGAMES_6, + ItemID.NECKLACE_OF_MINIGAMES_5, + ItemID.NECKLACE_OF_MINIGAMES_4, + ItemID.NECKLACE_OF_MINIGAMES_3, + ItemID.NECKLACE_OF_MINIGAMES_2, + ItemID.NECKLACE_OF_MINIGAMES_1), + GLOWING_FUNGUS(ItemID.GLOWING_FUNGUS), + HEADSLOT(ItemID.CASTLEWARS_CLOAK_SARADOMIN_PRIZE, // TODO: also use slot or item category + ItemID.CASTLEWARS_CLOAK_ZAMORAK_PRIZE), + LAVA_BATTLESTAFF(ItemID.LAVA_BATTLESTAFF, + ItemID.MYSTIC_LAVA_STAFF), + LAVA_RUNE(ItemID.LAVARUNE), + LAW_RUNE(ItemID.LAWRUNE), + MACHETE(ItemID.MACHETTE, + ItemID.MACHETTE_OPAL, + ItemID.MACHETTE_JADE, + ItemID.MACHETTE_REDTOPAZ), + MAX_CAPE(ItemID.SKILLCAPE_MAX, + ItemID.SKILLCAPE_MAX_WORN, + ItemID.SKILLCAPE_MAX_FIRECAPE, + ItemID.SKILLCAPE_MAX_FIRECAPE_DUMMY, + ItemID.SKILLCAPE_MAX_FIRECAPE_TROUVER, + ItemID.SKILLCAPE_MAX_SARADOMIN, + ItemID.SKILLCAPE_MAX_ZAMORAK, + ItemID.SKILLCAPE_MAX_GUTHIX, + ItemID.SKILLCAPE_MAX_ANMA, + ItemID.SKILLCAPE_MAX_ARDY, + ItemID.SKILLCAPE_MAX_INFERNALCAPE, + ItemID.SKILLCAPE_MAX_INFERNALCAPE_DUMMY, + ItemID.SKILLCAPE_MAX_INFERNALCAPE_TROUVER, + ItemID.SKILLCAPE_MAX_SARADOMIN2, + ItemID.SKILLCAPE_MAX_SARADOMIN2_TROUVER, + ItemID.SKILLCAPE_MAX_ZAMORAK2, + ItemID.SKILLCAPE_MAX_ZAMORAK2_TROUVER, + ItemID.SKILLCAPE_MAX_GUTHIX2, + ItemID.SKILLCAPE_MAX_GUTHIX2_TROUVER, + ItemID.SKILLCAPE_MAX_ASSEMBLER, + ItemID.SKILLCAPE_MAX_ASSEMBLER_TROUVER, + ItemID.SKILLCAPE_MAX_MYTHICAL, + ItemID.SKILLCAPE_MAX_ASSEMBLER_MASORI, + ItemID.SKILLCAPE_MAX_ASSEMBLER_MASORI_TROUVER, + ItemID.SKILLCAPE_MAX_DIZANAS, + ItemID.SKILLCAPE_MAX_DIZANAS_TROUVER), + MAX_HOOD(ItemID.SKILLCAPE_MAX_HOOD, + ItemID.SKILLCAPE_MAX_HOOD_FIRECAPE, + ItemID.SKILLCAPE_MAX_HOOD_SARADOMIN, + ItemID.SKILLCAPE_MAX_HOOD_ZAMORAK, + ItemID.SKILLCAPE_MAX_HOOD_GUTHIX, + ItemID.SKILLCAPE_MAX_HOOD_ANMA, + ItemID.SKILLCAPE_MAX_HOOD_ARDY, + ItemID.SKILLCAPE_MAX_HOOD_INFERNALCAPE, + ItemID.SKILLCAPE_MAX_HOOD_SARADOMIN2, + ItemID.SKILLCAPE_MAX_HOOD_ZAMORAK2, + ItemID.SKILLCAPE_MAX_HOOD_GUTHIX2, + ItemID.SKILLCAPE_MAX_HOOD_ASSEMBLER, + ItemID.SKILLCAPE_MAX_HOOD_MYTHICAL, + ItemID.SKILLCAPE_MAX_HOOD_ASSEMBLER_MASORI, + ItemID.SKILLCAPE_MAX_HOOD_DIZANAS), + MAZE_KEY(ItemID.MELZARKEY), + MIND_RUNE(ItemID.MINDRUNE), + MIST_BATTLESTAFF(ItemID.MIST_BATTLESTAFF, + ItemID.MYSTIC_MIST_BATTLESTAFF), + MIST_RUNE(ItemID.MISTRUNE), + MITH_GRAPPLE(ItemID.XBOWS_GRAPPLE_TIP_BOLT_MITHRIL_ROPE), + MUD_BATTLESTAFF(ItemID.MUD_BATTLESTAFF, + ItemID.MYSTIC_MUD_STAFF), + MUD_RUNE(ItemID.MUDRUNE), + MYSTIC_DUST_STAFF(ItemID.MYSTIC_DUST_BATTLESTAFF), + MYSTIC_LAVA_STAFF(ItemID.MYSTIC_LAVA_STAFF), + MYSTIC_MIST_STAFF(ItemID.MYSTIC_MIST_BATTLESTAFF), + MYSTIC_MUD_STAFF(ItemID.MYSTIC_MUD_STAFF), + MYSTIC_SMOKE_STAFF(ItemID.MYSTIC_SMOKE_BATTLESTAFF), + MYSTIC_STEAM_STAFF(ItemID.MYSTIC_STEAM_BATTLESTAFF), + NATURE_RUNE(ItemID.NATURERUNE), + PICKAXE(ItemID.BRONZE_PICKAXE, + ItemID.IRON_PICKAXE, + ItemID.STEEL_PICKAXE, + ItemID.BLACK_PICKAXE, + ItemID.MITHRIL_PICKAXE, + ItemID.ADAMANT_PICKAXE, + ItemID.RUNE_PICKAXE, + ItemID.DRAGON_PICKAXE, + ItemID.CRYSTAL_PICKAXE, + ItemID.TRAIL_GILDED_PICKAXE, + ItemID._3A_PICKAXE, + ItemID.DRAGON_PICKAXE_PRETTY, + ItemID.ZALCANO_PICKAXE, + ItemID.TRAILBLAZER_PICKAXE_NO_INFERNAL, + ItemID.TRAILBLAZER_RELOADED_PICKAXE_NO_INFERNAL, + ItemID.INFERNAL_PICKAXE), + ROPE(ItemID.ROPE), + SHANTAY_PASS(ItemID.SHANTAY_PASS), + SKAVID_MAP(ItemID.SKAVIDMAP), + SMOKE_BATTLESTAFF(ItemID.SMOKE_BATTLESTAFF, + ItemID.MYSTIC_SMOKE_BATTLESTAFF), + SMOKE_RUNE(ItemID.SMOKERUNE), + SOUL_RUNE(ItemID.SOULRUNE), + STAFF_OF_AIR(ItemID.STAFF_OF_AIR, + ItemID.AIR_BATTLESTAFF, + ItemID.MIST_BATTLESTAFF, + ItemID.DUST_BATTLESTAFF, + ItemID.SMOKE_BATTLESTAFF, + ItemID.MYSTIC_MIST_BATTLESTAFF, + ItemID.MYSTIC_DUST_BATTLESTAFF, + ItemID.MYSTIC_SMOKE_BATTLESTAFF, + ItemID.SHADOWFLAME_QUADRANT), + STAFF_OF_EARTH(ItemID.STAFF_OF_EARTH, + ItemID.EARTH_BATTLESTAFF, + ItemID.DUST_BATTLESTAFF, + ItemID.MUD_BATTLESTAFF, + ItemID.LAVA_BATTLESTAFF, + ItemID.MYSTIC_DUST_BATTLESTAFF, + ItemID.MYSTIC_MUD_STAFF, + ItemID.MYSTIC_LAVA_STAFF, + ItemID.SHADOWFLAME_QUADRANT), + STAFF_OF_FIRE(ItemID.STAFF_OF_FIRE, + ItemID.FIRE_BATTLESTAFF, + ItemID.SMOKE_BATTLESTAFF, + ItemID.STEAM_BATTLESTAFF, + ItemID.LAVA_BATTLESTAFF, + ItemID.MYSTIC_SMOKE_BATTLESTAFF, + ItemID.MYSTIC_STEAM_BATTLESTAFF, + ItemID.MYSTIC_LAVA_STAFF, + ItemID.TWINFLAME_STAFF, + ItemID.SHADOWFLAME_QUADRANT), + STAFF_OF_WATER(ItemID.STAFF_OF_WATER, + ItemID.WATER_BATTLESTAFF, + ItemID.MIST_BATTLESTAFF, + ItemID.MUD_BATTLESTAFF, + ItemID.STEAM_BATTLESTAFF, + ItemID.MYSTIC_MIST_BATTLESTAFF, + ItemID.MYSTIC_MUD_STAFF, + ItemID.MYSTIC_STEAM_BATTLESTAFF, + ItemID.TWINFLAME_STAFF, + ItemID.SHADOWFLAME_QUADRANT), + STEAM_BATTLESTAFF(ItemID.STEAM_BATTLESTAFF, + ItemID.MYSTIC_STEAM_BATTLESTAFF, + ItemID.TWINFLAME_STAFF), + STEAM_RUNE(ItemID.STEAMRUNE), + TOME_OF_EARTH(ItemID.TOME_OF_EARTH), + TOME_OF_FIRE(ItemID.TOME_OF_FIRE), + TOME_OF_WATER(ItemID.TOME_OF_WATER), + WATER_RUNE(ItemID.WATERRUNE, + ItemID.MISTRUNE, + ItemID.MUDRUNE, + ItemID.STEAMRUNE), + ; + + @Getter + private final int[] ids; + + ItemVariations(int... ids) + { + this.ids = ids; + } + + public static int[] staves(ItemVariations itemVariation) + { + if (itemVariation == null) + { + return null; + } + switch (itemVariation) + { + case AIR_RUNE: + return STAFF_OF_AIR.ids; + case DUST_RUNE: + return DUST_BATTLESTAFF.ids; + case EARTH_RUNE: + return STAFF_OF_EARTH.ids; + case FIRE_RUNE: + return STAFF_OF_FIRE.ids; + case LAVA_RUNE: + return LAVA_BATTLESTAFF.ids; + case MIST_RUNE: + return MIST_BATTLESTAFF.ids; + case MUD_RUNE: + return MUD_BATTLESTAFF.ids; + case NATURE_RUNE: + return BRYOPHYTAS_STAFF.ids; + case SMOKE_RUNE: + return SMOKE_BATTLESTAFF.ids; + case STEAM_RUNE: + return STEAM_BATTLESTAFF.ids; + case WATER_RUNE: + return STAFF_OF_WATER.ids; + default: + return null; + } + } + + public static int[] offhands(ItemVariations itemVariation) + { + if (itemVariation == null) + { + return null; + } + switch (itemVariation) + { + case EARTH_RUNE: + return TOME_OF_EARTH.ids; + case FIRE_RUNE: + return TOME_OF_FIRE.ids; + case WATER_RUNE: + return TOME_OF_WATER.ids; + default: + return null; + } + } + + public static ItemVariations fromName(String name) + { + for (ItemVariations itemVariations : ItemVariations.values()) + { + if (itemVariations.name().equals(name)) + { + return itemVariations; + } + } + return null; + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/JewelleryBoxTier.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/JewelleryBoxTier.java new file mode 100644 index 00000000000..33863208a99 --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/JewelleryBoxTier.java @@ -0,0 +1,35 @@ +package shortestpath; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum JewelleryBoxTier +{ + NONE("None"), + BASIC("Basic"), + FANCY("Fancy"), + ORNATE("Ornate"), + ; + + private final String type; + + public static JewelleryBoxTier fromType(String type) + { + for (JewelleryBoxTier tier : values()) + { + if (tier.type.equals(type)) + { + return tier; + } + } + return null; + } + + @Override + public String toString() + { + return type; + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/PrimitiveIntHashMap.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/PrimitiveIntHashMap.java new file mode 100644 index 00000000000..be58bad4f9f --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/PrimitiveIntHashMap.java @@ -0,0 +1,312 @@ +package shortestpath; + +import java.util.Arrays; +import java.util.Collection; + +/** + * A lightweight hash map keyed by primitive {@code int} values using an open-addressed table with + * linear probing. + *

+ * Keys and values are stored in two parallel arrays ({@link #keys} / {@link #values}) rather than a + * node object per entry, so the map holds only a couple of arrays regardless of how many entries it + * contains (issue #491). A slot is occupied iff its value reference is non-null; since null values + * are rejected, {@code int} keys including {@code 0} need no separate sentinel. When the entry count + * reaches the configured {@linkplain #capacity load threshold} the table is rehashed into a larger + * power-of-two array. + *

+ * This implementation is intentionally minimal and tailored for the plugin's pathfinding needs: + *

    + *
  • No support for removing entries.
  • + *
  • No iteration views (keys, values, or entry set) are exposed beyond {@link #keys()}.
  • + *
  • Duplicate key insertion replaces the previous value, or appends collection contents when both + * the old and new values are {@link Collection}s (best effort; falls back to replacement on + * errors).
  • + *
+ * + * @param the value type stored for each primitive {@code int} key. Must be non-null. + */ +public class PrimitiveIntHashMap +{ + private static final int MINIMUM_SIZE = 8; + + // How full the map should get before growing it again. Smaller values speed up + // lookup times at the expense of space + private static final float DEFAULT_LOAD_FACTOR = 0.75f; + private final float loadFactor; + private int[] keys; + private Object[] values; + private int size; + private int capacity; + private int maxSize; + private int mask; + + /** + * Creates a new map with the specified initial size and the default load factor (0.75). + * + * @param initialSize initial expected number of elements; rounded to the next power of two + * internally. + */ + public PrimitiveIntHashMap(int initialSize) + { + this(initialSize, DEFAULT_LOAD_FACTOR); + } + + /** + * Creates a new map with the given initial size and load factor. + * + * @param initialSize initial expected number of elements; rounded up to maintain a + * power-of-two capacity. + * @param loadFactor a value in the range {@code [0.0, 1.0]} determining when the map rehashes. + * @throws IllegalArgumentException if {@code loadFactor} is outside the inclusive range 0..1. + */ + public PrimitiveIntHashMap(int initialSize, float loadFactor) + { + if (loadFactor < 0.0f || loadFactor > 1.0f) + { + throw new IllegalArgumentException("Load factor must be between 0 and 1"); + } + + this.loadFactor = loadFactor; + size = 0; + setNewSize(initialSize); + recreateArrays(); + } + + /** + * Hash function tuned for packed world point integer encodings. Mixes higher bits downward to + * reduce clustering while remaining inexpensive. + */ + private static int hash(int value) + { + // Full multiplicative avalanche. Linear probing is very sensitive to clustering, and packed + // world points of nearby tiles differ only in a few low bits, so the cheap xor-shift mix used + // previously left spatially-clustered transport origins clustered in the table too -> long + // probe runs on the per-tile miss lookups. Fibonacci-style multiply + xorshift spreads them. + int h = value * 0x9E3779B1; + return h ^ (h >>> 16); + } + + /** + * Returns the number of key/value pairs currently stored. + * + * @return current entry count (always {@code >= 0}). + */ + public int size() + { + return size; + } + + /** + * Returns all keys present in the map as a freshly allocated {@code int[]}. + * + * @return array of all keys in unspecified order; length equals {@link #size()}. + */ + @SuppressWarnings("unused") + public int[] keys() + { + int[] result = new int[size]; + int index = 0; + for (int i = 0; i < values.length; ++i) + { + if (values[i] != null) + { + result[index++] = keys[i]; + } + } + return result; + } + + /** + * Retrieves the value mapped to the provided key, or {@code null} if absent. + * + * @param key primitive key to look up. + * @return the mapped value, or {@code null} if the key does not exist. + */ + public V get(int key) + { + return getOrDefault(key, null); + } + + /** + * Retrieves the value mapped to the provided key. + * + * @param key primitive key to look up. + * @param defaultValue value to return if the key is not present. + * @return the mapped value, or {@code defaultValue} when absent. + */ + @SuppressWarnings("unchecked") + public V getOrDefault(int key, V defaultValue) + { + final int slot = findSlot(key); + if (slot < 0) + { + return defaultValue; + } + return (V) values[slot]; + } + + /** + * Associates the specified value with the given key. + *

+ * If a mapping already exists and both the existing and new values implement {@link Collection}, + * the method attempts to append all elements of the new collection into the existing one. If the + * append fails (e.g., due to incompatible element types or an unsupported operation) the existing + * value is replaced entirely. Otherwise the existing value is simply replaced. + * + * @param key primitive key to insert or update. + * @param value non-null value to associate. + * @param inferred element type if both values are collections. + * @return the previous value mapped to {@code key} (if any), or {@code null} if inserting a new + * entry. + * @throws IllegalArgumentException if {@code value} is {@code null}. + */ + @SuppressWarnings({"unchecked"}) + public V put(int key, V value) + { + if (value == null) + { + throw new IllegalArgumentException("Cannot insert a null value"); + } + + int i = (hash(key) & 0x7FFFFFFF) & mask; + while (values[i] != null) + { + if (keys[i] == key) + { + V previous = (V) values[i]; + if (previous instanceof Collection && value instanceof Collection) + { // append + try + { + Collection prevCollection = (Collection) values[i]; + Collection newCollection = (Collection) value; + prevCollection.addAll(newCollection); + } + catch (ClassCastException | UnsupportedOperationException e) + { + // If the collections contain incompatible types or the operation is not + // supported, just replace instead of append + values[i] = value; + } + } + else + { // replace + values[i] = value; + } + return previous; + } + i = (i + 1) & mask; + } + + keys[i] = key; + values[i] = value; + incrementSize(); + return null; + } + + private int findSlot(int key) + { + int i = (hash(key) & 0x7FFFFFFF) & mask; + while (values[i] != null) + { + if (keys[i] == key) + { + return i; + } + i = (i + 1) & mask; + } + return -1; + } + + private void incrementSize() + { + size++; + if (size >= capacity) + { + rehash(); + } + } + + private int getNewMaxSize(int size) + { + int nextPow2 = -1 >>> Integer.numberOfLeadingZeros(size); + if (nextPow2 >= (Integer.MAX_VALUE >>> 1)) + { + return (Integer.MAX_VALUE >>> 1) + 1; + } + return nextPow2 + 1; + } + + private void setNewSize(int size) + { + if (size < MINIMUM_SIZE) + { + size = MINIMUM_SIZE - 1; + } + + maxSize = getNewMaxSize(size); + mask = maxSize - 1; + capacity = (int) (maxSize * loadFactor); + } + + private void growCapacity() + { + setNewSize(maxSize); + } + + // Grow the table then rehash all the values into it and discard the old arrays + private void rehash() + { + growCapacity(); + + final int[] oldKeys = keys; + final Object[] oldValues = values; + recreateArrays(); + + for (int i = 0; i < oldValues.length; ++i) + { + if (oldValues[i] == null) + { + continue; + } + + int slot = (hash(oldKeys[i]) & 0x7FFFFFFF) & mask; + while (values[slot] != null) + { + slot = (slot + 1) & mask; + } + keys[slot] = oldKeys[i]; + values[slot] = oldValues[i]; + } + } + + private void recreateArrays() + { + keys = new int[maxSize]; + values = new Object[maxSize]; + } + + /** + * Approximate fullness of the table as a percentage of the entry count over the table length. + * + * @return fullness percentage in {@code [0.0, 100.0]}, or {@link Double#NaN} when the map is + * empty. + */ + public double calculateFullness() + { + if (size == 0) + { + return Double.NaN; + } + return 100.0 * (double) size / (double) maxSize; + } + + /** + * Removes all entries from the map. The backing arrays are retained and reused. + */ + public void clear() + { + size = 0; + Arrays.fill(values, null); + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/PrimitiveIntList.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/PrimitiveIntList.java new file mode 100644 index 00000000000..272ed293f6c --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/PrimitiveIntList.java @@ -0,0 +1,306 @@ +package shortestpath; + +import java.util.Arrays; + +/** + * A minimal, growable list implementation for primitive {@code int} values. + *

+ * This class avoids boxing overhead present in {@link java.util.List Integer} + * collections + * by storing values in a backing {@code int[]} that grows as needed. It + * purposefully + * implements only the operations required by the pathfinding logic in this + * plugin; it is + * not a drop‑in replacement for {@link java.util.ArrayList}. + *

+ * The growth policy increases capacity by 50% (similar to {@code ArrayList}) + * when the + * existing array is exhausted. Capacity never shrinks. + */ +public class PrimitiveIntList +{ + private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; + + private int[] elementData; + private int size; + + /** + * Creates a new list with the specified initial capacity. + * + * @param initialCapacity the initial length of the backing array (must be + * {@code >= 0}). + * @param initialize if {@code true}, the {@link #size} is set equal to + * {@code initialCapacity}, + * effectively pre-filling the logical list with zeroes. + * If {@code false}, + * the list is created empty. + * @throws IllegalArgumentException if {@code initialCapacity < 0}. + */ + public PrimitiveIntList(int initialCapacity, boolean initialize) + { + if (initialCapacity < 0) + { + throw new IllegalArgumentException("Illegal capacity: " + initialCapacity); + } + this.elementData = new int[initialCapacity]; + if (initialize) + { + size = initialCapacity; + } + } + + /** + * Creates an empty list with the given backing array capacity. + * + * @param initialCapacity initial length of the internal array (must be + * {@code >= 0}). + */ + public PrimitiveIntList(int initialCapacity) + { + this(initialCapacity, false); + } + + /** + * Creates an empty list with a default initial capacity of 10. + */ + public PrimitiveIntList() + { + this(10); + } + + private static int hugeCapacity(int minCapacity) + { + if (minCapacity < 0) + { // overflow + throw new OutOfMemoryError(); + } + return (minCapacity > MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE; + } + + /** + * Ensures that the backing array can store at least {@code minCapacity} + * elements. + * If the current capacity is already sufficient the call is a no-op. + * + * @param minCapacity the desired minimum capacity (ignored if {@code <= 0}). + */ + public void ensureCapacity(int minCapacity) + { + if (minCapacity > 0) + { + ensureCapacityInternal(minCapacity); + } + } + + private void ensureCapacityInternal(int minCapacity) + { + if (minCapacity - elementData.length > 0) + { + grow(minCapacity); + } + } + + private void grow(int minCapacity) + { + int oldCapacity = elementData.length; + int newCapacity = oldCapacity + (oldCapacity >> 1); + if (newCapacity - minCapacity < 0) + { + newCapacity = minCapacity; + } + if (newCapacity - MAX_ARRAY_SIZE > 0) + { + newCapacity = hugeCapacity(minCapacity); + } + elementData = Arrays.copyOf(elementData, newCapacity); + } + + /** + * Returns the number of elements that have been added to (or initialized in) + * this list. + * + * @return current element count (always {@code >= 0}). + */ + public int size() + { + return size; + } + + /** + * Indicates whether the list currently holds zero elements. + * + * @return {@code true} if {@link #size()} is zero; {@code false} otherwise. + */ + public boolean isEmpty() + { + return size == 0; + } + + /** + * Tests whether the specified primitive value exists in the list. + * + * @param e the value to search for. + * @return {@code true} if the value occurs at least once; {@code false} + * otherwise. + */ + public boolean contains(int e) + { + return indexOf(e) >= 0; + } + + /** + * Returns the index of the first occurrence of the given value, or {@code -1} + * if absent. + * + * @param e the value to locate. + * @return zero-based index of the value, or {@code -1} if not found. + */ + public int indexOf(int e) + { + for (int i = 0; i < size; i++) + { + if (e == elementData[i]) + { + return i; + } + } + return -1; + } + + /** + * Retrieves the value at the specified index. + * + * @param index zero-based position of the element to return. + * @return the value stored at {@code index}. + * @throws IndexOutOfBoundsException if {@code index < 0 || index >= size}. + */ + public int get(int index) + { + rangeCheck(index); + return elementData[index]; + } + + /** + * Replaces the value at the specified index. + * + * @param index zero-based position of the element to overwrite. + * @param element the new value. + * @return the previous value stored at {@code index}. + * @throws IndexOutOfBoundsException if {@code index < 0 || index >= size}. + */ + public int set(int index, int element) + { + rangeCheck(index); + int oldValue = elementData[index]; + elementData[index] = element; + return oldValue; + } + + /** + * Appends a value to the end of the list, growing the backing array if + * required. + * + * @param e value to append. + */ + public void add(int e) + { + ensureCapacityInternal(size + 1); + elementData[size++] = e; + } + + /** + * Inserts a value at the specified index, shifting subsequent elements one + * position to the right. + * + * @param index zero-based insertion point (may be equal to {@link #size()} to + * append). + * @param element the value to insert. + * @throws IndexOutOfBoundsException if {@code index < 0 || index > size}. + */ + public void add(int index, int element) + { + rangeCheckForAdd(index); + ensureCapacityInternal(size + 1); + System.arraycopy(elementData, index, elementData, index + 1, size - index); + elementData[index] = element; + size++; + } + + /** + * Removes the value at the specified index and compacts the list. + * + * @param index zero-based index of the element to remove. + * @return the removed value. + * @throws IndexOutOfBoundsException if {@code index < 0 || index >= size}. + */ + public int removeAt(int index) + { + rangeCheck(index); + int oldValue = elementData[index]; + int numMoved = size - index - 1; + if (numMoved > 0) + { + System.arraycopy(elementData, index + 1, elementData, index, numMoved); + } + elementData[--size] = 0; + return oldValue; + } + + /** + * Removes the first occurrence of the specified value, if present. + * + * @param e value to remove. + * @return {@code true} if a value was removed; {@code false} otherwise. + */ + public boolean remove(int e) + { + for (int i = 0; i < size; i++) + { + if (e == elementData[i]) + { + fastRemove(i); + return true; + } + } + return false; + } + + private void fastRemove(int index) + { + int numMoved = size - index - 1; + if (numMoved > 0) + { + System.arraycopy(elementData, index + 1, elementData, index, numMoved); + } + elementData[--size] = 0; + } + + /** + * Removes all elements from the list and resets {@link #size()} to zero. + * The backing array is retained for reuse. + */ + public void clear() + { + for (int i = 0; i < size; i++) + { + elementData[i] = 0; + } + size = 0; + } + + private void rangeCheck(int index) + { + if (index >= size) + { + throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size); + } + } + + private void rangeCheckForAdd(int index) + { + if (index > size || index < 0) + { + throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size); + } + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/ShortestPathConfig.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/ShortestPathConfig.java new file mode 100644 index 00000000000..66ac283a69e --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/ShortestPathConfig.java @@ -0,0 +1,1247 @@ +package shortestpath; + +import java.awt.Color; +import net.runelite.client.config.Alpha; +import net.runelite.client.config.Config; +import net.runelite.client.config.ConfigGroup; +import net.runelite.client.config.ConfigItem; +import net.runelite.client.config.ConfigSection; +import net.runelite.client.config.Keybind; +import net.runelite.client.config.Range; +import net.runelite.client.config.Units; + + + + +@SuppressWarnings("SameReturnValue") +@ConfigGroup(ShortestPathPlugin.CONFIG_GROUP) +public interface ShortestPathConfig extends Config +{ + @ConfigSection( + name = "Settings", + description = "Options for the pathfinding", + position = 0 + ) + String sectionSettings = "sectionSettings"; + + @ConfigItem( + keyName = "avoidWilderness", + name = "Avoid wilderness", + description = "Whether the wilderness should be avoided if possible
" + + "(otherwise, will e.g. use wilderness lever from Edgeville to Ardougne)", + position = 1, + section = sectionSettings + ) + default boolean avoidWilderness() + { + return true; + } + + @ConfigItem( + keyName = "useAgilityShortcuts", + name = "Use agility shortcuts", + description = "Whether to include agility shortcuts in the path.
" + + "You must also have the required agility level", + position = 2, + section = sectionSettings + ) + default boolean useAgilityShortcuts() + { + return true; + } + + @ConfigItem( + keyName = "useGrappleShortcuts", + name = "Use grapple shortcuts", + description = "Whether to include crossbow grapple agility shortcuts in the path.
" + + "You must also have the required agility, ranged and strength levels", + position = 3, + section = sectionSettings + ) + default boolean useGrappleShortcuts() + { + return false; + } + + @ConfigItem( + keyName = "useBoats", + name = "Use boats", + description = "Whether to include small boats in the path
" + + "(e.g. the boat to Fishing Platform)", + position = 4, + section = sectionSettings + ) + default boolean useBoats() + { + return true; + } + + @ConfigItem( + keyName = "useCanoes", + name = "Use canoes", + description = "Whether to include canoes in the path", + position = 5, + section = sectionSettings + ) + default boolean useCanoes() + { + return false; + } + + @ConfigItem( + keyName = "useCharterShips", + name = "Use charter ships", + description = "Whether to include charter ships in the path", + position = 6, + section = sectionSettings + ) + default boolean useCharterShips() + { + return false; + } + + @ConfigItem( + keyName = "useShips", + name = "Use ships", + description = "Whether to include passenger ships in the path
" + + "(e.g. the customs ships to Karamja)", + position = 7, + section = sectionSettings + ) + default boolean useShips() + { + return true; + } + + @ConfigItem( + keyName = "useFairyRings", + name = "Use fairy rings", + description = "Whether to include fairy rings in the path.
" + + "You must also have completed the required quests or miniquests", + position = 8, + section = sectionSettings + ) + default boolean useFairyRings() + { + return true; + } + + @ConfigItem( + keyName = "useGnomeGliders", + name = "Use gnome gliders", + description = "Whether to include gnome gliders in the path", + position = 9, + section = sectionSettings + ) + default boolean useGnomeGliders() + { + return true; + } + + @ConfigItem( + keyName = "useHotAirBalloons", + name = "Use hot air balloons", + description = "Whether to include hot air balloons in the path", + position = 10, + section = sectionSettings + ) + default boolean useHotAirBalloons() + { + return false; + } + + @ConfigItem( + keyName = "useMagicCarpets", + name = "Use magic carpets", + description = "Whether to include magic carpets in the path", + position = 11, + section = sectionSettings + ) + default boolean useMagicCarpets() + { + return true; + } + + @ConfigItem( + keyName = "useMagicMushtrees", + name = "Use magic mushtrees", + description = "Whether to include Fossil Island Magic Mushtrees in the path
" + + "(e.g. the Mycelium transport network from Verdant Valley to Mushroom Meadow)", + position = 12, + section = sectionSettings + ) + default boolean useMagicMushtrees() + { + return true; + } + + @ConfigItem( + keyName = "useMinecarts", + name = "Use minecarts", + description = "Whether to include minecarts in the path
" + + "(e.g. the Keldagrim and Lovakengj minecart networks)", + position = 13, + section = sectionSettings + ) + default boolean useMinecarts() + { + return true; + } + + @ConfigItem( + keyName = "useQuetzals", + name = "Use quetzals", + description = "Whether to include quetzals in the path", + position = 14, + section = sectionSettings + ) + default boolean useQuetzals() + { + return true; + } + + @ConfigItem( + keyName = "useSpiritTrees", + name = "Use spirit trees", + description = "Whether to include spirit trees in the path", + position = 15, + section = sectionSettings + ) + default boolean useSpiritTrees() + { + return true; + } + + @ConfigItem( + keyName = "useTeleportationItems", + name = "Use teleportation items", + description = "Whether to include teleportation items from the player's inventory and equipment.
" + + "Options labelled (perm) only use permanent non-charge items.
" + + "The All options do not check skill, quest or item requirements.", + position = 16, + section = sectionSettings + ) + default TeleportationItem useTeleportationItems() + { + return TeleportationItem.INVENTORY_NON_CONSUMABLE; + } + + @ConfigItem( + keyName = "useTeleportationLevers", + name = "Use teleportation levers", + description = "Whether to include teleportation levers in the path
" + + "(e.g. the lever from Edgeville to Wilderness)", + position = 17, + section = sectionSettings + ) + default boolean useTeleportationLevers() + { + return true; + } + + @ConfigItem( + keyName = "useTeleportationPortals", + name = "Use teleportation portals", + description = "Whether to include teleportation portals in the path
" + + "(e.g. the portal from Ferox Enclave to Castle Wars)", + position = 18, + section = sectionSettings + ) + default boolean useTeleportationPortals() + { + return true; + } + + @ConfigItem( + keyName = "useTeleportationSpells", + name = "Use teleportation spells", + description = "Whether to include teleportation spells in the path", + position = 19, + section = sectionSettings + ) + default boolean useTeleportationSpells() + { + return true; + } + + @ConfigItem( + keyName = "useTeleportationSpellsHome", + name = "Use Home Teleport spells", + description = "Whether to include Home Teleport spells in the path", + position = 20, + section = sectionSettings + ) + default boolean useTeleportationSpellsHome() + { + return true; + } + + @ConfigItem( + keyName = "useTeleportationMinigames", + name = "Use teleportation to minigames", + description = "Whether to include teleportation to minigames/activities/grouping in the path
" + + "(e.g. the Nightmare Zone minigame teleport). These teleports share a 20 minute cooldown.", + position = 21, + section = sectionSettings + ) + default boolean useTeleportationMinigames() + { + return true; + } + + @ConfigItem( + keyName = "useWildernessObelisks", + name = "Use wilderness obelisks", + description = "Whether to include wilderness obelisks in the path", + position = 22, + section = sectionSettings + ) + default boolean useWildernessObelisks() + { + return true; + } + + @ConfigItem( + keyName = "useSeasonalTransports", + name = "Use seasonal transports", + description = "Whether to include seasonal transports like League teleports in the path", + position = 23, + section = sectionSettings + ) + default boolean useSeasonalTransports() + { + return false; + } + + @ConfigItem( + keyName = "includeBankPath", + name = "Include path to bank", + description = "Whether to include the path to the closest bank
" + + "when suggesting teleports from the bank", + position = 24, + section = sectionSettings + ) + default boolean includeBankPath() + { + return false; + } + + @ConfigItem( + keyName = "currencyThreshold", + name = "Currency threshold", + description = "The maximum amount of currency to use on a single transportation method." + + "
The currencies affected by the threshold are coins, trading sticks, ecto-tokens and warrior guild tokens.", + position = 25, + section = sectionSettings + ) + default int currencyThreshold() + { + return 100000; + } + + @ConfigItem( + keyName = "cancelInstead", + name = "Cancel instead of recalculating", + description = "Whether the path should be cancelled rather than recalculated " + + "when the recalculate distance limit is exceeded", + position = 26, + section = sectionSettings + ) + default boolean cancelInstead() + { + return false; + } + + @Range( + min = -1, + max = 20000 + ) + @ConfigItem( + keyName = "recalculateDistance", + name = "Recalculate distance", + description = "Distance from the path the player should be for it to be recalculated (-1 for never)", + position = 27, + section = sectionSettings + ) + default int recalculateDistance() + { + return 10; + } + + @Range( + min = -1, + max = 50 + ) + @ConfigItem( + keyName = "finishDistance", + name = "Finish distance", + description = "Distance from the target tile at which the path should be ended (-1 for never)", + position = 28, + section = sectionSettings + ) + default int reachedDistance() + { + return 5; + } + + @Range( + max = 20000 + ) + @ConfigItem( + keyName = "unreachableTargetDistanceThreshold", + name = "Unreachable target distance", + description = "Distance from the target at which a finished path is considered not to reach the target." + + "
Useful for determining if a path is potentially invalid.", + position = 29, + section = sectionSettings + ) + default int unreachableTargetDistance() + { + return 2; + } + + @ConfigItem( + keyName = "showTileCounter", + name = "Show tile counter", + description = "Whether to display the number of tiles travelled, number of tiles remaining or disable counting", + position = 30, + section = sectionSettings + ) + default TileCounter showTileCounter() + { + return TileCounter.DISABLED; + } + + @ConfigItem( + keyName = "tileCounterStep", + name = "Tile counter step", + description = "The number of tiles between the displayed tile counter numbers", + position = 31, + section = sectionSettings + ) + default int tileCounterStep() + { + return 1; + } + + @Units( + value = Units.TICKS + ) + @Range( + min = 1, + max = 30 + ) + @ConfigItem( + keyName = "calculationCutoff", + name = "Calculation cutoff", + description = "The cutoff threshold in number of ticks (0.6 seconds) of no progress being
" + + "made towards the path target before the calculation will be stopped", + position = 32, + section = sectionSettings + ) + default int calculationCutoff() + { + return 5; + } + + @ConfigItem( + keyName = "showTransportInfo", + name = "Show transport info", + description = "Whether to display transport destination hint info, e.g. which chat option and text to click", + position = 33, + section = sectionSettings + ) + default boolean showTransportInfo() + { + return true; + } + + @ConfigItem( + keyName = "showBankPickupInfo", + name = "Show transport hint at pickup", + description = "When standing at a bank on the path, also show the transport hint for the next step requiring an item pickup", + position = 34, + section = sectionSettings + ) + default boolean showBankPickupInfo() + { + return false; + } + + @ConfigSection( + name = "Player-Owned House", + description = "Options for POH (Player-Owned House) teleports", + position = 35, + closedByDefault = true + ) + String sectionPoh = "sectionPoh"; + + @ConfigItem( + keyName = "usePoh", + name = "Enable POH teleports", + description = "Master toggle for all Player-Owned House (POH) teleports.
" + + "When disabled, all POH transports are excluded regardless of individual settings below.", + position = 36, + section = sectionPoh + ) + default boolean usePoh() + { + return false; + } + + @ConfigItem( + keyName = "usePohFairyRing", + name = "POH fairy ring", + description = "Whether to include the POH fairy ring in the path.
" + + "Enable this if you have built a fairy ring in your house (85 Construction or boosted)", + position = 37, + section = sectionPoh + ) + default boolean usePohFairyRing() + { + return false; + } + + @ConfigItem( + keyName = "usePohSpiritTree", + name = "POH spirit tree", + description = "Whether to include the POH spirit tree in the path.
" + + "Enable this if you have built a spirit tree in your house (75 Construction, 83 Farming or boosted)", + position = 38, + section = sectionPoh + ) + default boolean usePohSpiritTree() + { + return false; + } + + @ConfigItem( + keyName = "useTeleportationPortalsPoh", + name = "POH portal nexus", + description = "Whether to include POH teleportation portals/nexus in the path", + position = 39, + section = sectionPoh + ) + default boolean useTeleportationPortalsPoh() + { + return false; + } + + @ConfigItem( + keyName = "pohJewelleryBoxTier", + name = "POH jewellery box tier", + description = "The tier of jewellery box built in your POH
" + + "(Basic: 1-9, Fancy: A-J, Ornate: K-R). Set to None to disable jewellery box.", + position = 40, + section = sectionPoh + ) + default JewelleryBoxTier pohJewelleryBoxTier() + { + return JewelleryBoxTier.ORNATE; + } + + @ConfigItem( + keyName = "usePohMountedItems", + name = "POH mounted items", + description = "Whether to include POH mounted items in the path
" + + "(e.g. mounted glory, Xeric's talisman, digsite pendant, mythical cape)", + position = 41, + section = sectionPoh + ) + default boolean usePohMountedItems() + { + return true; + } + + @ConfigItem( + keyName = "usePohObelisk", + name = "POH wilderness obelisk", + description = "Whether to include the POH wilderness obelisk in the path.
" + + "Enable this if you have built an obelisk in your house (80 Construction or boosted)", + position = 42, + section = sectionPoh + ) + default boolean usePohObelisk() + { + return false; + } + + @ConfigSection( + name = "Transport Thresholds", + description = "Set customizable thresholds for how much faster a transportation
" + + "method must be to be preferred over other methods", + position = 43, + closedByDefault = true + ) + String sectionThresholds = "sectionThresholds"; + + @Range( + max = 10000 + ) + @ConfigItem( + keyName = "costAgilityShortcuts", + name = "Agility shortcut threshold", + description = "How many extra tiles an agility shortcut must save
" + + "to be preferred over walking or other transports", + position = 44, + section = sectionThresholds + ) + default int costAgilityShortcuts() + { + return 0; + } + + @Range( + max = 10000 + ) + @ConfigItem( + keyName = "costGrappleShortcuts", + name = "Grapple shortcut threshold", + description = "How many extra tiles a grapple shortcut must save
" + + "to be preferred over walking or other transports", + position = 45, + section = sectionThresholds + ) + default int costGrappleShortcuts() + { + return 0; + } + + @Range( + max = 10000 + ) + @ConfigItem( + keyName = "costBoats", + name = "Boat threshold", + description = "How many extra tiles a small boat must save
" + + "to be preferred over walking or other transports", + position = 46, + section = sectionThresholds + ) + default int costBoats() + { + return 0; + } + + @Range( + max = 10000 + ) + @ConfigItem( + keyName = "costCanoes", + name = "Canoe threshold", + description = "How many extra tiles a canoe must save
" + + "to be preferred over walking or other transports", + position = 47, + section = sectionThresholds + ) + default int costCanoes() + { + return 0; + } + + @Range( + max = 10000 + ) + @ConfigItem( + keyName = "costCharterShips", + name = "Charter ship threshold", + description = "How many extra tiles a charter ship must save
" + + "to be preferred over walking or other transports", + position = 48, + section = sectionThresholds + ) + default int costCharterShips() + { + return 0; + } + + @Range( + max = 10000 + ) + @ConfigItem( + keyName = "costShips", + name = "Ship threshold", + description = "How many extra tiles a passenger ship must save
" + + "to be preferred over walking or other transports", + position = 49, + section = sectionThresholds + ) + default int costShips() + { + return 0; + } + + @Range( + max = 10000 + ) + @ConfigItem( + keyName = "costFairyRings", + name = "Fairy ring threshold", + description = "How many extra tiles a fairy ring must save
" + + "to be preferred over walking or other transports", + position = 50, + section = sectionThresholds + ) + default int costFairyRings() + { + return 0; + } + + @Range( + max = 10000 + ) + @ConfigItem( + keyName = "costGnomeGliders", + name = "Gnome glider threshold", + description = "How many extra tiles a gnome glider must save
" + + "to be preferred over walking or other transports", + position = 51, + section = sectionThresholds + ) + default int costGnomeGliders() + { + return 0; + } + + @Range( + max = 10000 + ) + @ConfigItem( + keyName = "costHotAirBalloons", + name = "Hot air balloon threshold", + description = "How many extra tiles a hot air balloon must save
" + + "to be preferred over walking or other transports", + position = 52, + section = sectionThresholds + ) + default int costHotAirBalloons() + { + return 0; + } + + @Range( + max = 10000 + ) + @ConfigItem( + keyName = "costMagicCarpets", + name = "Magic carpets threshold", + description = "How many extra tiles a magic carpet must save
" + + "to be preferred over walking or other transports", + position = 53, + section = sectionThresholds + ) + default int costMagicCarpets() + { + return 0; + } + + @Range( + max = 10000 + ) + @ConfigItem( + keyName = "costMagicMushtrees", + name = "Magic mushtrees threshold", + description = "How many extra tiles a magic mushtree must save
" + + "to be preferred over walking or other transports", + position = 54, + section = sectionThresholds + ) + default int costMagicMushtrees() + { + return 0; + } + + @Range( + max = 10000 + ) + @ConfigItem( + keyName = "costMinecarts", + name = "Minecart threshold", + description = "How many extra tiles a minecart must save
" + + "to be preferred over walking or other transports", + position = 55, + section = sectionThresholds + ) + default int costMinecarts() + { + return 0; + } + + @Range( + max = 10000 + ) + @ConfigItem( + keyName = "costQuetzals", + name = "Quetzal threshold", + description = "How many extra tiles a quetzal must save
" + + "to be preferred over walking or other transports", + position = 56, + section = sectionThresholds + ) + default int costQuetzals() + { + return 0; + } + + @Range( + max = 10000 + ) + @ConfigItem( + keyName = "costQuetzalWhistle", + name = "Quetzal whistle threshold", + description = "How many extra tiles a quetzal whistle teleport must save
" + + "to be preferred over using a landing site", + position = 57, + section = sectionThresholds + ) + default int costQuetzalWhistle() + { + return 15; + } + + @Range( + max = 10000 + ) + @ConfigItem( + keyName = "costSpiritTrees", + name = "Spirit tree threshold", + description = "How many extra tiles a spirit tree must save
" + + "to be preferred over walking or other transports", + position = 58, + section = sectionThresholds + ) + default int costSpiritTrees() + { + return 0; + } + + @Range( + max = 10000 + ) + @ConfigItem( + keyName = "costNonConsumableTeleportationItems", + name = "Teleportation item (non-consumable) threshold", + description = "How many extra tiles a non-consumable (permanent) teleportation item
" + + "must save to be preferred over walking or other transports", + position = 59, + section = sectionThresholds + ) + default int costNonConsumableTeleportationItems() + { + return 0; + } + + @Range( + max = 10000 + ) + @ConfigItem( + keyName = "costConsumableTeleportationItems", + name = "Teleportation item (consumable) threshold", + description = "How many extra tiles a consumable (non-permanent) teleportation item
" + + "must save to be preferred over walking or other transports", + position = 60, + section = sectionThresholds + ) + default int costConsumableTeleportationItems() + { + return 0; + } + + @Range( + max = 10000 + ) + @ConfigItem( + keyName = "costTeleportationBoxes", + name = "Teleportation box threshold", + description = "How many extra tiles a teleportation box must save
" + + "to be preferred over walking or other transports", + position = 61, + section = sectionThresholds + ) + default int costTeleportationBoxes() + { + return 0; + } + + @Range( + max = 10000 + ) + @ConfigItem( + keyName = "costTeleportationLevers", + name = "Teleportation lever threshold", + description = "How many extra tiles a teleportation lever must save
" + + "to be preferred over walking or other transports", + position = 62, + section = sectionThresholds + ) + default int costTeleportationLevers() + { + return 0; + } + + @Range( + max = 10000 + ) + @ConfigItem( + keyName = "costTeleportationPortals", + name = "Teleportation portal threshold", + description = "How many extra tiles a teleportation portal must save
" + + "to be preferred over walking or other transports", + position = 63, + section = sectionThresholds + ) + default int costTeleportationPortals() + { + return 0; + } + + @Range( + max = 10000 + ) + @ConfigItem( + keyName = "costTeleportationSpells", + name = "Teleportation spell threshold", + description = "How many extra tiles a teleportation spell must save
" + + "to be preferred over walking or other transports", + position = 64, + section = sectionThresholds + ) + default int costTeleportationSpells() + { + return 0; + } + + @Range( + max = 10000 + ) + @ConfigItem( + keyName = "costTeleportationSpellsHome", + name = "Home Teleport spell threshold", + description = "How many extra tiles a Home Teleport spell must save
" + + "to be preferred over walking or other transports", + position = 65, + section = sectionThresholds + ) + default int costTeleportationSpellsHome() + { + return 0; + } + + @Range( + max = 10000 + ) + @ConfigItem( + keyName = "costTeleportationMinigames", + name = "Teleportation to minigame threshold", + description = "How many extra tiles a minigame teleport must save
" + + "to be preferred over walking or other transports", + position = 66, + section = sectionThresholds + ) + default int costTeleportationMinigames() + { + return 0; + } + + @Range( + max = 10000 + ) + @ConfigItem( + keyName = "costWildernessObelisks", + name = "Wilderness obelisk threshold", + description = "How many extra tiles a wilderness obelisk must save
" + + "to be preferred over walking or other transports", + position = 67, + section = sectionThresholds + ) + default int costWildernessObelisks() + { + return 0; + } + + @Range( + max = 10000 + ) + @ConfigItem( + keyName = "costSeasonalTransports", + name = "Seasonal transport threshold", + description = "How many extra tiles a seasonal transport must save
" + + "to be preferred over walking or other transports", + position = 68, + section = sectionThresholds + ) + default int costSeasonalTransports() + { + return 0; + } + + @ConfigSection( + name = "Display", + description = "Options for displaying the path on the world map, minimap and scene tiles", + position = 69 + ) + String sectionDisplay = "sectionDisplay"; + + @ConfigItem( + keyName = "drawMap", + name = "Draw path on world map", + description = "Whether the path should be drawn on the world map", + position = 70, + section = sectionDisplay + ) + default boolean drawMap() + { + return true; + } + + @ConfigItem( + keyName = "drawMinimap", + name = "Draw path on minimap", + description = "Whether the path should be drawn on the minimap", + position = 71, + section = sectionDisplay + ) + default boolean drawMinimap() + { + return true; + } + + @ConfigItem( + keyName = "drawTiles", + name = "Draw path on tiles", + description = "Whether the path should be drawn on the game tiles", + position = 72, + section = sectionDisplay + ) + default boolean drawTiles() + { + return true; + } + + @ConfigItem( + keyName = "pathStyle", + name = "Path style", + description = "Whether to display the path as tiles or a segmented line", + position = 73, + section = sectionDisplay + ) + default TileStyle pathStyle() + { + return TileStyle.TILES; + } + + @ConfigSection( + name = "Colours", + description = "Colours for the path map, minimap and scene tiles", + position = 74 + ) + String sectionColours = "sectionColours"; + + @Alpha + @ConfigItem( + keyName = "colourPath", + name = "Path", + description = "Colour of the path tiles on the world map, minimap and in the game scene", + position = 75, + section = sectionColours + ) + default Color colourPath() + { + return new Color(255, 0, 0); + } + + @Alpha + @ConfigItem( + keyName = "colourPathCalculating", + name = "Calculating", + description = "Colour of the path tiles while the pathfinding calculation is in progress," + + "
and the colour of unused targets if there are more than a single target", + position = 76, + section = sectionColours + ) + default Color colourPathCalculating() + { + return new Color(0, 0, 255); + } + + @Alpha + @ConfigItem( + keyName = "colourPathUnreachable", + name = "Unreachable", + description = "Colour of the path tiles when pathfinding has finished but the target is still too far away", + position = 77, + section = sectionColours + ) + default Color colourPathUnreachable() + { + return new Color(200, 40, 240); + } + + @Alpha + @ConfigItem( + keyName = "colourTransports", + name = "Transports", + description = "Colour of the transport tiles", + position = 78, + section = sectionColours + ) + default Color colourTransports() + { + return new Color(0, 255, 0, 128); + } + + @Alpha + @ConfigItem( + keyName = "colourCollisionMap", + name = "Collision map", + description = "Colour of the collision map tiles", + position = 79, + section = sectionColours + ) + default Color colourCollisionMap() + { + return new Color(0, 128, 255, 128); + } + + @Alpha + @ConfigItem( + keyName = "colourText", + name = "Text", + description = "Colour of the text of the tile counter and fairy ring codes", + position = 80, + section = sectionColours + ) + default Color colourText() + { + return Color.WHITE; + } + + @ConfigSection( + name = "Hotkeys", + description = "Options for keyboard shortcuts", + position = 81 + ) + String sectionHotkeys = "sectionHotkeys"; + + @ConfigItem( + keyName = "clearPathHotkey", + name = "Clear current path", + description = "Hotkey to clear the current path", + position = 82, + section = sectionHotkeys + ) + default Keybind clearPathHotkey() + { + return Keybind.NOT_SET; + } + + @ConfigSection( + name = "Debug Options", + description = "Various options for debugging", + position = 83, + closedByDefault = true + ) + String sectionDebug = "sectionDebug"; + + @ConfigItem( + keyName = "drawTransports", + name = "Draw transports", + description = "Whether transports should be drawn", + position = 84, + section = sectionDebug + ) + default boolean drawTransports() + { + return false; + } + + @ConfigItem( + keyName = "drawCollisionMap", + name = "Draw collision map", + description = "Whether the collision map should be drawn", + position = 85, + section = sectionDebug + ) + default boolean drawCollisionMap() + { + return false; + } + + @ConfigItem( + keyName = "drawDebugPanel", + name = "Show debug panel", + description = "Toggles displaying the pathfinding debug stats panel", + position = 86, + section = sectionDebug + ) + default boolean drawDebugPanel() + { + return false; + } + + @ConfigItem( + keyName = "postTransports", + name = "Post transports", + description = "Whether to post the transports used in the current path as a PluginMessage event", + position = 87, + section = sectionDebug + ) + default boolean postTransports() + { + return false; + } + + @ConfigItem( + keyName = "unreachableText", + name = "", + description = "Text shown on the player tile when the destination cannot be reached", + hidden = true + ) + default String unreachableText() + { + return "Destination could not be reached"; + } + + @ConfigItem( + keyName = "builtTeleportationBoxes", + name = "", + description = "ID=X Y Z;ID=X Y Z;ID=X Y Z", + hidden = true + ) + @SuppressWarnings("unused") + default String builtTeleportationBoxes() + { + return ""; + } + + @ConfigItem( + keyName = "builtTeleportationBoxes", + name = "", + description = "", + hidden = true + ) + @SuppressWarnings("unused") + void setBuiltTeleportationBoxes(String content); + + @ConfigItem( + keyName = "builtTeleportationPortalsPoh", + name = "", + description = "ID=X Y Z;ID=X Y Z;ID=X Y Z", + hidden = true + ) + @SuppressWarnings("unused") + default String builtTeleportationPortalsPoh() + { + return ""; + } + + @ConfigItem( + keyName = "builtTeleportationPortalsPoh", + name = "", + description = "", + hidden = true + ) + @SuppressWarnings("unused") + void setBuiltTeleportationPortalsPoh(String content); + +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/ShortestPathPlugin.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/ShortestPathPlugin.java new file mode 100644 index 00000000000..ad968022758 --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/ShortestPathPlugin.java @@ -0,0 +1,57 @@ +package shortestpath; + +import shortestpath.transport.TransportType; + +/** + * Resource/config compatibility anchor for the vendored planner core. + * + *

This is intentionally not a RuneLite plugin. Microbot owns the only plugin lifecycle and projects + * resolved state into the upstream engine through its adapter.

+ */ +public final class ShortestPathPlugin +{ + public static final String CONFIG_GROUP = "shortestpath"; + private static final int POH_MIN_X = 1856; + private static final int POH_MAX_X = 2047; + private static final int POH_MIN_Y = 5696; + private static final int POH_MAX_Y = 5767; + + private ShortestPathPlugin() + { + } + + public static boolean isInsidePoh(int x, int y) + { + return x >= POH_MIN_X && x <= POH_MAX_X && y >= POH_MIN_Y && y <= POH_MAX_Y; + } + + public static boolean override(String key, boolean value) + { + return value; + } + + public static int override(String key, int value) + { + return value; + } + + public static boolean override(TransportType type, boolean value) + { + return value; + } + + public static int override(TransportType type, int value) + { + return value; + } + + public static TeleportationItem override(String key, TeleportationItem value) + { + return value; + } + + public static JewelleryBoxTier override(String key, JewelleryBoxTier value) + { + return value; + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/TeleportationItem.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/TeleportationItem.java new file mode 100644 index 00000000000..6a78763f0b3 --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/TeleportationItem.java @@ -0,0 +1,40 @@ +package shortestpath; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TeleportationItem +{ + NONE("None"), + INVENTORY("Inventory"), + INVENTORY_NON_CONSUMABLE("Inventory (perm)"), + INVENTORY_AND_BANK("Inventory and Bank"), + INVENTORY_AND_BANK_NON_CONSUMABLE("Inventory and Bank (perm)"), + UNLOCKED("Unlocked"), + UNLOCKED_NON_CONSUMABLE("Unlocked (perm)"), + ALL("All"), + ALL_NON_CONSUMABLE("All (perm)"), + ; + + private final String type; + + public static TeleportationItem fromType(String type) + { + for (TeleportationItem teleportationItem : values()) + { + if (teleportationItem.type.equals(type)) + { + return teleportationItem; + } + } + return null; + } + + @Override + public String toString() + { + return type; + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/TileCounter.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/TileCounter.java new file mode 100644 index 00000000000..8d70727f58f --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/TileCounter.java @@ -0,0 +1,25 @@ +package shortestpath; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum TileCounter +{ + DISABLED("Disabled"), + TRAVELLED("Travelled"), + REMAINING("Remaining"); + + private final String type; + + public static TileCounter fromType(String type) + { + for (TileCounter tileCounter : values()) + { + if (tileCounter.type.equals(type)) + { + return tileCounter; + } + } + return null; + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/TileStyle.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/TileStyle.java new file mode 100644 index 00000000000..bb72ebdeda1 --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/TileStyle.java @@ -0,0 +1,24 @@ +package shortestpath; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum TileStyle +{ + TILES("Tiles"), + LINES("Lines"); + + private final String type; + + public static TileStyle fromType(String type) + { + for (TileStyle tileStyle : values()) + { + if (tileStyle.type.equals(type)) + { + return tileStyle; + } + } + return null; + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/Util.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/Util.java new file mode 100644 index 00000000000..515c2a05547 --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/Util.java @@ -0,0 +1,78 @@ +package shortestpath; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * General utility helpers for I/O and primitive array manipulation used by the + * shortest path plugin. + */ +public class Util +{ + /** + * Reads all bytes from the provided {@link InputStream} until EOF. + * This method does not close the stream; the caller retains responsibility for + * resource management. + * + * @param in the input stream to read from. + * @return a newly allocated byte array containing all bytes read (may be empty, + * never {@code null}). + * @throws IOException if an I/O error occurs while reading. + */ + public static byte[] readAllBytes(InputStream in) throws IOException + { + ByteArrayOutputStream result = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + + while (true) + { + int read = in.read(buffer, 0, buffer.length); + + if (read == -1) + { + return result.toByteArray(); + } + + result.write(buffer, 0, read); + } + } + + /** + * Concatenates the contents of multiple {@code int[]} arrays into one + * contiguous array. + * {@code null} elements in {@code arrays} are skipped. If all arrays are + * {@code null} or empty, {@code null} + * is returned (this mirrors the existing behavior relied upon by callers). + * + * @param arrays an array of {@code int[]} segments to concatenate (may contain + * {@code null}). + * @return a new combined array, or {@code null} if there are no elements to + * copy. + */ + public static int[] concatenate(int[][] arrays) + { + int n = 0; + for (int[] value : arrays) + { + n += (value == null) ? 0 : value.length; + } + if (n == 0) + { + return null; + } + int[] array = new int[n]; + int k = 0; + for (int[] ints : arrays) + { + if (ints != null) + { + for (int anInt : ints) + { + array[k++] = anInt; + } + } + } + return array; + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/WorldPointUtil.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/WorldPointUtil.java new file mode 100644 index 00000000000..4c006a7067b --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/WorldPointUtil.java @@ -0,0 +1,500 @@ +package shortestpath; + +import net.runelite.api.Client; +import static net.runelite.api.Constants.CHUNK_SIZE; +import static net.runelite.api.Perspective.LOCAL_COORD_BITS; +import net.runelite.api.Player; +import net.runelite.api.WorldEntity; +import net.runelite.api.WorldView; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldArea; +import net.runelite.api.coords.WorldPoint; + +/** + * Utility functions for packing, unpacking, and transforming {@link WorldPoint} + * coordinates as compact {@code int} values. + *

+ * A packed world point encodes {@code x}, {@code y}, and {@code plane} into a + * single 32-bit integer using: + * + *

+ * bits 0..14   : x (15 bits)
+ * bits 15..29  : y (15 bits)
+ * bits 30..31  : plane (2 bits)
+ * 
+ *

+ * This representation allows efficient storage and hashing of coordinates + * within the pathfinding data structures. + */ +public class WorldPointUtil +{ + public static final int CHEBYSHEV_DISTANCE_METRIC = 1; + public static final int EUCLIDEAN_SQUARED_DISTANCE_METRIC = 1414213562; + public static final int MANHATTAN_DISTANCE_METRIC = 2; + public static final int UNDEFINED = -1; + + /** + * Packs a {@link WorldPoint} into a compact {@code int} encoding. + * + * @param point world point (may be {@code null}). + * @return packed integer value, or {@link #UNDEFINED} if {@code point} is + * {@code null}. + */ + public static int packWorldPoint(WorldPoint point) + { + if (point == null) + { + return -1; + } + return packWorldPoint(point.getX(), point.getY(), point.getPlane()); + } + + /** + * Packs the provided coordinate triple into a single {@code int}. + * First 15 bits are {@code x}, next 15 bits are {@code y}, final 2 bits are the + * plane. + * Values are masked into range; overflow bits are discarded. + * + * @param x world x (0..32767 effectively supported). + * @param y world y (0..32767 effectively supported). + * @param plane plane (0..3). + * @return packed integer representation. + */ + public static int packWorldPoint(int x, int y, int plane) + { + return (x & 0x7FFF) | ((y & 0x7FFF) << 15) | ((plane & 0x3) << 30); + } + + /** + * Unpacks a packed world point into a new {@link WorldPoint} instance. + * + * @param packedPoint packed coordinate. + * @return decoded {@link WorldPoint}. + */ + public static WorldPoint unpackWorldPoint(int packedPoint) + { + final int x = unpackWorldX(packedPoint); + final int y = unpackWorldY(packedPoint); + final int plane = unpackWorldPlane(packedPoint); + return new WorldPoint(x, y, plane); + } + + /** + * Extracts the x component from a packed world point. + * + * @param packedPoint packed coordinate. + * @return x value. + */ + public static int unpackWorldX(int packedPoint) + { + return packedPoint & 0x7FFF; + } + + /** + * Extracts the y component from a packed world point. + * + * @param packedPoint packed coordinate. + * @return y value. + */ + public static int unpackWorldY(int packedPoint) + { + return (packedPoint >> 15) & 0x7FFF; + } + + /** + * Extracts the plane component from a packed world point. + * + * @param packedPoint packed coordinate. + * @return plane value (0..3). + */ + public static int unpackWorldPlane(int packedPoint) + { + return (packedPoint >> 30) & 0x3; + } + + /** + * Offsets a packed world point by {@code (dx, dy)} on the same plane. + * + * @param packedPoint base packed point. + * @param dx delta x to add. + * @param dy delta y to add. + * @return packed point after applying deltas. + */ + public static int dxdy(int packedPoint, int dx, int dy) + { + int x = unpackWorldX(packedPoint); + int y = unpackWorldY(packedPoint); + int z = unpackWorldPlane(packedPoint); + return packWorldPoint(x + dx, y + dy, z); + } + + /** + * Computes the distance between two packed points using Chebyshev metric + * (diagonal = 1). + */ + public static int distanceBetween(int previousPacked, int currentPacked) + { + return distanceBetween(previousPacked, currentPacked, 1); + } + + /** + * Computes the 2D distance (ignoring plane) between two packed points using + * Chebyshev metric (diagonal = 1). + */ + public static int distanceBetween2D(int previousPacked, int currentPacked) + { + return distanceBetween2D(previousPacked, currentPacked, 1); + } + + /** + * Computes distance between two packed points with selectable distance metric. + * + * @param previousPacked first packed point. + * @param currentPacked second packed point. + * @param diagonal {@code 1} for Chebyshev (max), {@code 2} for Manhattan + * (sum). Otherwise returns Euclidean squared distance. + * @return distance or {@code Integer.MAX_VALUE} if plane differs. + */ + public static int distanceBetween(int previousPacked, int currentPacked, int diagonal) + { + final int previousX = WorldPointUtil.unpackWorldX(previousPacked); + final int previousY = WorldPointUtil.unpackWorldY(previousPacked); + final int previousZ = WorldPointUtil.unpackWorldPlane(previousPacked); + final int currentX = WorldPointUtil.unpackWorldX(currentPacked); + final int currentY = WorldPointUtil.unpackWorldY(currentPacked); + final int currentZ = WorldPointUtil.unpackWorldPlane(currentPacked); + return distanceBetween(previousX, previousY, previousZ, + currentX, currentY, currentZ, diagonal); + } + + /** + * Computes 2D distance (ignoring plane) between two packed points with + * selectable metric. + * + * @see #distanceBetween(int, int, int, int, int, int, int) + */ + public static int distanceBetween2D(int previousPacked, int currentPacked, int diagonal) + { + final int previousX = WorldPointUtil.unpackWorldX(previousPacked); + final int previousY = WorldPointUtil.unpackWorldY(previousPacked); + final int currentX = WorldPointUtil.unpackWorldX(currentPacked); + final int currentY = WorldPointUtil.unpackWorldY(currentPacked); + return distanceBetween2D(previousX, previousY, currentX, currentY, diagonal); + } + + /** + * Computes distance between two coordinates with selectable metric; returns + * {@code Integer.MAX_VALUE} if planes differ. + * + * @param previousX x of first point. + * @param previousY y of first point. + * @param previousZ plane of first point. + * @param currentX x of second point. + * @param currentY y of second point. + * @param currentZ plane of second point. + * @param diagonal metric selector ({@code 1}=Chebyshev, {@code 2}=Manhattan, + * otherwise Euclidean squared distance). + * @return distance or {@code Integer.MAX_VALUE} if planes differ. + */ + public static int distanceBetween( + int previousX, + int previousY, + int previousZ, + int currentX, + int currentY, + int currentZ, + int diagonal) + { + final int dz = previousZ - currentZ; + + if (dz != 0) + { + return Integer.MAX_VALUE; + } + + return distanceBetween2D(previousX, previousY, currentX, currentY, diagonal); + } + + /** + * Computes a 2D distance using either Chebyshev, Manhattan or Euclidean squared + * distance metric. + * + * @param previousX x of first point. + * @param previousY y of first point. + * @param currentX x of second point. + * @param currentY y of second point. + * @param diagonal metric selector ({@code 1}=Chebyshev, {@code 2}=Manhattan, + * otherwise Euclidean squared distance). + * @return distance. + */ + public static int distanceBetween2D(int previousX, int previousY, + int currentX, int currentY, int diagonal) + { + final int dx = previousX - currentX; + final int dy = previousY - currentY; + + if (diagonal == CHEBYSHEV_DISTANCE_METRIC) + { + return Math.max(Math.abs(dx), Math.abs(dy)); + } + else if (diagonal == MANHATTAN_DISTANCE_METRIC) + { + return Math.abs(dx) + Math.abs(dy); + } + + return dx * dx + dy * dy; + } + + /** + * Convenience overload using Chebyshev distance between two + * {@link WorldPoint}s. + */ + public static int distanceBetween(WorldPoint previous, WorldPoint current) + { + return distanceBetween(previous, current, 1); + } + + /** + * Distance between two {@link WorldPoint}s with selectable metric. + * + * @see #distanceBetween(int, int, int, int, int, int, int) + */ + public static int distanceBetween(WorldPoint previous, WorldPoint current, int diagonal) + { + return distanceBetween(previous.getX(), previous.getY(), previous.getPlane(), + current.getX(), current.getY(), current.getPlane(), diagonal); + } + + /** + * Distance from a packed point to a {@link WorldArea} using Chebyshev metric, + * respecting plane. + * Returns {@code Integer.MAX_VALUE} if the plane differs. + */ + public static int distanceToArea(int packedPoint, WorldArea area) + { + final int plane = unpackWorldPlane(packedPoint); + if (area.getPlane() != plane) + { + return Integer.MAX_VALUE; + } + return distanceToArea2D(packedPoint, area); + } + + /** + * 2D distance (Chebyshev) from a packed point to a {@link WorldArea} ignoring + * plane, equivalent to + * {@link WorldArea#distanceTo(WorldPoint)} semantics in 2D. + */ + public static int distanceToArea2D(int packedPoint, WorldArea area) + { + final int y = unpackWorldY(packedPoint); + final int x = unpackWorldX(packedPoint); + final int areaMaxX = area.getX() + area.getWidth() - 1; + final int areaMaxY = area.getY() + area.getHeight() - 1; + final int dx = Math.max(Math.max(area.getX() - x, 0), x - areaMaxX); + final int dy = Math.max(Math.max(area.getY() - y, 0), y - areaMaxY); + + return Math.max(dx, dy); + } + + private static int rotate(int originalX, int originalY, int z, int rotation) + { + int chunkX = originalX & -CHUNK_SIZE; + int chunkY = originalY & -CHUNK_SIZE; + int x = originalX & (CHUNK_SIZE - 1); + int y = originalY & (CHUNK_SIZE - 1); + switch (rotation) + { + case 1: + return packWorldPoint(chunkX + y, chunkY + (CHUNK_SIZE - 1 - x), z); + case 2: + return packWorldPoint(chunkX + (CHUNK_SIZE - 1 - x), chunkY + (CHUNK_SIZE - 1 - y), z); + case 3: + return packWorldPoint(chunkX + (CHUNK_SIZE - 1 - y), chunkY + x, z); + } + return packWorldPoint(originalX, originalY, z); + } + + private static int unpackChunkRotation(int chunkData) + { + return chunkData >> 1 & 0x3; + } + + private static int unpackChunkTemplateY(int chunkData) + { + return (chunkData >> 3 & 0x7FF) * CHUNK_SIZE; + } + + private static int unpackChunkTemplateX(int chunkData) + { + return (chunkData >> 14 & 0x3FF) * CHUNK_SIZE; + } + + private static int unpackChunkTemplatePlane(int chunkData) + { + return chunkData >> 24 & 0x3; + } + + public static int fromLocalInstance(Client client, Player localPlayer) + { + WorldView worldView = localPlayer.getWorldView(); + int worldViewId = worldView.getId(); + boolean isOnBoat = worldViewId != WorldView.TOPLEVEL; + if (isOnBoat) + { + WorldEntity worldEntity = client.getTopLevelWorldView().worldEntities().byIndex(worldViewId); + return fromLocalInstance(client, worldEntity.getLocalLocation()); + } + return fromLocalInstance(client, localPlayer.getLocalLocation()); + } + + /** + * Converts an instanced {@link LocalPoint} to its corresponding packed world + * point coordinate, resolving the + * underlying template chunk mapping and rotation. + * + * @param client RuneLite client. + * @param localPoint local scene coordinate. + * @return packed world point. + */ + public static int fromLocalInstance(Client client, LocalPoint localPoint) + { + WorldView worldView = client.getWorldView(localPoint.getWorldView()); + int plane = worldView.getPlane(); + + if (!worldView.isInstance()) + { + return packWorldPoint( + (localPoint.getX() >> LOCAL_COORD_BITS) + worldView.getBaseX(), + (localPoint.getY() >> LOCAL_COORD_BITS) + worldView.getBaseY(), + plane); + } + + int[][][] instanceTemplateChunks = worldView.getInstanceTemplateChunks(); + + // get position in the scene + int sceneX = localPoint.getSceneX(); + int sceneY = localPoint.getSceneY(); + + // get chunk from scene + int chunkX = sceneX / CHUNK_SIZE; + int chunkY = sceneY / CHUNK_SIZE; + + // get the template chunk for the chunk + int templateChunk = instanceTemplateChunks[plane][chunkX][chunkY]; + + int rotation = unpackChunkRotation(templateChunk); + int templateChunkY = unpackChunkTemplateY(templateChunk); + int templateChunkX = unpackChunkTemplateX(templateChunk); + int templateChunkPlane = unpackChunkTemplatePlane(templateChunk); + + // calculate world point of the template + int x = templateChunkX + (sceneX & (CHUNK_SIZE - 1)); + int y = templateChunkY + (sceneY & (CHUNK_SIZE - 1)); + + // create and rotate point back to 0, to match with template + return rotate(x, y, templateChunkPlane, 4 - rotation); + } + + /** + * Converts a packed world point to one or more packed world points representing + * its locations within an instanced + * map (e.g., dungeons or raids). If the current top-level world is not + * instanced, the result contains exactly the + * original point. + * + * @param client RuneLite client. + * @param packedPoint packed world coordinate. + * @return list of packed coordinates valid in the current instance. + */ + public static PrimitiveIntList toLocalInstance(Client client, int packedPoint) + { + WorldView worldView = client.getTopLevelWorldView(); + + PrimitiveIntList worldPoints = new PrimitiveIntList(); + if (!worldView.isInstance()) + { + worldPoints.add(packedPoint); + return worldPoints; + } + + int baseX = worldView.getBaseX(); + int baseY = worldView.getBaseY(); + int worldPointX = unpackWorldX(packedPoint); + int worldPointY = unpackWorldY(packedPoint); + int worldPointPlane = unpackWorldPlane(packedPoint); + + int[][][] instanceTemplateChunks = worldView.getInstanceTemplateChunks(); + + // find instance chunks using the template point. there might be more than one. + for (int z = 0; z < instanceTemplateChunks.length; z++) + { + for (int x = 0; x < instanceTemplateChunks[z].length; ++x) + { + for (int y = 0; y < instanceTemplateChunks[z][x].length; ++y) + { + int chunkData = instanceTemplateChunks[z][x][y]; + int rotation = unpackChunkRotation(chunkData); + int templateChunkY = unpackChunkTemplateY(chunkData); + int templateChunkX = unpackChunkTemplateX(chunkData); + int plane = unpackChunkTemplatePlane(chunkData); + if (worldPointX >= templateChunkX && worldPointX < templateChunkX + CHUNK_SIZE + && worldPointY >= templateChunkY && worldPointY < templateChunkY + CHUNK_SIZE + && plane == worldPointPlane) + { + worldPoints.add(rotate( + baseX + x * CHUNK_SIZE + (worldPointX & (CHUNK_SIZE - 1)), + baseY + y * CHUNK_SIZE + (worldPointY & (CHUNK_SIZE - 1)), + z, + rotation)); + } + } + } + } + return worldPoints; + } + + private static boolean isInScene(WorldView worldView, int packedPoint) + { + int x = unpackWorldX(packedPoint); + int y = unpackWorldY(packedPoint); + + int baseX = worldView.getBaseX(); + int baseY = worldView.getBaseY(); + + int maxX = baseX + worldView.getSizeX(); + int maxY = baseY + worldView.getSizeY(); + + return x >= baseX && x < maxX && y >= baseY && y < maxY; + } + + /** + * Converts a packed world point into a {@link LocalPoint} relative to the + * top-level world view if it resides in + * the currently loaded scene and on the same plane; returns {@code null} + * otherwise. + * + * @param client RuneLite client. + * @param packedPoint packed world point. + * @return {@link LocalPoint} or {@code null} if out of scene or plane. + */ + public static LocalPoint toLocalPoint(Client client, int packedPoint) + { + WorldView worldView = client.getTopLevelWorldView(); + + if (worldView.getPlane() != unpackWorldPlane(packedPoint)) + { + return null; + } + + if (!isInScene(worldView, packedPoint)) + { + return null; + } + + return new LocalPoint( + (unpackWorldX(packedPoint) - worldView.getBaseX() << LOCAL_COORD_BITS) + (1 << LOCAL_COORD_BITS - 1), + (unpackWorldY(packedPoint) - worldView.getBaseY() << LOCAL_COORD_BITS) + (1 << LOCAL_COORD_BITS - 1), + worldView.getId()); + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/leagues/LeagueModeState.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/leagues/LeagueModeState.java new file mode 100644 index 00000000000..2130e3e90eb --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/leagues/LeagueModeState.java @@ -0,0 +1,189 @@ +package shortestpath.leagues; + +import java.util.EnumSet; +import java.util.Map; +import java.util.Set; +import lombok.Getter; +import net.runelite.api.Client; +import net.runelite.api.WorldType; + +/** + * Snapshot of the player's Demonic Pacts League state, refreshed once per + * {@code PathfinderConfig.refresh()} cycle (i.e. on world change, login, or + * any config-driven recompute). + * + *

+ * The Pathfinder asks two questions of this object on the hot path: + *

+ *
    + *
  • {@link #isSeasonal()} — is the player currently on a Leagues world? + * When false, all league-specific filtering is bypassed.
  • + *
  • {@link #isUnlocked(LeagueRegion)} — has the player unlocked the + * supplied region? Always-unlocked regions + * ({@link LeagueRegion#isAlwaysUnlocked()}) return {@code true} + * regardless of seasonal status; always-blocked regions + * ({@link LeagueRegion#isAlwaysBlocked()}) always return {@code false} + * when seasonal.
  • + *
+ * + *

+ * The unlock set is rebuilt from the {@code LEAGUE_AREA_SELECTION_*} varbit + * slots ({@link #AREA_SELECTION_VARBITS}). Each slot stores a numeric area + * id matching the wiki's enumeration (mapping defined in + * {@link #AREA_VARBIT_TO_REGION}): + *

+ *
    + *
  • Slot 0 ({@code 10662}) — pre-set to Varlamore on a seasonal world.
  • + *
  • Slot 1 ({@code 10663}) — Karamja, awarded for free with the player's + * first paid pick at 80 tasks.
  • + *
  • Slots 2-3 ({@code 10664}/{@code 10665}) — the player's three area + * picks at 200/300/450 tasks.
  • + *
  • Slots 4-5 ({@code 10666}/{@code 10667}) — reserved by the game for + * additional bonus unlocks; we read them defensively.
  • + *
+ * + *

+ * The mapping is deliberately hard-coded here — these IDs are known not to + * change during a league season. + *

+ */ +public class LeagueModeState +{ + /** + * Varbit IDs storing the league area unlocks. The values match + * {@code LEAGUE_AREA_SELECTION_0..5} from RuneLite's gameval VarbitID + * table. Slot 0 is the auto-set Varlamore slot, slot 1 is the Karamja + * free pick, and the remaining slots correspond to the three player + * picks at 200/300/450 tasks plus two bonus slots reserved by the game. + */ + static final int[] AREA_SELECTION_VARBITS = { + 10662, 10663, 10664, 10665, 10666, 10667, + }; + + /** + * Maps the numeric area id stored in a {@code LEAGUE_AREA_SELECTION_*} + * varbit to its {@link LeagueRegion}. Numbering was found through trial + * and error (id 1 = Misthalin is included for completeness even though + * it is never selectable). + */ + private static final Map AREA_VARBIT_TO_REGION; + + static + { + AREA_VARBIT_TO_REGION = Map.ofEntries( + Map.entry(1, LeagueRegion.MISTHALIN), + Map.entry(2, LeagueRegion.KARAMJA), + Map.entry(3, LeagueRegion.ASGARNIA), + Map.entry(4, LeagueRegion.KANDARIN), + Map.entry(5, LeagueRegion.MORYTANIA), + Map.entry(6, LeagueRegion.DESERT), + Map.entry(7, LeagueRegion.TIRANNWN), + Map.entry(8, LeagueRegion.FREMENNIK), + Map.entry(11, LeagueRegion.WILDERNESS), + Map.entry(20, LeagueRegion.KOUREND), + Map.entry(21, LeagueRegion.VARLAMORE)); + } + + @Getter + private boolean seasonal; + + private Set unlockedRegions = EnumSet.noneOf(LeagueRegion.class); + + /** + * Re-reads {@link Client#getWorldType()} and the area-unlock varbits. + * Called from {@code PathfinderConfig.refresh()} which already runs on + * world change, login, and config edits. + * + *

+ * Off the game thread (or on a {@code null} client) this resets to a + * non-seasonal state with no extra unlocks; this is the safe default + * because non-seasonal logic mirrors normal pathfinding. + *

+ */ + public void refresh(Client client) + { + if (client == null) + { + seasonal = false; + unlockedRegions = EnumSet.noneOf(LeagueRegion.class); + return; + } + EnumSet worldTypes = client.getWorldType(); + seasonal = worldTypes != null && worldTypes.contains(WorldType.SEASONAL); + + EnumSet next = EnumSet.noneOf(LeagueRegion.class); + if (seasonal) + { + for (int varbitId : AREA_SELECTION_VARBITS) + { + addRegionFromSlot(client, varbitId, next); + } + } + unlockedRegions = next; + } + + /** + * Whether the supplied region is currently traversable. Outside of + * seasonal mode every region is considered unlocked. + */ + public boolean isUnlocked(LeagueRegion region) + { + if (region == null) + { + return true; + } + if (region.isAlwaysUnlocked()) + { + return true; + } + if (!seasonal) + { + return true; + } + if (region.isAlwaysBlocked()) + { + return false; + } + return unlockedRegions.contains(region); + } + + /** + * Whether the supplied tile is in the always-blocked region while the + * player is on a seasonal world. Returns {@code false} on non-seasonal + * worlds so normal pathfinding is unaffected. + */ + public boolean isInBlockedRegion(int packedPoint) + { + if (!seasonal) + { + return false; + } + return LeagueRegionChecker.getRegion(packedPoint).isAlwaysBlocked(); + } + + /** + * Test hook: forces the seasonal flag and unlock set without touching + * the client. + */ + public void setForTest(boolean seasonal, Set unlocked) + { + this.seasonal = seasonal; + this.unlockedRegions = unlocked == null + ? EnumSet.noneOf(LeagueRegion.class) + : EnumSet.copyOf(unlocked); + } + + private static void addRegionFromSlot(Client client, int varbitId, Set out) + { + int value = client.getVarbitValue(varbitId); + if (value <= 0) + { + return; + } + LeagueRegion region = AREA_VARBIT_TO_REGION.get(value); + if (region != null) + { + out.add(region); + } + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/leagues/LeagueRegion.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/leagues/LeagueRegion.java new file mode 100644 index 00000000000..d12647bd6a8 --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/leagues/LeagueRegion.java @@ -0,0 +1,70 @@ +package shortestpath.leagues; + +/** + * Identifies the Demonic Pacts League area that a tile belongs to. + * + *

+ * Per the wiki ({@code Demonic_Pacts_League/Areas}), all players start locked + * to {@link #VARLAMORE}; {@link #KARAMJA} is the first region unlock awarded + * for free at 80 tasks; the remaining regions are picked at 200/300/450 + * tasks. Karamja is therefore not always-unlocked — it flows through the + * same area-slot varbits as the other player picks. + *

+ * + *

+ * {@link #NEUTRAL} captures areas reachable from anywhere regardless of the + * player's unlocks: Death's office, POH, Zanaris, the Abyssal Area, random + * events, instances, dynamic regions, tutorial island, and Sailing-skill + * content (e.g. The Great Conch, The Summer Shore, The Node) which is not + * part of the league at all. The Great Conch is explicitly blocked as + * {@link #MISTHALIN} rather than left NEUTRAL because it has walkable tiles + * reachable via charter ship and fairy ring — leaving it NEUTRAL would allow + * the pathfinder to route through it. + *

+ * + *

+ * {@link #MISTHALIN} is permanently inaccessible during the league. + *

+ * + *

+ * Region geometry is sourced from {@code leagues/regions.tsv}: a mapping + * from OSRS map region id to one of these enum names. Tiles with no + * mapping fall back to {@link #NEUTRAL}. + *

+ */ +public enum LeagueRegion +{ + VARLAMORE, + KARAMJA, + ASGARNIA, + KANDARIN, + FREMENNIK, + KOUREND, + WILDERNESS, + MORYTANIA, + DESERT, + TIRANNWN, + MISTHALIN, + NEUTRAL; + + /** + * Whether this region is reachable regardless of which area unlocks the + * player has chosen. Only Varlamore (the starting region) and the + * NEUTRAL bucket are always-unlocked; every other region — including + * Karamja — depends on the player's slot picks. + */ + public boolean isAlwaysUnlocked() + { + return this == VARLAMORE || this == NEUTRAL; + } + + /** + * Whether this region is permanently blocked during the league. Tiles in + * always-blocked regions reject both walking and transport traversal + * (see {@code LeagueRegionChecker} and {@code PathfinderConfig.useTransport}). + */ + public boolean isAlwaysBlocked() + { + return this == MISTHALIN; + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/leagues/LeagueRegionChecker.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/leagues/LeagueRegionChecker.java new file mode 100644 index 00000000000..8d3427fec76 --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/leagues/LeagueRegionChecker.java @@ -0,0 +1,168 @@ +package shortestpath.leagues; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import lombok.extern.slf4j.Slf4j; +import shortestpath.ShortestPathPlugin; +import shortestpath.Util; +import shortestpath.WorldPointUtil; + +/** + * Resolves a packed world point to its {@link LeagueRegion} for the Demonic + * Pacts League. + * + *

+ * Mirrors the static-utility shape of + * {@link shortestpath.pathfinder.WildernessChecker}. The mapping is keyed by + * OSRS map region id (a 64x64-tile chunk identifier matching + * {@code WorldPoint.getRegionID()}: {@code (x >> 6) << 8 | (y >> 6)}) and + * loaded from {@code /leagues/regions.tsv} on first access. + *

+ * + *

+ * Tiles whose region id is absent from the mapping fall back to + * {@link LeagueRegion#NEUTRAL}. This is deliberate: it covers instances and + * dynamic regions (POH, raids, dungeon entrances) which are universally + * accessible during the league regardless of unlocked areas. + *

+ */ +@Slf4j +public class LeagueRegionChecker +{ + private static final String RESOURCE_PATH = "/leagues/regions.tsv"; + + private static volatile Map regionsById; + + private LeagueRegionChecker() + { + } + + /** + * Returns the league region containing the supplied packed world point. + * Never returns {@code null}; unmapped tiles resolve to + * {@link LeagueRegion#NEUTRAL}. + */ + public static LeagueRegion getRegion(int packedPoint) + { + final int x = WorldPointUtil.unpackWorldX(packedPoint); + final int y = WorldPointUtil.unpackWorldY(packedPoint); + final int regionId = ((x >> 6) << 8) | (y >> 6); + return regions().getOrDefault(regionId, LeagueRegion.NEUTRAL); + } + + /** + * Convenience predicate for the always-blocked region. + */ + public static boolean isInMisthalin(int packedPoint) + { + return getRegion(packedPoint) == LeagueRegion.MISTHALIN; + } + + /** + * Returns the underlying region-id-to-region map. Lazily initialised on + * first call. Visible to tests via {@link #reload(String)}. + */ + private static Map regions() + { + Map snapshot = regionsById; + if (snapshot == null) + { + synchronized (LeagueRegionChecker.class) + { + snapshot = regionsById; + if (snapshot == null) + { + snapshot = loadFromResource(); + regionsById = snapshot; + } + } + } + return snapshot; + } + + /** + * Replaces the in-memory mapping with a parsed copy of the supplied + * TSV body. Intended for tests. Pass {@code null} to clear the cache so + * the next call re-loads from the resource file. + */ + static synchronized void reload(String tsv) + { + if (tsv == null) + { + regionsById = null; + return; + } + regionsById = parse(tsv); + } + + private static Map loadFromResource() + { + try (InputStream in = ShortestPathPlugin.class.getResourceAsStream(RESOURCE_PATH)) + { + if (in == null) + { + log.warn("League regions resource not found at {}; defaulting all tiles to NEUTRAL", RESOURCE_PATH); + return new HashMap<>(); + } + String body = new String(Util.readAllBytes(Objects.requireNonNull(in)), StandardCharsets.UTF_8); + return parse(body); + } + catch (IOException e) + { + log.error("Failed to load league regions from {}", RESOURCE_PATH, e); + return new HashMap<>(); + } + } + + private static Map parse(String tsv) + { + Map result = new HashMap<>(); + if (tsv == null || tsv.isEmpty()) + { + return result; + } + int lineNumber = 0; + for (String rawLine : tsv.split("\\R")) + { + lineNumber++; + String line = rawLine.trim(); + if (line.isEmpty() || line.startsWith("#")) + { + continue; + } + String[] parts = line.split("\\s+", 2); + if (parts.length != 2) + { + log.warn("Skipping malformed league regions row {}: '{}'", lineNumber, rawLine); + continue; + } + final int regionId; + try + { + regionId = Integer.parseInt(parts[0]); + } + catch (NumberFormatException e) + { + log.warn("Skipping league regions row {} with non-numeric region id '{}'", lineNumber, parts[0]); + continue; + } + final LeagueRegion region; + try + { + region = LeagueRegion.valueOf(parts[1]); + } + catch (IllegalArgumentException e) + { + log.warn("Skipping league regions row {} with unknown region '{}'", lineNumber, parts[1]); + continue; + } + result.put(regionId, region); + } + return result; + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/AbstractNodeKind.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/AbstractNodeKind.java new file mode 100644 index 00000000000..cc59e7bda62 --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/AbstractNodeKind.java @@ -0,0 +1,44 @@ +package shortestpath.pathfinder; + +public enum AbstractNodeKind +{ + // These four abstract teleport states mirror the wilderness buckets that change + // which teleports are legal. + GLOBAL_TELEPORTS_OVER_30, + GLOBAL_TELEPORTS_OVER_20, + GLOBAL_TELEPORTS_OVER_0, + GLOBAL_TELEPORTS_NORMAL; + + public static AbstractNodeKind fromWildernessLevel(int wildernessLevel) + { + if (wildernessLevel > 30) + { + return GLOBAL_TELEPORTS_OVER_30; + } + if (wildernessLevel > 20) + { + return GLOBAL_TELEPORTS_OVER_20; + } + if (wildernessLevel > 0) + { + return GLOBAL_TELEPORTS_OVER_0; + } + return GLOBAL_TELEPORTS_NORMAL; + } + + public int maxWildernessLevel() + { + switch (this) + { + case GLOBAL_TELEPORTS_OVER_30: + return 31; + case GLOBAL_TELEPORTS_OVER_20: + return 30; + case GLOBAL_TELEPORTS_OVER_0: + return 20; + case GLOBAL_TELEPORTS_NORMAL: + default: + return 0; + } + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/CollisionMap.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/CollisionMap.java new file mode 100644 index 00000000000..d594008345e --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/CollisionMap.java @@ -0,0 +1,284 @@ +package shortestpath.pathfinder; + +import shortestpath.PrimitiveIntList; +import shortestpath.WorldPointUtil; +import shortestpath.transport.Transport; + +public class CollisionMap +{ + // Enum.values() makes copies every time which hurts performance in the hotpath + private static final OrdinalDirection[] ORDINAL_VALUES = OrdinalDirection.values(); + + private final SplitFlagMap collisionData; + private final EdgeOverride edgeOverride; + // This is only safe if pathfinding is single-threaded. Holds the ids of the neighbour nodes + // appended to the NodeGraph during the most recent getNeighbors call. + private final PrimitiveIntList neighbors = new PrimitiveIntList(16); + private final boolean[] traversable = new boolean[8]; + + public CollisionMap(SplitFlagMap collisionData) + { + this(collisionData, null); + } + + public CollisionMap(SplitFlagMap collisionData, EdgeOverride edgeOverride) + { + this.collisionData = collisionData; + this.edgeOverride = edgeOverride; + } + + private static int packedPointFromOrdinal(int startPacked, OrdinalDirection direction) + { + final int x = WorldPointUtil.unpackWorldX(startPacked); + final int y = WorldPointUtil.unpackWorldY(startPacked); + final int plane = WorldPointUtil.unpackWorldPlane(startPacked); + return WorldPointUtil.packWorldPoint(x + direction.x, y + direction.y, plane); + } + + public byte getRegionPlaneCounts(int regionIndex) + { + return collisionData.getRegionPlaneCounts(regionIndex); + } + + private boolean get(int x, int y, int z, int flag) + { + if (edgeOverride != null) + { + Boolean value = edgeOverride.edge(x, y, z, flag); + if (value != null) + { + return value; + } + } + return collisionData.get(x, y, z, flag); + } + + public boolean n(int x, int y, int z) + { + return get(x, y, z, 0); + } + + public boolean s(int x, int y, int z) + { + return n(x, y - 1, z); + } + + public boolean e(int x, int y, int z) + { + return get(x, y, z, 1); + } + + public boolean w(int x, int y, int z) + { + return e(x - 1, y, z); + } + + private boolean ne(int x, int y, int z) + { + return n(x, y, z) && e(x, y + 1, z) && e(x, y, z) && n(x + 1, y, z); + } + + private boolean nw(int x, int y, int z) + { + return n(x, y, z) && w(x, y + 1, z) && w(x, y, z) && n(x - 1, y, z); + } + + private boolean se(int x, int y, int z) + { + return s(x, y, z) && e(x, y - 1, z) && e(x, y, z) && s(x + 1, y, z); + } + + private boolean sw(int x, int y, int z) + { + return s(x, y, z) && w(x, y - 1, z) && w(x, y, z) && s(x - 1, y, z); + } + + public boolean isBlocked(int x, int y, int z) + { + return !n(x, y, z) && !s(x, y, z) && !e(x, y, z) && !w(x, y, z); + } + + public PrimitiveIntList getNeighbors(int node, VisitedTiles visited, PathfinderConfig config, int wildernessLevel, boolean targetInWilderness, NodeGraph graph) + { + if (graph.isTile(node)) + { + return getTileNeighbors(node, visited, config, wildernessLevel, graph); + } + else + { + return getAbstractNodeNeighbors(node, visited, config, targetInWilderness, graph); + } + } + + // Get neighbours for a walkable tile: + // * Neighbouring tiles we can walk to + // * A transition into banked state, if the current tile is a bank. + // * Transition into abstract global teleport nodes, if we haven't tried that yet. + private PrimitiveIntList getTileNeighbors(int node, VisitedTiles visited, PathfinderConfig config, int wildernessLevel, NodeGraph graph) + { + final int packedPosition = graph.packedPosition(node); + final int x = WorldPointUtil.unpackWorldX(packedPosition); + final int y = WorldPointUtil.unpackWorldY(packedPosition); + final int z = WorldPointUtil.unpackWorldPlane(packedPosition); + + neighbors.clear(); + + // Either we have already visited a bank, if the current tile is a bank switch into the bankVisited state for the + // rest of the path. + boolean pathBankVisited = graph.bankVisited(node) + || (config.isBankPathEnabled() && config.bankAccessible(packedPosition)); + + // Firstly check if there are any transports or teleports which are applicable from the current tile. + Transport[] transports = config.getTransportsPacked(pathBankVisited).getOrDefault(packedPosition, TransportAvailability.EMPTY_TRANSPORTS); + // If this tile was itself reached via a delayed-visit teleport (e.g. QUETZAL_WHISTLE), propagate its + // differential cost to any competing delayed-visit transports emitted from here. This prevents the + // pathfinder from choosing a chain (e.g. whistle → landing site A → fly to B) over a direct teleport + // to B, because the chain inherits the teleport's penalty and is therefore always more expensive. + int inheritedDifferential = (graph.isTransport(node) && graph.isDelayedVisit(node)) + ? graph.differentialCost(node) + : 0; + for (Transport transport : transports) + { + boolean delayedVisit = transport.getType().sharesDestinationsWith() != null; + // Do not consider a transport if we have already visited its target tile. + // For transports that share destinations with a teleport, skip this check + // so both can compete in the priority queue (delayed visit). + if (!delayedVisit && visited.get(transport.getDestination(), pathBankVisited)) + { + continue; + } + // Inherit the parent teleport's differential as a real cost on chained shared-destination transports, + // so that chaining (e.g. fly to landing site A then use station to B) is always more expensive than + // a direct teleport to B. + int chainPenalty = (delayedVisit && inheritedDifferential > 0) ? inheritedDifferential : 0; + // NB: Do not need to check for wilderness level for transports, since transports have specific origin tile. + neighbors.add(graph.createTransport( + transport.getDestination(), + node, + transport.getDuration(), + config.getAdditionalTransportCost(transport) + chainPenalty, + pathBankVisited, + delayedVisit, + delayedVisit ? config.getDifferentialCost(transport) : 0, + transport)); + } + + // Global teleports are only considered from an abstract node, so each + // wilderness/bank state expands them once. + AbstractNodeKind abstractKind = AbstractNodeKind.fromWildernessLevel(wildernessLevel); + if (!visited.getAbstract(abstractKind, pathBankVisited)) + { + neighbors.add(graph.createAbstract(abstractKind, node, pathBankVisited)); + } + + // Then add tiles which we can walk to, which go into the FIFO boundary queue. + if (isBlocked(x, y, z)) + { + boolean westBlocked = isBlocked(x - 1, y, z); + boolean eastBlocked = isBlocked(x + 1, y, z); + boolean southBlocked = isBlocked(x, y - 1, z); + boolean northBlocked = isBlocked(x, y + 1, z); + boolean southWestBlocked = isBlocked(x - 1, y - 1, z); + boolean southEastBlocked = isBlocked(x + 1, y - 1, z); + boolean northWestBlocked = isBlocked(x - 1, y + 1, z); + boolean northEastBlocked = isBlocked(x + 1, y + 1, z); + traversable[0] = !westBlocked; + traversable[1] = !eastBlocked; + traversable[2] = !southBlocked; + traversable[3] = !northBlocked; + traversable[4] = !southWestBlocked && !westBlocked && !southBlocked; + traversable[5] = !southEastBlocked && !eastBlocked && !southBlocked; + traversable[6] = !northWestBlocked && !westBlocked && !northBlocked; + traversable[7] = !northEastBlocked && !eastBlocked && !northBlocked; + } + else + { + traversable[0] = w(x, y, z); + traversable[1] = e(x, y, z); + traversable[2] = s(x, y, z); + traversable[3] = n(x, y, z); + traversable[4] = sw(x, y, z); + traversable[5] = se(x, y, z); + traversable[6] = nw(x, y, z); + traversable[7] = ne(x, y, z); + } + + for (int i = 0; i < traversable.length; i++) + { + OrdinalDirection d = ORDINAL_VALUES[i]; + int neighborPacked = packedPointFromOrdinal(packedPosition, d); + if (visited.get(neighborPacked, pathBankVisited)) + { + continue; + } + + if (traversable[i]) + { + neighbors.add(graph.createTile(neighborPacked, node, pathBankVisited, + config.getAdditionalWalkingCost(neighborPacked))); + } + else if (Math.abs(d.x + d.y) == 1 && isBlocked(x + d.x, y + d.y, z)) + { + // The transport starts from a blocked adjacent tile, e.g. fairy ring + // Only checks non-teleport transports (includes portals and levers, but not + // items and spells) + Transport[] neighborTransports = config.getTransportsPacked(pathBankVisited).getOrDefault(neighborPacked, + TransportAvailability.EMPTY_TRANSPORTS); + for (Transport transport : neighborTransports) + { + if (transport.getOrigin() == Transport.UNDEFINED_ORIGIN + || !(transport.isUsableAtWildernessLevel(wildernessLevel)) + || visited.get(transport.getOrigin(), pathBankVisited)) + { + continue; + } + neighbors.add(graph.createTile(transport.getOrigin(), node, pathBankVisited, + config.getAdditionalWalkingCost(transport.getOrigin()))); + } + } + } + + return neighbors; + } + + // The only abstract nodes are currently for global teleports + private PrimitiveIntList getAbstractNodeNeighbors(int node, VisitedTiles visited, PathfinderConfig config, + boolean targetInWilderness, NodeGraph graph) + { + neighbors.clear(); + int sourceTile = graph.getClosestTilePosition(node); + boolean bankVisited = graph.bankVisited(node); + int maxWildernessLevel = graph.abstractKind(node).maxWildernessLevel(); + for (Transport transport : config.getUsableTeleports(bankVisited)) + { + boolean delayedVisit = transport.getType().sharesDestinationsWith() != null; + if (!delayedVisit && visited.get(transport.getDestination(), bankVisited)) + { + continue; + } + if (!transport.isUsableAtWildernessLevel(maxWildernessLevel)) + { + continue; + } + if (config.avoidWilderness(sourceTile, transport.getDestination(), targetInWilderness)) + { + continue; + } + // The differential cost is only used for priority-queue ordering (compareCost), not + // propagated as real cost, so applying it unconditionally is safe: a nearby partner + // station can still win the dequeue race, and a far-away whistle still resolves as the + // cheapest path because no competitor has a lower real cost to the same destination. + int differentialCost = delayedVisit ? config.getDifferentialCost(transport) : 0; + neighbors.add(graph.createTransport( + transport.getDestination(), + node, + transport.getDuration(), + config.getAdditionalTransportCost(transport), + bankVisited, + delayedVisit, + differentialCost, + transport)); + } + return neighbors; + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/EdgeOverride.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/EdgeOverride.java new file mode 100644 index 00000000000..a30548fd2f5 --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/EdgeOverride.java @@ -0,0 +1,11 @@ +package shortestpath.pathfinder; + +/** Immutable per-search override for the pinned static collision edge model. */ +@FunctionalInterface +public interface EdgeOverride +{ + /** + * @return {@code TRUE}/{@code FALSE} for a known override, or {@code null} to use static collision. + */ + Boolean edge(int x, int y, int plane, int flag); +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/IntDeque.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/IntDeque.java new file mode 100644 index 00000000000..c2976a288aa --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/IntDeque.java @@ -0,0 +1,106 @@ +package shortestpath.pathfinder; + +/** + * A minimal growable FIFO of primitive {@code int} node ids backed by a ring buffer. + *

+ * Replaces the {@code ArrayDeque} boundary queue in {@link Pathfinder} so the search frontier + * no longer boxes node references. Only the operations the pathfinder needs are implemented; it is + * single-threaded (worker only) like the queue it replaces. + */ +class IntDeque +{ + private int[] elements; + private int head; + private int tail; + private int size; + + IntDeque(int initialCapacity) + { + elements = new int[Math.max(1, initialCapacity)]; + } + + int size() + { + return size; + } + + boolean isEmpty() + { + return size == 0; + } + + void addLast(int value) + { + if (size == elements.length) + { + grow(); + } + elements[tail] = value; + tail = increment(tail); + size++; + } + + void addFirst(int value) + { + if (size == elements.length) + { + grow(); + } + head = decrement(head); + elements[head] = value; + size++; + } + + /** + * @return the first element, or {@link NodeGraph#NO_NODE} if empty. + */ + int peekFirst() + { + return size == 0 ? NodeGraph.NO_NODE : elements[head]; + } + + /** + * @return the removed first element, or {@link NodeGraph#NO_NODE} if empty. + */ + int pollFirst() + { + if (size == 0) + { + return NodeGraph.NO_NODE; + } + final int value = elements[head]; + head = increment(head); + size--; + return value; + } + + void clear() + { + head = 0; + tail = 0; + size = 0; + } + + private int increment(int index) + { + return index + 1 == elements.length ? 0 : index + 1; + } + + private int decrement(int index) + { + return index == 0 ? elements.length - 1 : index - 1; + } + + private void grow() + { + final int oldCapacity = elements.length; + final int[] grown = new int[oldCapacity << 1]; + for (int i = 0; i < size; i++) + { + grown[i] = elements[(head + i) % oldCapacity]; + } + elements = grown; + head = 0; + tail = size; + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/IntMinHeap.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/IntMinHeap.java new file mode 100644 index 00000000000..b37c2b45d73 --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/IntMinHeap.java @@ -0,0 +1,124 @@ +package shortestpath.pathfinder; + +import java.util.Arrays; + +/** + * A binary min-heap of primitive {@code int} node ids ordered by {@link NodeGraph#compareCost}. + *

+ * Replaces the {@code PriorityQueue} pending queue in {@link Pathfinder} so transport + * candidates are stored as int ids rather than boxed node objects. The ordering key is fixed when a + * node is created (its differential cost never changes), so no decrease-key support is needed; the + * pathfinder discards stale cheaper duplicates with its dequeue-time visited re-check. Single-threaded + * (worker only), matching the queue it replaces. + */ +class IntMinHeap +{ + private final NodeGraph graph; + private int[] heap; + private int size; + + IntMinHeap(NodeGraph graph, int initialCapacity) + { + this.graph = graph; + this.heap = new int[Math.max(1, initialCapacity)]; + } + + int size() + { + return size; + } + + boolean isEmpty() + { + return size == 0; + } + + /** + * @return the minimum-cost element, or {@link NodeGraph#NO_NODE} if empty. + */ + int peek() + { + return size == 0 ? NodeGraph.NO_NODE : heap[0]; + } + + void add(int id) + { + if (size == heap.length) + { + heap = Arrays.copyOf(heap, heap.length << 1); + } + heap[size] = id; + siftUp(size); + size++; + } + + /** + * @return the removed minimum-cost element, or {@link NodeGraph#NO_NODE} if empty. + */ + int poll() + { + if (size == 0) + { + return NodeGraph.NO_NODE; + } + final int top = heap[0]; + size--; + if (size > 0) + { + heap[0] = heap[size]; + siftDown(0); + } + return top; + } + + void clear() + { + size = 0; + } + + private void siftUp(int index) + { + final int id = heap[index]; + final int key = graph.compareCost(id); + while (index > 0) + { + final int parent = (index - 1) >> 1; + if (key >= graph.compareCost(heap[parent])) + { + break; + } + heap[index] = heap[parent]; + index = parent; + } + heap[index] = id; + } + + private void siftDown(int index) + { + final int id = heap[index]; + final int key = graph.compareCost(id); + final int half = size >> 1; + while (index < half) + { + int child = (index << 1) + 1; + int childKey = graph.compareCost(heap[child]); + final int right = child + 1; + if (right < size) + { + final int rightKey = graph.compareCost(heap[right]); + if (rightKey < childKey) + { + child = right; + childKey = rightKey; + } + } + if (key <= childKey) + { + break; + } + heap[index] = heap[child]; + index = child; + } + heap[index] = id; + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/NodeGraph.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/NodeGraph.java new file mode 100644 index 00000000000..53fb12f7d77 --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/NodeGraph.java @@ -0,0 +1,336 @@ +package shortestpath.pathfinder; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import shortestpath.WorldPointUtil; +import shortestpath.transport.Transport; + +/** + * Structure-of-Arrays store for pathfinding nodes. + *

+ * The previous design allocated one {@link Object} per explored tile (a {@code Node} or + * {@code TransportNode}). Heavy searches explore hundreds of thousands of tiles, so this produced + * hundreds of thousands of live objects, each carrying a ~16-byte header plus several object + * references (issue #491). Here every node is instead an {@code int} index into parallel primitive + * arrays, so a whole search holds only a handful of arrays regardless of how many nodes it visits. + *

+ * The fields packed per node are exactly those of the old {@code Node}/{@code TransportNode}: + * packed world position, the index of the previous node ({@link #NO_NODE} for the start), + * accumulated cost, the transport differential cost (queue-ordering only), a set of boolean flags, + * and the {@link AbstractNodeKind} ordinal for abstract nodes. + *

+ * Threading. The search runs on a single worker thread, but the render thread + * reads the partial path while the search is still running (progressive rendering via + * {@code Pathfinder.getPath()}). Node data is write-once and is published to the render thread by + * the single volatile {@code Pathfinder.bestLastNode} handoff rather than by marking these arrays + * {@code volatile} (which would cripple the hot loop, see the field comment). The chain walks + * ({@link #getPathSteps} / {@link #getClosestTilePosition}) snapshot the arrays into locals and + * tolerate an index that is out of bounds or released. This means a walk concurrent with a + * grow/release can never throw; at worst it yields a one-frame-stale path. + */ +public class NodeGraph +{ + public static final int NO_NODE = -1; + + private static final byte FLAG_BANK_VISITED = 1; // bit0 + private static final byte FLAG_ABSTRACT = 1 << 1; // bit1 + private static final byte FLAG_DELAYED_VISIT = 1 << 2; // bit2 + private static final byte FLAG_TRANSPORT = 1 << 3; // bit3 + + // Enum.values() copies on every call, so cache it for the abstractKind lookup. + private static final AbstractNodeKind[] ABSTRACT_KINDS = AbstractNodeKind.values(); + + // The node arrays are NOT volatile: making them volatile forces every accessor (packedPosition, + // cost, isTile, compareCost, the append() writes, ...) to re-read the array reference on each + // call and blocks the JIT from caching the base in a register or eliminating bounds checks. The + // hot loop touches these hundreds of times per node, so volatile reads roughly halved field + // throughput (~1.6x slower searches). Safe publication to the render thread is provided instead + // by the single volatile Pathfinder.bestLastNode handoff: the worker writes the node data, then + // volatile-writes bestLastNode; the render thread volatile-reads bestLastNode before walking, + // which establishes happens-before for all the plain writes above it. The walk methods snapshot + // the references into locals and tolerate a null (post-release) or stale-but-valid (mid-grow, + // Arrays.copyOf preserves every index) array, so a concurrent grow/release never throws. + private int[] packedPosition; + private int[] previous; + private int[] cost; + private int[] differentialCost; + private byte[] flags; + private byte[] abstractKind; + private Transport[] transport; + private int size; + + public NodeGraph(int initialCapacity) + { + final int capacity = Math.max(1, initialCapacity); + packedPosition = new int[capacity]; + previous = new int[capacity]; + cost = new int[capacity]; + differentialCost = new int[capacity]; + flags = new byte[capacity]; + abstractKind = new byte[capacity]; + transport = new Transport[capacity]; + } + + public int size() + { + return size; + } + + private void ensureCapacity() + { + if (size < packedPosition.length) + { + return; + } + // Grow by 50% like ArrayList. Arrays.copyOf preserves every existing index, and node data + // is write-once, so a render thread reading a pre-grow array still sees correct values for + // the indices it walks. + final int newCapacity = packedPosition.length + (packedPosition.length >> 1); + packedPosition = Arrays.copyOf(packedPosition, newCapacity); + previous = Arrays.copyOf(previous, newCapacity); + cost = Arrays.copyOf(cost, newCapacity); + differentialCost = Arrays.copyOf(differentialCost, newCapacity); + flags = Arrays.copyOf(flags, newCapacity); + abstractKind = Arrays.copyOf(abstractKind, newCapacity); + transport = Arrays.copyOf(transport, newCapacity); + } + + private int append(int packed, int prev, int nodeCost, int diffCost, byte flagBits, byte kind, + Transport selectedTransport) + { + ensureCapacity(); + final int id = size; + packedPosition[id] = packed; + previous[id] = prev; + cost[id] = nodeCost; + differentialCost[id] = diffCost; + abstractKind[id] = kind; + flags[id] = flagBits; + transport[id] = selectedTransport; + size = id + 1; + return id; + } + + private int costOf(int id) + { + return id == NO_NODE ? 0 : cost[id]; + } + + /** + * The search root. Carries no previous node and zero cost. + */ + public int createStart(int packedPosition) + { + return append(packedPosition, NO_NODE, 0, 0, (byte) 0, (byte) 0, null); + } + + /** + * A concrete walkable tile. Travel cost is the walking distance from the previous node, but + * only when the previous node is itself a tile (mirrors the old {@code Node.cost}); reaching a + * tile from an abstract node adds no travel cost. + */ + public int createTile(int packedPosition, int previous, boolean bankVisited) + { + return createTile(packedPosition, previous, bankVisited, 0); + } + + public int createTile(int packedPosition, int previous, boolean bankVisited, int additionalCost) + { + final int travelTime = (previous != NO_NODE && isTile(previous)) + ? WorldPointUtil.distanceBetween(this.packedPosition[previous], packedPosition) + : 0; + final byte flagBits = bankVisited ? FLAG_BANK_VISITED : 0; + return append(packedPosition, previous, costOf(previous) + travelTime + additionalCost, + 0, flagBits, (byte) 0, null); + } + + /** + * A transport destination tile. Cost is the previous cost plus the transport's travel time and + * any additional cost; there is no walking-distance term (mirrors the old {@code TransportNode}). + */ + public int createTransport(int packedPosition, int previous, int travelTime, int additionalCost, + boolean bankVisited, boolean delayedVisit, int differentialCost, Transport selectedTransport) + { + byte flagBits = FLAG_TRANSPORT; + if (bankVisited) + { + flagBits |= FLAG_BANK_VISITED; + } + if (delayedVisit) + { + flagBits |= FLAG_DELAYED_VISIT; + } + return append(packedPosition, previous, costOf(previous) + travelTime + additionalCost, + differentialCost, flagBits, (byte) 0, selectedTransport); + } + + /** + * An abstract search-state node (global teleports). Has no world position and inherits the + * previous node's cost (mirrors the old {@code Node.abstractNode}). + */ + public int createAbstract(AbstractNodeKind abstractKind, int previous, boolean bankVisited) + { + byte flagBits = FLAG_ABSTRACT; + if (bankVisited) + { + flagBits |= FLAG_BANK_VISITED; + } + return append(WorldPointUtil.UNDEFINED, previous, costOf(previous), 0, flagBits, + (byte) abstractKind.ordinal(), null); + } + + public int packedPosition(int id) + { + return packedPosition[id]; + } + + public int previous(int id) + { + return previous[id]; + } + + public int cost(int id) + { + return cost[id]; + } + + public int differentialCost(int id) + { + return differentialCost[id]; + } + + /** + * The cost used for priority-queue ordering, includes the transport differential. + */ + public int compareCost(int id) + { + return cost[id] + differentialCost[id]; + } + + public boolean bankVisited(int id) + { + return (flags[id] & FLAG_BANK_VISITED) != 0; + } + + public boolean isTile(int id) + { + return (flags[id] & FLAG_ABSTRACT) == 0; + } + + public boolean isAbstract(int id) + { + return (flags[id] & FLAG_ABSTRACT) != 0; + } + + public boolean isTransport(int id) + { + return (flags[id] & FLAG_TRANSPORT) != 0; + } + + public boolean isDelayedVisit(int id) + { + return (flags[id] & FLAG_DELAYED_VISIT) != 0; + } + + public AbstractNodeKind abstractKind(int id) + { + return ABSTRACT_KINDS[abstractKind[id]]; + } + + /** + * Walks the previous chain from {@code id} to the start, collecting the tile nodes (abstract + * nodes are skipped) into an ordered list of path steps. + *

+ * Safe to call from the render thread during the search: the arrays are snapshotted into locals + * and the walk is bounds-tolerant, so a concurrent grow or {@link #release()} yields an empty + * or one-frame-stale result rather than throwing. + */ + public List getPathSteps(int id) + { + final int[] prev = previous; + final int[] packed = packedPosition; + final byte[] flg = flags; + final Transport[] selectedTransports = transport; + if (prev == null || packed == null || flg == null || selectedTransports == null + || id == NO_NODE) + { + return new ArrayList<>(); + } + final int len = prev.length; + + int node = id; + int n = 0; + while (node != NO_NODE && node < len) + { + if ((flg[node] & FLAG_ABSTRACT) == 0) + { + n++; + } + node = prev[node]; + } + + final List pathSteps = new ArrayList<>(n); + for (int i = 0; i < n; i++) + { + pathSteps.add(null); + } + + node = id; + int i = n; + while (node != NO_NODE && node < len && i > 0) + { + if ((flg[node] & FLAG_ABSTRACT) == 0) + { + pathSteps.set(--i, new PathStep(packed[node], + (flg[node] & FLAG_BANK_VISITED) != 0, selectedTransports[node])); + } + node = prev[node]; + } + + return pathSteps; + } + + /** + * Walks the previous chain from {@code id} until the first tile node and returns its packed + * position, or {@link WorldPointUtil#UNDEFINED} if none. Same threading guarantees as + * {@link #getPathSteps}. + */ + public int getClosestTilePosition(int id) + { + final int[] prev = previous; + final int[] packed = packedPosition; + final byte[] flg = flags; + if (prev == null || packed == null || flg == null) + { + return WorldPointUtil.UNDEFINED; + } + final int len = prev.length; + int node = id; + while (node != NO_NODE && node < len && (flg[node] & FLAG_ABSTRACT) != 0) + { + node = prev[node]; + } + return (node != NO_NODE && node < len) ? packed[node] : WorldPointUtil.UNDEFINED; + } + + /** + * Releases the backing arrays once the search is finished and the final path has been + * materialised, so the large per-search working set becomes eligible for garbage collection + * (the old design dropped the explored {@code Node} objects the same way by clearing the + * frontier collections). A render-thread walk in flight keeps its own local references and + * finishes safely. + */ + public void release() + { + packedPosition = null; + previous = null; + cost = null; + differentialCost = null; + flags = null; + abstractKind = null; + transport = null; + size = 0; + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/OrdinalDirection.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/OrdinalDirection.java new file mode 100644 index 00000000000..54033e41c95 --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/OrdinalDirection.java @@ -0,0 +1,22 @@ +package shortestpath.pathfinder; + +public enum OrdinalDirection +{ + WEST(-1, 0), + EAST(1, 0), + SOUTH(0, -1), + NORTH(0, 1), + SOUTH_WEST(-1, -1), + SOUTH_EAST(1, -1), + NORTH_WEST(-1, 1), + NORTH_EAST(1, 1); + + final int x; + final int y; + + OrdinalDirection(int x, int y) + { + this.x = x; + this.y = y; + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/PathStep.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/PathStep.java new file mode 100644 index 00000000000..77d3af61d08 --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/PathStep.java @@ -0,0 +1,24 @@ +package shortestpath.pathfinder; + +import lombok.Getter; +import shortestpath.transport.Transport; + +@Getter +public final class PathStep +{ + private final int packedPosition; + private final boolean bankVisited; + private final Transport transport; + + public PathStep(int packedPosition, boolean bankVisited) + { + this(packedPosition, bankVisited, null); + } + + public PathStep(int packedPosition, boolean bankVisited, Transport transport) + { + this.packedPosition = packedPosition; + this.bankVisited = bankVisited; + this.transport = transport; + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/PathTerminationReason.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/PathTerminationReason.java new file mode 100644 index 00000000000..7021fa14e79 --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/PathTerminationReason.java @@ -0,0 +1,9 @@ +package shortestpath.pathfinder; + +public enum PathTerminationReason +{ + TARGET_REACHED, + SEARCH_EXHAUSTED, + CUTOFF_REACHED, + CANCELLED +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/Pathfinder.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/Pathfinder.java new file mode 100644 index 00000000000..8242eb1db6a --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/Pathfinder.java @@ -0,0 +1,417 @@ +package shortestpath.pathfinder; + +import java.util.List; +import java.util.Set; + +import lombok.Getter; +import shortestpath.PrimitiveIntList; +import shortestpath.WorldPointUtil; +import shortestpath.leagues.LeagueModeState; + +public class Pathfinder implements Runnable +{ + private final PathfinderStats stats; + @Getter + private final int start; + @Getter + private final Set targets; + private final PathfinderConfig config; + private final CollisionMap map; + private final boolean targetInWilderness; + private final boolean targetInBlockedRegion; + private final Runnable completionCallback; + // Nodes are stored structure-of-arrays style: each node is an int id into the graph, instead of + // an object per explored tile. This keeps a whole search to a handful of arrays (issue #491). + private final NodeGraph graph = new NodeGraph(1 << 14); + // Capacities should be enough to store all nodes without requiring the queue to grow + // They were found by checking the max queue size + private final IntDeque boundary = new IntDeque(4096); + private final IntMinHeap pending = new IntMinHeap(graph, 256); + private final VisitedTiles visited; + @Getter + private volatile boolean done = false; + private volatile boolean cancelled = false; + // Read by the render thread during the search to draw the partial path; written by the worker. + private volatile int bestLastNode = NodeGraph.NO_NODE; + // The path the render thread builds progressively while the search runs. + private List pathSteps = List.of(); + private boolean pathNeedsUpdate = false; + // Built once on the worker thread when the search finishes, then served to the render thread so + // it never walks the node chain (which is released) after the search is done. + private volatile List finalPath = null; + private volatile int closestReachedPoint = WorldPointUtil.UNDEFINED; + private int bestRemainingDistance = Integer.MAX_VALUE; + private int bestTravelledDistance = Integer.MAX_VALUE; + private int bestX = Integer.MAX_VALUE; + private int bestY = Integer.MAX_VALUE; + private int reachedTarget = WorldPointUtil.UNDEFINED; + private PathTerminationReason terminationReason; + /** + * Teleportation transports are updated when this changes. + * Can be either: + * 0 = all teleports can be used (e.g. Chronicle) + * 20 = most teleports can be used (e.g. Varrock Teleport) + * 30 = some teleports can be used (e.g. Amulet of Glory) + * 31 = no teleports can be used + */ + private int wildernessLevel; + + public Pathfinder(PathfinderConfig config, int start, Set targets, Runnable completionCallback) + { + stats = new PathfinderStats(); + this.config = config; + this.map = config.getMap(); + this.start = start; + this.targets = targets; + this.completionCallback = completionCallback; + visited = new VisitedTiles(map); + targetInWilderness = WildernessChecker.isInWilderness(targets); + targetInBlockedRegion = anyInBlockedRegion(config.getLeagueModeState(), targets); + wildernessLevel = 31; + } + + private static boolean anyInBlockedRegion(LeagueModeState league, Set packed) + { + if (!league.isSeasonal() || packed == null || packed.isEmpty()) + { + return false; + } + for (Integer point : packed) + { + if (league.isInBlockedRegion(point)) + { + return true; + } + } + return false; + } + + public Pathfinder(PathfinderConfig config, int start, Set targets) + { + this(config, start, targets, null); + } + + public void cancel() + { + cancelled = true; + } + + public PathfinderStats getStats() + { + if (stats.started && stats.ended) + { + return stats; + } + + // Don't give incomplete results + return null; + } + + public List getPath() + { + int lastNode = bestLastNode; // For thread safety, read bestLastNode once + if (lastNode == NodeGraph.NO_NODE) + { + List finalised = finalPath; + return finalised != null ? finalised : pathSteps; + } + + // Once the search is finished the node graph is released, so serve the pre-built snapshot. + if (done) + { + List finalised = finalPath; + if (finalised != null) + { + return finalised; + } + } + + if (pathNeedsUpdate) + { + List walked = graph.getPathSteps(lastNode); + // An empty result means the graph was released mid-walk; keep the last good path. + if (!walked.isEmpty()) + { + pathSteps = walked; + pathNeedsUpdate = false; + } + } + + return pathSteps; + } + + public PathfinderResult getResult() + { + PathfinderStats currentStats = getStats(); + if (currentStats == null) + { + return null; + } + + List currentPath = getPath(); + boolean reached = reachedTarget != WorldPointUtil.UNDEFINED; + int target = reached ? reachedTarget : (targets.isEmpty() ? WorldPointUtil.UNDEFINED : targets.iterator().next()); + // getStats() only returns non-null once the search has ended, so the snapshot is set. + return new PathfinderResult( + start, + target, + reached, + currentPath, + closestReachedPoint, + currentStats.getNodesChecked(), + currentStats.getTransportsChecked(), + currentStats.getElapsedTimeNanos(), + terminationReason + ); + } + + private void addNeighbors(int node, boolean nodeIsTile, int nodePacked) + { + PrimitiveIntList nodes = map.getNeighbors(node, visited, config, wildernessLevel, targetInWilderness, graph); + final int count = nodes.size(); + for (int i = 0; i < count; i++) + { + int neighbor = nodes.get(i); + // Each graph.xxx(id) re-indexes a backing array, so read each neighbour field once and + // reuse the loop-invariant node fields passed in (the JIT cached these for free when nodes + // were objects, but not when they are int ids into structure-of-arrays storage). + final boolean neighborIsTile = graph.isTile(neighbor); + if (nodeIsTile && neighborIsTile) + { + final int neighborPacked = graph.packedPosition(neighbor); + if (config.avoidWilderness(nodePacked, neighborPacked, targetInWilderness)) + { + continue; + } + if (config.avoidBlockedRegion(nodePacked, neighborPacked, targetInBlockedRegion)) + { + continue; + } + } + + final boolean neighborIsTransport = graph.isTransport(neighbor); + // For delayed-visit nodes (shared destinations), don't mark as visited on enqueue. + // They will be checked and marked when dequeued from pending. + if (!(neighborIsTransport && graph.isDelayedVisit(neighbor))) + { + visited.set(neighbor, graph); + } + if (neighborIsTransport) + { + pending.add(neighbor); + ++stats.transportsChecked; + } + else + { + boundary.addLast(neighbor); + ++stats.nodesChecked; + } + } + } + + /** + * Pathfinding to an unreachable target is slightly different from normal pathfinding. + * Straight-line movement before diagonal movement is no longer prioritized, because the + * original target is moved to the closest reachable tile. To avoid having to move the + * original target we instead do the following to favour the closest reachable tile: + * - 1) Pick the path with the minimum Euclidean distance (no need to use square root though) + * - 2) If a tie occurs, pick the path with minimum travelled distance + * - 3) If another tie occurs, pick the path with minimum x-coordinate + * - 4) If another tie occurs, pick the path with minimum y-coordinate + */ + private boolean updateBestPathWhenUnreachable(int node, int packedPosition) + { + boolean update = false; + + final int travelledDistance = graph.cost(node); + for (int target : targets) + { + int remainingDistance = WorldPointUtil.distanceBetween(target, packedPosition, WorldPointUtil.EUCLIDEAN_SQUARED_DISTANCE_METRIC); + int x = WorldPointUtil.unpackWorldX(packedPosition); + int y = WorldPointUtil.unpackWorldY(packedPosition); + if ((remainingDistance < bestRemainingDistance) || + (remainingDistance == bestRemainingDistance && travelledDistance < bestTravelledDistance) || + (remainingDistance == bestRemainingDistance && travelledDistance == bestTravelledDistance && x < bestX) || + (remainingDistance == bestRemainingDistance && travelledDistance == bestTravelledDistance && x == bestX && y < bestY)) + { + bestRemainingDistance = remainingDistance; + bestTravelledDistance = travelledDistance; + bestX = x; + bestY = y; + bestLastNode = node; + pathNeedsUpdate = true; + update = true; + } + } + + return update; + } + + /** + * Update wilderness level based on the current node position. + */ + private void updateWildernessLevel(int packedPosition) + { + if (wildernessLevel > 0) + { + // These are overlapping boundaries, so if the node isn't in level 30, it's in 0-29 + // likewise, if the node isn't in level 20, it's in 0-19 + if (wildernessLevel > 30 && !WildernessChecker.isInLevel30Wilderness(packedPosition)) + { + wildernessLevel = 30; + } + if (wildernessLevel > 20 && !WildernessChecker.isInLevel20Wilderness(packedPosition)) + { + wildernessLevel = 20; + } + if (wildernessLevel > 0 && !WildernessChecker.isInWilderness(packedPosition)) + { + wildernessLevel = 0; + } + } + } + + @Override + public void run() + { + stats.start(); + boundary.addFirst(graph.createStart(start)); + + long cutoffDurationMillis = config.getCalculationCutoffMillis(); + long cutoffTimeMillis = System.currentTimeMillis() + cutoffDurationMillis; + + while (!cancelled && (!boundary.isEmpty() || !pending.isEmpty())) + { + int boundaryHead = boundary.peekFirst(); + int pendingHead = pending.peek(); + + int node; + if (pendingHead != NodeGraph.NO_NODE + && (boundaryHead == NodeGraph.NO_NODE || graph.compareCost(pendingHead) < graph.cost(boundaryHead))) + { + node = pending.poll(); + + // For delayed-visit nodes, check if the destination was already + // reached by a cheaper path while this node was queued. + if (graph.isDelayedVisit(node)) + { + int packed = graph.packedPosition(node); + boolean bank = graph.bankVisited(node); + if (visited.get(packed, bank)) + { + continue; + } + visited.set(packed, bank); + } + } + else + { + node = boundary.pollFirst(); + } + if (node == NodeGraph.NO_NODE) + { + continue; + } + // Read the node's tile-ness and position once; every graph.xxx(id) re-indexes a backing + // array, and these are used by several of the checks below. + final boolean nodeIsTile = graph.isTile(node); + final int nodePacked = nodeIsTile ? graph.packedPosition(node) : WorldPointUtil.UNDEFINED; + if (nodeIsTile) + { + updateWildernessLevel(nodePacked); + + if (targets.contains(nodePacked)) + { + bestLastNode = node; + pathNeedsUpdate = true; + reachedTarget = nodePacked; + terminationReason = PathTerminationReason.TARGET_REACHED; + break; + } + + if (updateBestPathWhenUnreachable(node, nodePacked)) + { + cutoffTimeMillis = System.currentTimeMillis() + cutoffDurationMillis; + } + } + + if (System.currentTimeMillis() > cutoffTimeMillis) + { + terminationReason = PathTerminationReason.CUTOFF_REACHED; + break; + } + + addNeighbors(node, nodeIsTile, nodePacked); + } + + if (cancelled) + { + terminationReason = PathTerminationReason.CANCELLED; + } + else if (terminationReason == null) + { + terminationReason = PathTerminationReason.SEARCH_EXHAUSTED; + } + + // Materialise the final path and closest reached tile on the worker thread, publish them, + // then release the large node graph. Once done is set the render thread serves finalPath + // and never touches the released graph, so this is race-free with progressive rendering. + int lastNode = bestLastNode; + if (lastNode != NodeGraph.NO_NODE) + { + finalPath = graph.getPathSteps(lastNode); + closestReachedPoint = graph.getClosestTilePosition(lastNode); + } + else + { + finalPath = pathSteps; + closestReachedPoint = start; + } + + done = !cancelled; + + boundary.clear(); + visited.clear(); + pending.clear(); + graph.release(); + + stats.end(); // Include cleanup in stats to get the total cost of pathfinding + + if (completionCallback != null) + { + completionCallback.run(); + } + } + + public static class PathfinderStats + { + @Getter + private int nodesChecked = 0, transportsChecked = 0; + private long startNanos, endNanos; + private volatile boolean started = false, ended = false; + + public int getTotalNodesChecked() + { + return nodesChecked + transportsChecked; + } + + public long getElapsedTimeNanos() + { + return endNanos - startNanos; + } + + private void start() + { + started = true; + nodesChecked = 0; + transportsChecked = 0; + startNanos = System.nanoTime(); + } + + private void end() + { + endNanos = System.nanoTime(); + ended = true; + } + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/PathfinderConfig.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/PathfinderConfig.java new file mode 100644 index 00000000000..e10d62a4e62 --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/PathfinderConfig.java @@ -0,0 +1,1136 @@ +package shortestpath.pathfinder; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import lombok.Getter; +import net.runelite.api.Client; +import net.runelite.api.Constants; +import net.runelite.api.EnumComposition; +import net.runelite.api.EnumID; +import net.runelite.api.GameState; +import net.runelite.api.Item; +import net.runelite.api.ItemContainer; +import net.runelite.api.Quest; +import net.runelite.api.QuestState; +import net.runelite.api.Skill; +import net.runelite.api.gameval.InventoryID; +import net.runelite.api.gameval.ItemID; +import net.runelite.api.gameval.VarPlayerID; +import net.runelite.api.gameval.VarbitID; +import shortestpath.Destination; +import shortestpath.DestinationRequirements; +import shortestpath.ItemVariations; +import shortestpath.JewelleryBoxTier; +import shortestpath.PrimitiveIntHashMap; +import shortestpath.ShortestPathConfig; +import shortestpath.ShortestPathPlugin; +import shortestpath.TeleportationItem; +import shortestpath.WorldPointUtil; +import shortestpath.leagues.LeagueModeState; +import shortestpath.leagues.LeagueRegion; +import shortestpath.leagues.LeagueRegionChecker; +import shortestpath.transport.Transport; +import shortestpath.transport.TransportLoader; +import shortestpath.transport.TransportType; +import shortestpath.transport.TransportTypeConfig; +import shortestpath.transport.parser.VarRequirement; +import shortestpath.transport.requirement.ItemRequirement; +import shortestpath.transport.requirement.TransportItems; + +@SuppressWarnings("SameParameterValue") +public class PathfinderConfig +{ + public static final List RUNE_POUCHES = Arrays.asList( + ItemID.BH_RUNE_POUCH, ItemID.BH_RUNE_POUCH_TROUVER, + ItemID.DIVINE_RUNE_POUCH, ItemID.DIVINE_RUNE_POUCH_TROUVER + ); + public static final int[] RUNE_POUCH_RUNE_VARBITS = + { + VarbitID.RUNE_POUCH_TYPE_1, VarbitID.RUNE_POUCH_TYPE_2, VarbitID.RUNE_POUCH_TYPE_3, VarbitID.RUNE_POUCH_TYPE_4, + VarbitID.RUNE_POUCH_TYPE_5, VarbitID.RUNE_POUCH_TYPE_6 + }; + public static final int[] RUNE_POUCH_AMOUNT_VARBITS = + { + VarbitID.RUNE_POUCH_QUANTITY_1, VarbitID.RUNE_POUCH_QUANTITY_2, VarbitID.RUNE_POUCH_QUANTITY_3, VarbitID.RUNE_POUCH_QUANTITY_4, + VarbitID.RUNE_POUCH_QUANTITY_5, VarbitID.RUNE_POUCH_QUANTITY_6 + }; + public static final Set CURRENCIES = Set.of( + ItemID.COINS, ItemID.VILLAGE_TRADE_STICKS, ItemID.ECTOTOKEN, ItemID.WARGUILD_TOKENS); + private static final TransportItems DRAMEN_STAFF = new TransportItems( + new int[][]{null}, + new int[][]{ItemVariations.DRAMEN_STAFF.getIds()}, + new int[][]{null}, + new int[]{1}); + + private final SplitFlagMap mapData; + private final ThreadLocal map; + /** + * All transports by origin. The WorldPointUtil.UNDEFINED key is used for transports centered on the player. + */ + // Flat list of every loaded transport. refreshTransports only ever iterates these (the origin + // is re-derived from each transport), so the per-origin Set/HashMap/Integer-key map the loader + // produces is flattened here and not retained (issue #491). + private final Transport[] allTransports; + private final Map> allDestinations; + private final Map> filteredDestinations; + /** + * Per packed tile; only bank.tsv rows with Skills/Quests/Varbits/VarPlayers. + */ + private final Map bankRequirements; + private final Map itemsAndQuantities = new HashMap<>(28 + 11 + 500); + private final List filteredTargets = new ArrayList<>(4); + private final Client client; + private final ShortestPathConfig config; + // Centralized transport type enable/disable config + private final TransportTypeConfig transportTypeConfig; + private final int[] boostedSkillLevelsAndMore = new int[Skill.values().length + 3]; + private final Map questStates = new HashMap<>(); + private final Map varbitValues = new HashMap<>(); + private final Map varPlayerValues = new HashMap<>(); + @Getter + private final LeagueModeState leagueModeState = new LeagueModeState(); + public ItemContainer bank = null; + public Set availableSpiritTrees = null; + /** + * Bank tiles the player may use for path banking state (requirements satisfied). Rebuilt in {@link #refresh()}. + */ + private Set accessibleBankTiles = Set.of(); + /** + * Which transports are available for the current user configuration in the + * unbanked/banked state. + * - transportAvailabilityWithoutBank answers the question, which transport can a player take right now? + * - transportAvailabilityWithBank answers the question, which transports can a player take if they visit a bank? + */ + private TransportAvailability transportAvailabilityWithoutBank; + private TransportAvailability transportAvailabilityWithBank; + /** + * Reference that points to either allDestinations or filteredDestinations + */ + private Map> destinations; + @Getter + private long calculationCutoffMillis; + @Getter + private boolean avoidWilderness; + // POH-specific settings (not tied to a single TransportType) + private boolean usePohFairyRing, + usePohSpiritTree, + usePohMountedItems, + usePoh, + usePohObelisk, + includeBankPath; + private JewelleryBoxTier pohJewelleryBoxTier; + private int costConsumableTeleportationItems; + private int currencyThreshold; + @Getter + private boolean isOnSailingBoat; + + public PathfinderConfig(Client client, ShortestPathConfig config) + { + this.client = client; + this.config = config; + this.transportTypeConfig = new TransportTypeConfig(config); + this.mapData = SplitFlagMap.fromResources(); + this.map = ThreadLocal.withInitial(() -> new CollisionMap(mapData)); + Map> loadedTransports = TransportLoader.loadAllFromResources(); + remapPohDestinations(loadedTransports); + this.allTransports = flatten(loadedTransports); + this.transportAvailabilityWithoutBank = new TransportAvailability.Builder(allTransports.length).build(); + this.transportAvailabilityWithBank = new TransportAvailability.Builder(allTransports.length).build(); + this.allDestinations = Destination.loadAllFromResources(); + this.filteredDestinations = filterDestinations(allDestinations); + this.destinations = allDestinations; + this.bankRequirements = Destination.loadBankRequirementsFromResources(); + } + + protected PathfinderConfig(Client client, ShortestPathConfig config, + SplitFlagMap mapData, Map> allTransports, + Map> allDestinations, Map> filteredDestinations, + Map bankRequirements) + { + this.client = client; + this.config = config; + this.transportTypeConfig = new TransportTypeConfig(config); + this.mapData = mapData; + this.map = ThreadLocal.withInitial(() -> new CollisionMap(this.mapData)); + this.allTransports = flatten(allTransports); + this.transportAvailabilityWithoutBank = new TransportAvailability.Builder(this.allTransports.length).build(); + this.transportAvailabilityWithBank = new TransportAvailability.Builder(this.allTransports.length).build(); + this.allDestinations = allDestinations; + this.filteredDestinations = filteredDestinations; + this.destinations = allDestinations; + this.bankRequirements = bankRequirements; + } + + /** + * Pure combat-level formula, extracted for testability. + */ + static int computeCombatLevel(int attack, int strength, int defence, int hitpoints, int magic, int ranged, int prayer) + { + // Integer division is intentional here — it matches the OSRS floor(x/2) steps in the formula. + double base = 0.25 * (defence + hitpoints + Math.floorDiv(prayer, 2)); + double melee = (13 * (attack + strength)) / 40.0; + double range = (13 * (3 * Math.floorDiv(ranged, 2))) / 40.0; + double mage = (13 * (3 * Math.floorDiv(magic, 2))) / 40.0; + return (int) Math.floor(base + Math.max(Math.max(melee, range), Math.max(melee, mage))); + } + + static String getPlantedSpiritTreeName(int x, int y) + { + if (x >= 3058 && x <= 3062 && y >= 3256 && y <= 3260) + { + return "Port Sarim"; + } + if (x >= 2611 && x <= 2615 && y >= 3855 && y <= 3860) + { + return "Etceteria"; + } + if (x >= 2800 && x <= 2804 && y >= 3201 && y <= 3205) + { + return "Brimhaven"; + } + if (x >= 1691 && x <= 1695 && y >= 3540 && y <= 3544) + { + return "Hosidius"; + } + if (x >= 1251 && x <= 1255 && y >= 3748 && y <= 3752) + { + return "Farming Guild"; + } + return null; + } + + public CollisionMap getMap() + { + return map.get(); + } + + /** + * WARNING: This method collapses the banked/unbanked transport distinction into a single view. + *

+ * It exists only for legacy display-oriented callers such as overlays which want a coarse + * "currently relevant" set of transports to render. It must not be used for path-state-sensitive + * logic, because transport availability now depends on whether a path has visited a bank. + *

+ * Use {@link #getTransportAvailability(boolean)}, {@link #getTransportsPacked(boolean)}, or + * {@link #getUsableTeleports(boolean)} for pathfinding and path analysis code. + */ + public PrimitiveIntHashMap getTransports() + { + return getTransportAvailability(includeBankPath).getDisplayTransports(); + } + + public PrimitiveIntHashMap getTransportsPacked(boolean bankVisited) + { + return getTransportAvailability(bankVisited).getTransportsPacked(); + } + + public Transport[] getUsableTeleports(boolean bankVisited) + { + return getTransportAvailability(bankVisited).getUsableTeleports(); + } + + public TransportAvailability getTransportAvailability(boolean bankVisited) + { + return bankVisited ? transportAvailabilityWithBank : transportAvailabilityWithoutBank; + } + + public boolean isBankPathEnabled() + { + return includeBankPath; + } + + public boolean hasDestination(String destinationType) + { + return destinations.containsKey(destinationType); + } + + public Set getDestinations(String destinationType) + { + return destinations.get(destinationType); + } + + /** + * Whether standing on this tile may flip the path into {@code bankVisited} (inventory-from-bank) state. + */ + public boolean bankAccessible(int packedPosition) + { + return accessibleBankTiles.contains(packedPosition); + } + + public void refresh() + { + calculationCutoffMillis = (long) config.calculationCutoff() * Constants.GAME_TICK_LENGTH; + avoidWilderness = ShortestPathPlugin.override("avoidWilderness", config.avoidWilderness()); + usePoh = ShortestPathPlugin.override("usePoh", config.usePoh()); + leagueModeState.refresh(client); + + // Refresh transport type enabled states + transportTypeConfig.refresh(); + // POH-specific settings + usePohFairyRing = ShortestPathPlugin.override("usePohFairyRing", config.usePohFairyRing()); + usePohSpiritTree = ShortestPathPlugin.override("usePohSpiritTree", config.usePohSpiritTree()); + usePohMountedItems = ShortestPathPlugin.override("usePohMountedItems", config.usePohMountedItems()); + usePohObelisk = ShortestPathPlugin.override("usePohObelisk", config.usePohObelisk()); + pohJewelleryBoxTier = ShortestPathPlugin.override("pohJewelleryBoxTier", config.pohJewelleryBoxTier()); + + // Other settings (useTeleportationItems is now managed by transportTypeConfig) + currencyThreshold = ShortestPathPlugin.override("currencyThreshold", config.currencyThreshold()); + includeBankPath = ShortestPathPlugin.override("includeBankPath", config.includeBankPath()); + + // Note: Transport type costs are now managed by transportTypeConfig.getCost() + costConsumableTeleportationItems = ShortestPathPlugin.override("costConsumableTeleportationItems", config.costConsumableTeleportationItems()); + + if (GameState.LOGGED_IN.equals(client.getGameState())) + { + isOnSailingBoat = client.getVarbitValue(VarbitID.SAILING_BOARDED_BOAT) != 0; + + int i = 0; + for (; i < Skill.values().length; i++) + { + boostedSkillLevelsAndMore[i] = client.getBoostedSkillLevel(Skill.values()[i]); + } + boostedSkillLevelsAndMore[i++] = client.getTotalLevel(); // skill total level + boostedSkillLevelsAndMore[i++] = getCombatLevel(); // combat level + boostedSkillLevelsAndMore[i] = client.getVarpValue(VarPlayerID.QP); // quest points + + refreshTransports(); + } + + refreshDestinations(); + rebuildAccessibleBankTiles(); + } + + private void refreshDestinations() + { + destinations = avoidWilderness ? filteredDestinations : allDestinations; + } + + private void rebuildAccessibleBankTiles() + { + Set bankLocs = destinations.get("bank"); + if (bankLocs == null) + { + accessibleBankTiles = Set.of(); + return; + } + if (!GameState.LOGGED_IN.equals(client.getGameState())) + { + accessibleBankTiles = Set.copyOf(bankLocs); + return; + } + Set acc = new HashSet<>(bankLocs.size()); + for (Integer p : bankLocs) + { + DestinationRequirements req = bankRequirements.getOrDefault(p, DestinationRequirements.EMPTY); + if (satisfiesBankDestinationRequirements(req)) + { + acc.add(p); + } + } + accessibleBankTiles = Collections.unmodifiableSet(acc); + } + + /** + * Quest/skill/var gates for bank tiles (not used for transport overlays). + */ + private boolean satisfiesBankDestinationRequirements(DestinationRequirements dr) + { + if (dr == null || dr.isEmpty()) + { + return true; + } + int[] requiredLevels = dr.getSkillLevels(); + for (int i = 0; i < boostedSkillLevelsAndMore.length; i++) + { + int need = i < requiredLevels.length ? requiredLevels[i] : 0; + if (boostedSkillLevelsAndMore[i] < need) + { + return false; + } + } + for (Quest quest : dr.getQuests()) + { + if (!QuestState.FINISHED.equals(getQuestState(quest))) + { + return false; + } + } + for (VarRequirement req : dr.getVarbits()) + { + if (!req.checkValue(client.getVarbitValue(req.getId()))) + { + return false; + } + } + for (VarRequirement req : dr.getVarPlayers()) + { + if (!req.checkValue(client.getVarpValue(req.getId()))) + { + return false; + } + } + return true; + } + + /** + * Changes to the config might have invalidated some locations, e.g. those in the wilderness + */ + public void filterLocations(Set locations, boolean canReviveFiltered) + { + if (avoidWilderness) + { + locations.removeIf(location -> + { + boolean inWilderness = WildernessChecker.isInWilderness(location); + if (inWilderness) + { + filteredTargets.add(location); + } + return inWilderness; + }); + // If we ended up with no valid locations we re-include the filtered locations + if (locations.isEmpty()) + { + locations.addAll(filteredTargets); + filteredTargets.clear(); + } + } + else if (canReviveFiltered) + { // Re-include previously filtered locations + locations.addAll(filteredTargets); + filteredTargets.clear(); + } + } + + /** + * Returns the user-configured additional cost for a given transport + */ + public int getAdditionalTransportCost(Transport transport) + { + if (transport.isConsumable() && TransportType.TELEPORTATION_ITEM.equals(transport.getType())) + { + return costConsumableTeleportationItems; + } + if (transport.isConsumable() && TransportType.QUETZAL_WHISTLE.equals(transport.getType())) + { + return transportTypeConfig.getCost(transport.getType()) + costConsumableTeleportationItems; + } + return transportTypeConfig.getCost(transport.getType()); + } + + /** Adapter hook for immutable per-search walking penalties; upstream behavior defaults to zero. */ + public int getAdditionalWalkingCost(int packedDestination) + { + return 0; + } + + /** + * Returns the differential cost for a transport type that shares destinations with another type. + * This cost is only applied when the transport is in delayed-visit competition with its partner, + * not globally against all other transport types. + */ + public int getDifferentialCost(Transport transport) + { + if (transport.getType().differentialCostFunction() != null) + { + return transport.getType().differentialCostFunction().apply(config); + } + return 0; + } + + static Map> filterDestinations(Map> allDestinations) + { + Map> filteredDestinations = new HashMap<>(allDestinations.size()); + for (Map.Entry> entry : allDestinations.entrySet()) + { + String destinationType = entry.getKey(); + Set usableDestinations = new HashSet<>(entry.getValue().size()); + for (Integer destination : entry.getValue()) + { + // We filter based on whether the destination is inside or outside wilderness + if (!WildernessChecker.isInWilderness(destination)) + { + usableDestinations.add(destination); + } + } + // If all destinations of a destination type have been filtered away then we don't add the entry + if (!usableDestinations.isEmpty()) + { + // If no destinations of a destination type have been filtered away then we re-use the same set reference + filteredDestinations.put(destinationType, usableDestinations); + } + } + return filteredDestinations; + } + + private void refreshTransports() + { + if (!Thread.currentThread().equals(client.getClientThread())) + { + return; // Has to run on the client thread; data will be refreshed when path finding commences + } + + // Fairy ring staff/diary requirements are enforced later in hasRequiredItems(). + transportTypeConfig.disableUnless(TransportType.FAIRY_RING, + client.getVarbitValue(VarbitID.FAIRY2_QUEENCURE_QUEST) > 39); + transportTypeConfig.disableUnless(TransportType.GNOME_GLIDER, + QuestState.FINISHED.equals(getQuestState(Quest.THE_GRAND_TREE))); + transportTypeConfig.disableUnless(TransportType.MAGIC_MUSHTREE, + QuestState.FINISHED.equals(getQuestState(Quest.BONE_VOYAGE))); + transportTypeConfig.disableUnless(TransportType.SPIRIT_TREE, + QuestState.FINISHED.equals(getQuestState(Quest.TREE_GNOME_VILLAGE))); + + TransportAvailability.Builder withoutBank = new TransportAvailability.Builder(allTransports.length); + TransportAvailability.Builder withBank = new TransportAvailability.Builder(allTransports.length); + for (Transport transport : allTransports) + { + for (Quest quest : transport.getQuests()) + { + try + { + questStates.put(quest, getQuestState(quest)); + } + catch (NullPointerException ignored) + { + } + } + + for (VarRequirement varRequirement : transport.getVarRequirements()) + { + if (varRequirement.isVarbit()) + { + varbitValues.put(varRequirement.getId(), client.getVarbitValue(varRequirement.getId())); + } + else + { + varPlayerValues.put(varRequirement.getId(), client.getVarpValue(varRequirement.getId())); + } + } + + if (!useTransport(transport)) + { + continue; + } + + boolean usableWithoutBank = hasRequiredItems(transport, true, true, false, true); + boolean usableWithBank = hasRequiredItems(transport, true, true, includeBankPath, true); + if (usableWithoutBank) + { + withoutBank.add(transport); + } + if (usableWithBank) + { + withBank.add(transport); + } + } + + withoutBank.remapPohTransports(); + withBank.remapPohTransports(); + transportAvailabilityWithoutBank = withoutBank.build(); + transportAvailabilityWithBank = withBank.build(); + } + + public boolean avoidWilderness(int packedPosition, int packedNeighborPosition, boolean targetInWilderness) + { + return avoidWilderness + && !targetInWilderness + && !WildernessChecker.isInWilderness(packedPosition) + && WildernessChecker.isInWilderness(packedNeighborPosition); + } + + /** + * League-mode neighbour gate: parallels {@link #avoidWilderness} but + * blocks crossing into the always-blocked Misthalin region. Always + * returns {@code false} on non-seasonal worlds so vanilla pathfinding is + * unaffected. + */ + public boolean avoidBlockedRegion(int packedPosition, int packedNeighborPosition, boolean targetInBlockedRegion) + { + if (!leagueModeState.isSeasonal()) + { + return false; + } + return !targetInBlockedRegion + && !leagueModeState.isInBlockedRegion(packedPosition) + && leagueModeState.isInBlockedRegion(packedNeighborPosition); + } + + /** + * Whether both endpoints of the supplied transport are in unlocked + * regions for the current league state. Always-unlocked tiles + * (NEUTRAL, Varlamore, Karamja) pass through unchanged on any world. + * + *

If the transport declares a {@link Transport#getRegionOverride() + * region override}, it replaces the chunk-classifier result for the + * destination endpoint. Used for shortcuts whose destination chunk + * sits in a different region than the wiki classifies the shortcut + * under (e.g. Trollheim Wilderness climb — destination chunk is + * Wilderness, but the shortcut is wiki-listed as Asgarnia). + */ + private boolean isTransportRegionAllowed(Transport transport) + { + if (!leagueModeState.isSeasonal()) + { + return true; + } + LeagueRegion origin = LeagueRegionChecker.getRegion(transport.getOrigin()); + if (!leagueModeState.isUnlocked(origin)) + { + return false; + } + LeagueRegion destination = transport.getRegionOverride() != null + ? transport.getRegionOverride() + : LeagueRegionChecker.getRegion(transport.getDestination()); + return leagueModeState.isUnlocked(destination); + } + + /** + * Remaps POH transport destinations to the house landing tile. + * Transports that arrive inside the POH (e.g., fairy ring DIQ, spirit tree "Your house") + * are remapped so chaining with other POH transports is possible. + * Called once at load time since Transport objects in allTransports are shared references. + */ + private static Transport[] flatten(Map> transports) + { + List all = new ArrayList<>(); + for (Set set : transports.values()) + { + all.addAll(set); + } + return all.toArray(new Transport[0]); + } + + static void remapPohDestinations(Map> transports) + { + int pohLanding = WorldPointUtil.packWorldPoint(1923, 5709, 0); + for (Set transportSet : transports.values()) + { + for (Transport transport : transportSet) + { + int destination = transport.getDestination(); + int destX = WorldPointUtil.unpackWorldX(destination); + int destY = WorldPointUtil.unpackWorldY(destination); + if (destination != pohLanding && ShortestPathPlugin.isInsidePoh(destX, destY)) + { + transport.setDestination(pohLanding); + } + } + } + } + + public QuestState getQuestState(Quest quest) + { + return quest.getState(client); + } + + private boolean completedQuests(Transport transport) + { + for (Quest quest : transport.getQuests()) + { + if (!QuestState.FINISHED.equals(questStates.getOrDefault(quest, QuestState.NOT_STARTED))) + { + return false; + } + } + return true; + } + + public boolean varbitChecks(Transport transport) + { + for (VarRequirement varRequirement : transport.getVarbits()) + { + if (!varRequirement.check(varbitValues)) + { + return true; + } + } + return false; + } + + public boolean varPlayerChecks(Transport transport) + { + for (VarRequirement varRequirement : transport.getVarPlayers()) + { + if (!varRequirement.check(varPlayerValues)) + { + return true; + } + } + return false; + } + + private boolean useTransport(Transport transport) + { + // Sailing: suppress teleports while the player is aboard a boat. + // We don't model sailing navigation, so teleporting away mid-ocean would produce + // confusing suggestions. Pathfinding resumes normally after disembarking. + if (isOnSailingBoat && transport.getType().isTeleport()) + { + return false; + } + + // Master POH gate - if POH is disabled, reject all POH transports + if (!usePoh) + { + int originX = WorldPointUtil.unpackWorldX(transport.getOrigin()); + int originY = WorldPointUtil.unpackWorldY(transport.getOrigin()); + int destX = WorldPointUtil.unpackWorldX(transport.getDestination()); + int destY = WorldPointUtil.unpackWorldY(transport.getDestination()); + if (ShortestPathPlugin.isInsidePoh(originX, originY) || ShortestPathPlugin.isInsidePoh(destX, destY)) + { + return false; + } + } + + // League region gate: in seasonal mode, drop transports that touch the + // always-blocked region or a region the player has not unlocked. + if (!isTransportRegionAllowed(transport)) + { + return false; + } + + final boolean isQuestLocked = transport.isQuestLocked(); + TransportType type = transport.getType(); + + // Check if transport type is enabled in config + if (!transportTypeConfig.isEnabled(type)) + { + return false; + } + + // Handle POH variants for types that have them + if (!checkPohVariant(transport, type)) + { + return false; + } + + // Handle special cases for teleportation items and seasonal transports + if (!checkTeleportationItemRules(transport, type)) + { + return false; + } + + // Handle jewellery box tier filtering + if (TransportType.TELEPORTATION_BOX.equals(type)) + { + if (!checkJewelleryBoxTier(transport)) + { + return false; + } + } + + if (!hasRequiredLevels(transport)) + { + return false; + } + + if (isQuestLocked && !completedQuests(transport)) + { + return false; + } + + if (varbitChecks(transport)) + { + return false; + } + + if (varPlayerChecks(transport)) + { + return false; + } + + if (TransportType.SPIRIT_TREE.equals(type) || TransportType.SEASONAL_TRANSPORTS.equals(type)) + { + return checkPlantedSpiritTrees(transport); + } + + return true; + } + + private boolean checkPlantedSpiritTrees(Transport transport) + { + int originX = WorldPointUtil.unpackWorldX(transport.getOrigin()); + int originY = WorldPointUtil.unpackWorldY(transport.getOrigin()); + + // Check planted spirit tree origins (travel FROM a planted tree) + if (isPlantedSpiritTreeAllowed(originX, originY)) + { + return false; + } + + // Check planted spirit tree destinations (travel TO a planted tree) + int destX = WorldPointUtil.unpackWorldX(transport.getDestination()); + int destY = WorldPointUtil.unpackWorldY(transport.getDestination()); + + return !isPlantedSpiritTreeAllowed(destX, destY); + } + + /** + * Checks POH-specific transport variants (fairy ring, spirit tree, obelisk inside POH). + * Returns false if the transport is a POH variant and that variant is disabled. + */ + private boolean checkPohVariant(Transport transport, TransportType type) + { + int originX = WorldPointUtil.unpackWorldX(transport.getOrigin()); + int originY = WorldPointUtil.unpackWorldY(transport.getOrigin()); + int destX = WorldPointUtil.unpackWorldX(transport.getDestination()); + int destY = WorldPointUtil.unpackWorldY(transport.getDestination()); + + if (!ShortestPathPlugin.isInsidePoh(originX, originY) && !ShortestPathPlugin.isInsidePoh(destX, destY)) + { + return true; // Not a POH transport + } + + // POH fairy ring + if (TransportType.FAIRY_RING.equals(type)) + { + return usePohFairyRing; + } + // POH spirit tree + if (TransportType.SPIRIT_TREE.equals(type)) + { + return usePohSpiritTree; + } + // POH obelisk + if (TransportType.WILDERNESS_OBELISK.equals(type)) + { + return usePohObelisk; + } + + return true; + } + + /** + * Checks teleportation item rules (consumable vs non-consumable, inventory settings). + * Returns false if the transport should be filtered out based on teleportation item settings. + */ + private boolean checkTeleportationItemRules(Transport transport, TransportType type) + { + if (!TransportType.TELEPORTATION_ITEM.equals(type) + && !TransportType.SEASONAL_TRANSPORTS.equals(type) + && !TransportType.QUETZAL_WHISTLE.equals(type)) + { + return true; // Not a teleportation item type + } + + switch (transportTypeConfig.getTeleportationItemSetting()) + { + case ALL: + return true; + case ALL_NON_CONSUMABLE: + case UNLOCKED_NON_CONSUMABLE: + case INVENTORY_NON_CONSUMABLE: + case INVENTORY_AND_BANK_NON_CONSUMABLE: + return !transport.isConsumable(); + case UNLOCKED: + case INVENTORY: + case INVENTORY_AND_BANK: + return true; // Will be checked later by hasRequiredItems + case NONE: + return false; + } + return true; + } + + /** + * Checks if a TELEPORTATION_BOX transport should be used based on POH settings. + * Handles jewellery box tiers and mounted items. + */ + private boolean checkJewelleryBoxTier(Transport transport) + { + String objectInfo = transport.getObjectInfo(); + if (objectInfo == null) + { + return false; + } + + // Check if this is a mounted item (glory, xeric's, digsite, mythical cape) + boolean isMountedGlory = objectInfo.contains("Amulet of Glory"); + boolean isMountedItem = isMountedGlory || + objectInfo.contains("Xeric's Talisman") || + objectInfo.contains("Digsite") || + objectInfo.contains("Mythical cape"); + + if (isMountedItem) + { + // If mounted glory and ornate jewellery box is enabled, skip the glory + // because the ornate box already covers all 4 destinations with correct prefixes + if (isMountedGlory && JewelleryBoxTier.ORNATE.equals(pohJewelleryBoxTier)) + { + return false; + } + return usePohMountedItems; + } + + // Filter jewellery boxes by tier + if (JewelleryBoxTier.NONE.equals(pohJewelleryBoxTier)) + { + return false; + } + + // Basic box (37492): destinations 1-9 + if (objectInfo.contains("Basic Jewellery Box 37492")) + { + return true; // All tiers include basic + } + + // Fancy box (37501): destinations A-J + if (objectInfo.contains("Fancy Jewellery Box 37501")) + { + return JewelleryBoxTier.FANCY.equals(pohJewelleryBoxTier) || + JewelleryBoxTier.ORNATE.equals(pohJewelleryBoxTier); + } + + // Ornate box (37520): destinations K-R + if (objectInfo.contains("Ornate Jewellery Box 37520")) + { + return JewelleryBoxTier.ORNATE.equals(pohJewelleryBoxTier); + } + + return false; + } + + /** + * Checks if the player has all the required skill levels for the transport + */ + private boolean hasRequiredLevels(Transport transport) + { + // In leagues some skills are disabled so the max total level is lower than + // the standard 2376. Holding the item (e.g. Max cape) already proves the + // player is maxed for the available skills, so skip the total-level check. + final int totalLevelIndex = Skill.values().length; + int[] requiredLevels = transport.getSkillLevels(); + for (int i = 0; i < boostedSkillLevelsAndMore.length; i++) + { + if (leagueModeState.isSeasonal() && i == totalLevelIndex) + { + continue; + } + int boostedLevel = boostedSkillLevelsAndMore[i]; + int requiredLevel = requiredLevels[i]; + if (boostedLevel < requiredLevel) + { + return false; + } + } + return true; + } + + /** + * Checks if the player has all the required equipment and inventory items for the transport + */ + private boolean hasRequiredItems( + Transport transport, + boolean checkInventory, + boolean checkEquipment, + boolean checkBank, + boolean checkRunePouch) + { + if (TransportType.TELEPORTATION_ITEM.equals(transport.getType()) || + TransportType.SEASONAL_TRANSPORTS.equals(transport.getType()) || + TransportType.QUETZAL_WHISTLE.equals(transport.getType())) + { + switch (transportTypeConfig.getTeleportationItemSetting()) + { + case ALL: + case ALL_NON_CONSUMABLE: + case UNLOCKED: + case UNLOCKED_NON_CONSUMABLE: + return true; + case NONE: + return false; + default: + break; + } + } + + // Fairy rings require Dramen/Lunar staff unless Lumbridge Elite diary is complete + if (TransportType.FAIRY_RING.equals(transport.getType())) + { + int lumbridgeDiaryComplete = varbitValues.getOrDefault(VarbitID.LUMBRIDGE_DIARY_ELITE_COMPLETE, 0); + if (lumbridgeDiaryComplete != 1) + { + if (!hasRequiredItems(DRAMEN_STAFF, checkInventory, checkEquipment, checkBank, checkRunePouch)) + { + return false; + } + } + } + + return hasRequiredItems(transport.getItemRequirements(), + checkInventory, checkEquipment, checkBank, checkRunePouch); + } + + /** + * Checks if the player has all the required equipment and inventory items for the transport + */ + private boolean hasRequiredItems( + TransportItems transportItems, + boolean checkInventory, + boolean checkEquipment, + boolean checkBank, + boolean checkRunePouch) + { + if (transportItems == null) + { + return true; + } + itemsAndQuantities.clear(); + + if (checkInventory) + { + ItemContainer inventory = client.getItemContainer(InventoryID.INV); + if (inventory != null) + { + for (Item item : inventory.getItems()) + { + if (item.getId() >= 0 && item.getQuantity() > 0) + { + itemsAndQuantities.put(item.getId(), item.getQuantity()); + } + } + } + } + + if (checkEquipment) + { + ItemContainer equipment = client.getItemContainer(InventoryID.WORN); + if (equipment != null) + { + for (Item item : equipment.getItems()) + { + if (item.getId() >= 0 && item.getQuantity() > 0) + { + itemsAndQuantities.put(item.getId(), item.getQuantity()); + } + } + } + } + + if (checkBank) + { + TeleportationItem teleportSetting = transportTypeConfig.getTeleportationItemSetting(); + if (bank != null + && (TeleportationItem.INVENTORY_AND_BANK.equals(teleportSetting) + || TeleportationItem.INVENTORY_AND_BANK_NON_CONSUMABLE.equals(teleportSetting))) + { + for (Item item : bank.getItems()) + { + if (item.getId() >= 0 && item.getQuantity() > 0) + { + itemsAndQuantities.put(item.getId(), item.getQuantity()); + } + } + } + } + + if (checkRunePouch) + { + if (RUNE_POUCHES.stream().anyMatch(itemsAndQuantities::containsKey)) + { + EnumComposition runePouchEnum = client.getEnum(EnumID.RUNEPOUCH_RUNE); + for (int i = 0; i < RUNE_POUCH_RUNE_VARBITS.length; i++) + { + int runeEnumId = client.getVarbitValue(RUNE_POUCH_RUNE_VARBITS[i]); + int runeId = runeEnumId > 0 ? runePouchEnum.getIntValue(runeEnumId) : 0; + int runeAmount = client.getVarbitValue(RUNE_POUCH_AMOUNT_VARBITS[i]); + if (runeId > 0 && runeAmount > 0) + { + itemsAndQuantities.put(runeId, runeAmount); + } + } + } + } + + boolean usingStaff = false; + boolean usingOffhand = false; + for (ItemRequirement req : transportItems.getRequirements()) + { + boolean missing = true; + int requiredQuantity = req.getQuantity(); + if (req.getItemIds() != null) + { + for (int itemId : req.getItemIds()) + { + int quantity = itemsAndQuantities.getOrDefault(itemId, 0); + if (requiredQuantity > 0 && quantity >= requiredQuantity || requiredQuantity == 0 && quantity == 0) + { + if (CURRENCIES.contains(itemId) && requiredQuantity > currencyThreshold) + { + return false; + } + missing = false; + break; + } + } + } + if (missing && !usingStaff && req.getStaffIds() != null) + { + for (int itemId : req.getStaffIds()) + { + int quantity = itemsAndQuantities.getOrDefault(itemId, 0); + if (requiredQuantity > 0 && quantity >= 1 || requiredQuantity == 0 && quantity == 0) + { + usingStaff = true; + missing = false; + break; + } + } + } + if (missing && !usingOffhand && req.getOffhandIds() != null) + { + for (int itemId : req.getOffhandIds()) + { + int quantity = itemsAndQuantities.getOrDefault(itemId, 0); + if (requiredQuantity > 0 && quantity >= 1 || requiredQuantity == 0 && quantity == 0) + { + usingOffhand = true; + missing = false; + break; + } + } + } + if (missing) + { + return false; + } + } + return true; + } + + /** + * Calculates the combat level of the player + */ + private int getCombatLevel() + { + int attack = client.getRealSkillLevel(Skill.ATTACK); + int strength = client.getRealSkillLevel(Skill.STRENGTH); + int defence = client.getRealSkillLevel(Skill.DEFENCE); + int hitpoints = client.getRealSkillLevel(Skill.HITPOINTS); + int magic = client.getRealSkillLevel(Skill.MAGIC); + int ranged = client.getRealSkillLevel(Skill.RANGED); + int prayer = client.getRealSkillLevel(Skill.PRAYER); + return computeCombatLevel(attack, strength, defence, hitpoints, magic, ranged, prayer); + } + + private boolean isPlantedSpiritTreeAllowed(int x, int y) + { + String treeName = getPlantedSpiritTreeName(x, y); + if (treeName == null) + { + return false; // + } + if (availableSpiritTrees == null) + { + return true; + } + return !availableSpiritTrees.contains(treeName); + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/PathfinderResult.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/PathfinderResult.java new file mode 100644 index 00000000000..42a9cfae611 --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/PathfinderResult.java @@ -0,0 +1,40 @@ +package shortestpath.pathfinder; + +import java.util.List; +import lombok.Getter; + +@Getter +public class PathfinderResult +{ + private final int start; + private final int target; + private final boolean reached; + private final List pathSteps; + private final int closestReachedPoint; + private final int nodesChecked; + private final int transportsChecked; + private final long elapsedNanos; + private final PathTerminationReason terminationReason; + + public PathfinderResult( + int start, + int target, + boolean reached, + List pathSteps, + int closestReachedPoint, + int nodesChecked, + int transportsChecked, + long elapsedNanos, + PathTerminationReason terminationReason) + { + this.start = start; + this.target = target; + this.reached = reached; + this.pathSteps = pathSteps; + this.closestReachedPoint = closestReachedPoint; + this.nodesChecked = nodesChecked; + this.transportsChecked = transportsChecked; + this.elapsedNanos = elapsedNanos; + this.terminationReason = terminationReason; + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/SplitFlagMap.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/SplitFlagMap.java new file mode 100644 index 00000000000..0e01f23b951 --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/SplitFlagMap.java @@ -0,0 +1,171 @@ +package shortestpath.pathfinder; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Arrays; +import java.util.BitSet; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import static net.runelite.api.Constants.REGION_SIZE; + +import shortestpath.ShortestPathPlugin; +import shortestpath.Util; + +public class SplitFlagMap +{ + private static final int FLAG_COUNT = 2; + private static final int BITS_PER_PLANE = REGION_SIZE * REGION_SIZE * FLAG_COUNT; + private static final int WORDS_PER_PLANE = BITS_PER_PLANE / Long.SIZE; + private static final int REGION_MASK = REGION_SIZE - 1; + + @Getter + private static RegionExtent regionExtents; + + private final byte[] regionMapPlaneCounts; + // Every region's collision bits are packed into one shared word array instead of a separate + // FlagMap + BitSet + long[] per region. regionWordOffset gives each region's start word, or -1 + // when the region has no collision data (issue #491). + private final long[] flags; + private final int[] regionWordOffset; + private final int widthInclusive; + + public SplitFlagMap(Map compressedRegions) + { + widthInclusive = regionExtents.getWidth() + 1; + final int heightInclusive = regionExtents.getHeight() + 1; + final int regionCount = widthInclusive * heightInclusive; + regionMapPlaneCounts = new byte[regionCount]; + regionWordOffset = new int[regionCount]; + Arrays.fill(regionWordOffset, -1); + + // First pass: decode each region and reserve it a slice of the shared word array. + final Map regionWords = new HashMap<>(compressedRegions.size()); + int totalWords = 0; + for (Map.Entry entry : compressedRegions.entrySet()) + { + final int pos = entry.getKey(); + final int index = getIndex(unpackX(pos), unpackY(pos)); + final BitSet bits = BitSet.valueOf(entry.getValue()); + // Same plane-count derivation the old FlagMap used. + final int planeCount = (bits.size() + BITS_PER_PLANE - 1) / BITS_PER_PLANE; + regionMapPlaneCounts[index] = (byte) planeCount; + regionWordOffset[index] = totalWords; + regionWords.put(index, bits.toLongArray()); + totalWords += planeCount * WORDS_PER_PLANE; + } + + // Second pass: copy each region's words into its reserved slice (trailing zero words from + // BitSet.toLongArray are left as the zero-filled remainder of the slice). + flags = new long[totalWords]; + for (Map.Entry entry : regionWords.entrySet()) + { + final long[] words = entry.getValue(); + System.arraycopy(words, 0, flags, regionWordOffset[entry.getKey()], words.length); + } + } + + public static int unpackX(int position) + { + return position & 0xFFFF; + } + + public static int unpackY(int position) + { + return (position >> 16) & 0xFFFF; + } + + public static int packPosition(int x, int y) + { + return (x & 0xFFFF) | ((y & 0xFFFF) << 16); + } + + public static SplitFlagMap fromResources() + { + Map compressedRegions = new HashMap<>(); + try (ZipInputStream in = new ZipInputStream(Objects.requireNonNull(ShortestPathPlugin.class.getResourceAsStream("/collision-map.zip")))) + { + int minX = Integer.MAX_VALUE; + int minY = Integer.MAX_VALUE; + int maxX = 0; + int maxY = 0; + + ZipEntry entry; + while ((entry = in.getNextEntry()) != null) + { + String[] n = entry.getName().split("_"); + final int x = Integer.parseInt(n[0]); + final int y = Integer.parseInt(n[1]); + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + + compressedRegions.put(SplitFlagMap.packPosition(x, y), Util.readAllBytes(in)); + } + + regionExtents = new RegionExtent(minX, minY, maxX, maxY); + } + catch (IOException e) + { + throw new UncheckedIOException(e); + } + + return new SplitFlagMap(compressedRegions); + } + + public byte getRegionPlaneCounts(int index) + { + return regionMapPlaneCounts[index]; + } + + public boolean get(int x, int y, int z, int flag) + { + final int index = getIndex(x / REGION_SIZE, y / REGION_SIZE); + if (index < 0 || index >= regionWordOffset.length) + { + return false; + } + + final int wordOffset = regionWordOffset[index]; + if (wordOffset < 0 || z < 0 || z >= regionMapPlaneCounts[index]) + { + return false; + } + + // SplitFlagMap routes (x, y) to the region that contains it, so the in-region coordinates + // are simply the low REGION_SIZE bits; this matches the old FlagMap.index arithmetic. + final int localBit = (z * REGION_SIZE * REGION_SIZE + + (y & REGION_MASK) * REGION_SIZE + + (x & REGION_MASK)) * FLAG_COUNT + flag; + return (flags[wordOffset + (localBit >> 6)] >>> (localBit & 63) & 1L) != 0L; + } + + private int getIndex(int regionX, int regionY) + { + return (regionX - regionExtents.getMinX()) + (regionY - regionExtents.getMinY()) * widthInclusive; + } + + @RequiredArgsConstructor + @Getter + public static class RegionExtent + { + public final int minX, minY, maxX, maxY; + + public int getWidth() + { + return maxX - minX; + } + + public int getHeight() + { + return maxY - minY; + } + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/TransportAvailability.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/TransportAvailability.java new file mode 100644 index 00000000000..f147e7f8218 --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/TransportAvailability.java @@ -0,0 +1,130 @@ +package shortestpath.pathfinder; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import shortestpath.PrimitiveIntHashMap; +import shortestpath.WorldPointUtil; +import shortestpath.transport.Transport; + +public final class TransportAvailability +{ + public static final Transport[] EMPTY_TRANSPORTS = new Transport[0]; + + // Transports grouped by origin tile, stored as flat arrays. The per-origin HashSet/HashMap + // wrappers used while building are not retained (issue #491). + // + // transportsPacked is the pathfinding view: a transport is reachable from its literal origin + // tile, and POH transports are additionally reachable from the canonical landing tile. + // displayTransports is the coarse display view used by overlays and getTransports(): POH origin + // tiles are collapsed into the landing tile only. The two maps share their Transport[] arrays + // for every non-POH origin. + private final PrimitiveIntHashMap transportsPacked; + private final PrimitiveIntHashMap displayTransports; + private final Transport[] usableTeleports; + + TransportAvailability( + PrimitiveIntHashMap transportsPacked, + PrimitiveIntHashMap displayTransports, + Transport[] usableTeleports) + { + this.transportsPacked = transportsPacked; + this.displayTransports = displayTransports; + this.usableTeleports = usableTeleports; + } + + public PrimitiveIntHashMap getTransportsPacked() + { + return transportsPacked; + } + + public PrimitiveIntHashMap getDisplayTransports() + { + return displayTransports; + } + + public Transport[] getUsableTeleports() + { + return usableTeleports; + } + + /** + * The transports that start at the given origin tile in the display view, or an empty array. + */ + public Transport[] getTransportsAt(int origin) + { + return displayTransports.getOrDefault(origin, EMPTY_TRANSPORTS); + } + + /* + * Build a TransportAvailability by incrementally adding available transports. + */ + public static final class Builder + { + // Temporary accumulation; converted to flat arrays in build() and not retained afterwards. + private final Map> transportsByOrigin; + private final Set usableTeleports; + private final Set pohOrigins = new HashSet<>(); + + public Builder(int expectedTransportCount) + { + this.transportsByOrigin = new HashMap<>(expectedTransportCount / 2); + this.usableTeleports = new HashSet<>(expectedTransportCount / 20); + } + + public void add(Transport transport) + { + if (transport.getOrigin() == WorldPointUtil.UNDEFINED) + { + usableTeleports.add(transport); + return; + } + + transportsByOrigin.computeIfAbsent(transport.getOrigin(), ignored -> new HashSet<>()).add(transport); + } + + void remapPohTransports() + { + int pohLanding = WorldPointUtil.packWorldPoint(1923, 5709, 0); + Set pohTransports = new HashSet<>(); + + for (Map.Entry> entry : transportsByOrigin.entrySet()) + { + int origin = entry.getKey(); + int originX = WorldPointUtil.unpackWorldX(origin); + int originY = WorldPointUtil.unpackWorldY(origin); + if (shortestpath.ShortestPathPlugin.isInsidePoh(originX, originY)) + { + pohTransports.addAll(entry.getValue()); + // Kept in the pathfinding view, collapsed out of the display view. + pohOrigins.add(origin); + } + } + + if (!pohTransports.isEmpty()) + { + transportsByOrigin.computeIfAbsent(pohLanding, ignored -> new HashSet<>()).addAll(pohTransports); + } + } + + public TransportAvailability build() + { + int expected = Math.max(1, transportsByOrigin.size()); + PrimitiveIntHashMap packed = new PrimitiveIntHashMap<>(expected); + PrimitiveIntHashMap display = new PrimitiveIntHashMap<>(expected); + for (Map.Entry> entry : transportsByOrigin.entrySet()) + { + int origin = entry.getKey(); + Transport[] transports = entry.getValue().toArray(EMPTY_TRANSPORTS); + packed.put(origin, transports); + if (!pohOrigins.contains(origin)) + { + display.put(origin, transports); + } + } + return new TransportAvailability(packed, display, usableTeleports.toArray(EMPTY_TRANSPORTS)); + } + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/VisitedTiles.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/VisitedTiles.java new file mode 100644 index 00000000000..8815c764b22 --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/VisitedTiles.java @@ -0,0 +1,193 @@ +package shortestpath.pathfinder; + +import static net.runelite.api.Constants.REGION_SIZE; + +import shortestpath.WorldPointUtil; + +public class VisitedTiles +{ + private final SplitFlagMap.RegionExtent regionExtents; + private final int widthInclusive; + + private final VisitedRegion[] visitedRegionsWithoutBank; + private final VisitedRegion[] visitedRegionsWithBank; + private final CollisionMap map; + // Abstract nodes are visited separately from tile nodes because they represent + // global search states, not map positions. + private final boolean[] abstractVisitedWithoutBank = new boolean[AbstractNodeKind.values().length]; + private final boolean[] abstractVisitedWithBank = new boolean[AbstractNodeKind.values().length]; + + public VisitedTiles(CollisionMap map) + { + this.map = map; + regionExtents = SplitFlagMap.getRegionExtents(); + widthInclusive = regionExtents.getWidth() + 1; + final int heightInclusive = regionExtents.getHeight() + 1; + + visitedRegionsWithoutBank = new VisitedRegion[widthInclusive * heightInclusive]; + visitedRegionsWithBank = new VisitedRegion[widthInclusive * heightInclusive]; + } + + public boolean get(int packedPoint, boolean bankVisited) + { + final int x = WorldPointUtil.unpackWorldX(packedPoint); + final int y = WorldPointUtil.unpackWorldY(packedPoint); + final int plane = WorldPointUtil.unpackWorldPlane(packedPoint); + return get(x, y, plane, bankVisited); + } + + public boolean get(int x, int y, int plane, boolean bankVisited) + { + VisitedRegion[] visitedRegions = bankVisited ? visitedRegionsWithBank : visitedRegionsWithoutBank; + final int regionIndex = getRegionIndex(x / REGION_SIZE, y / REGION_SIZE); + if (regionIndex < 0 || regionIndex >= visitedRegions.length) + { + return true; // Region is out of bounds; report that it's been visited to avoid exploring it + // further + } + + final VisitedRegion region = visitedRegions[regionIndex]; + if (region == null) + { + return false; + } + + return region.get(x % REGION_SIZE, y % REGION_SIZE, plane); + } + + public boolean set(int packedPoint, boolean bankVisited) + { + final int x = WorldPointUtil.unpackWorldX(packedPoint); + final int y = WorldPointUtil.unpackWorldY(packedPoint); + final int plane = WorldPointUtil.unpackWorldPlane(packedPoint); + return set(x, y, plane, bankVisited); + } + + public boolean get(int id, NodeGraph graph) + { + if (graph.isTile(id)) + { + return get(graph.packedPosition(id), graph.bankVisited(id)); + } + return getAbstract(graph.abstractKind(id), graph.bankVisited(id)); + } + + public boolean getAbstract(AbstractNodeKind abstractKind, boolean bankVisited) + { + return bankVisited + ? abstractVisitedWithBank[abstractKind.ordinal()] + : abstractVisitedWithoutBank[abstractKind.ordinal()]; + } + + public boolean set(int id, NodeGraph graph) + { + if (graph.isTile(id)) + { + return set(graph.packedPosition(id), graph.bankVisited(id)); + } + + final AbstractNodeKind abstractKind = graph.abstractKind(id); + boolean visited = getAbstract(abstractKind, graph.bankVisited(id)); + if (graph.bankVisited(id)) + { + abstractVisitedWithBank[abstractKind.ordinal()] = true; + // A banked abstract state dominates the equivalent unbanked state. + } + abstractVisitedWithoutBank[abstractKind.ordinal()] = true; + return !visited; + } + + public boolean set(int x, int y, int plane, boolean bankVisited) + { + final int regionIndex = getRegionIndex(x / REGION_SIZE, y / REGION_SIZE); + if (regionIndex < 0 || regionIndex >= visitedRegionsWithoutBank.length) + { + return false; // Region is out of bounds; report that it's been visited to avoid exploring it + // further + } + + if (bankVisited) + { + boolean unique = setInRegion(visitedRegionsWithBank, regionIndex, x, y, plane); + // A banked tile dominates the equivalent unbanked tile, so populate both + // buckets. + setInRegion(visitedRegionsWithoutBank, regionIndex, x, y, plane); + return unique; + } + + return setInRegion(visitedRegionsWithoutBank, regionIndex, x, y, plane); + } + + private boolean setInRegion(VisitedRegion[] visitedRegions, int regionIndex, int x, int y, int plane) + { + VisitedRegion region = visitedRegions[regionIndex]; + if (region == null) + { + region = new VisitedRegion(map.getRegionPlaneCounts(regionIndex)); + visitedRegions[regionIndex] = region; + } + return region.set(x % REGION_SIZE, y % REGION_SIZE, plane); + } + + public void clear() + { + for (int i = 0; i < visitedRegionsWithoutBank.length; ++i) + { + visitedRegionsWithoutBank[i] = null; + visitedRegionsWithBank[i] = null; + } + for (int i = 0; i < abstractVisitedWithoutBank.length; i++) + { + abstractVisitedWithoutBank[i] = false; + abstractVisitedWithBank[i] = false; + } + } + + private int getRegionIndex(int regionX, int regionY) + { + return (regionX - regionExtents.minX) + (regionY - regionExtents.minY) * widthInclusive; + } + + private static class VisitedRegion + { + // This assumes a row is at most 64 tiles and fits in a long + private final long[] planes; + private final byte planeCount; + + VisitedRegion(byte planeCount) + { + this.planeCount = planeCount; + this.planes = new long[planeCount * REGION_SIZE]; + } + + // Sets a tile as visited in the tile bitset + // Returns true if the tile is unique and hasn't been seen before or false if it + // was seen before + public boolean set(int x, int y, int plane) + { + if (plane >= planeCount) + { + // Plane is out of bounds; report that it has been visited to avoid further + // exploration + return false; + } + final int index = y + plane * REGION_SIZE; + boolean unique = (planes[index] & (1L << x)) == 0; + planes[index] |= 1L << x; + return unique; + } + + public boolean get(int x, int y, int plane) + { + if (plane >= planeCount) + { + // This check is necessary since we check visited tiles before checking the + // collision map, e.g. the node + // at (2816, 3455, 1) will check its neighbour to the north which is in a new + // region with no plane = 1 + return true; + } + return (planes[y + plane * REGION_SIZE] & (1L << x)) != 0; + } + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/WildernessChecker.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/WildernessChecker.java new file mode 100644 index 00000000000..a3386713b0e --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/pathfinder/WildernessChecker.java @@ -0,0 +1,68 @@ +package shortestpath.pathfinder; + +import java.util.Set; + +import net.runelite.api.coords.WorldArea; +import shortestpath.WorldPointUtil; + +public class WildernessChecker +{ + + private static final WorldArea WILDERNESS_ABOVE_GROUND = new WorldArea(2944, 3525, 448, 448, 0); + private static final WorldArea WILDERNESS_UNDERGROUND = new WorldArea(2944, 9918, 518, 458, 0); + + private static final WorldArea FEROX_ENCLAVE_1 = new WorldArea(3123, 3622, 2, 10, 0); + private static final WorldArea FEROX_ENCLAVE_2 = new WorldArea(3125, 3617, 16, 23, 0); + private static final WorldArea FEROX_ENCLAVE_3 = new WorldArea(3138, 3636, 18, 10, 0); + private static final WorldArea FEROX_ENCLAVE_4 = new WorldArea(3141, 3625, 14, 11, 0); + private static final WorldArea FEROX_ENCLAVE_5 = new WorldArea(3141, 3619, 7, 6, 0); + + private static final WorldArea NOT_WILDERNESS_1 = new WorldArea(2997, 3525, 34, 9, 0); + private static final WorldArea NOT_WILDERNESS_2 = new WorldArea(3005, 3534, 21, 10, 0); + private static final WorldArea NOT_WILDERNESS_3 = new WorldArea(3000, 3534, 5, 5, 0); + private static final WorldArea NOT_WILDERNESS_4 = new WorldArea(3031, 3525, 2, 2, 0); + + private static final WorldArea WILDERNESS_ABOVE_GROUND_LEVEL_20 = new WorldArea(2944, 3680, 448, 448, 0); + private static final WorldArea WILDERNESS_ABOVE_GROUND_LEVEL_30 = new WorldArea(2944, 3760, 448, 448, 0); + private static final WorldArea WILDERNESS_UNDERGROUND_LEVEL_20 = new WorldArea(2944, 10075, 518, 301, 0); + private static final WorldArea WILDERNESS_UNDERGROUND_LEVEL_30 = new WorldArea(2944, 10155, 518, 221, 0); + + public static boolean isInWilderness(int packedPoint) + { + return WorldPointUtil.distanceToArea2D(packedPoint, WILDERNESS_ABOVE_GROUND) == 0 + && WorldPointUtil.distanceToArea2D(packedPoint, FEROX_ENCLAVE_1) != 0 + && WorldPointUtil.distanceToArea2D(packedPoint, FEROX_ENCLAVE_2) != 0 + && WorldPointUtil.distanceToArea2D(packedPoint, FEROX_ENCLAVE_3) != 0 + && WorldPointUtil.distanceToArea2D(packedPoint, FEROX_ENCLAVE_4) != 0 + && WorldPointUtil.distanceToArea2D(packedPoint, FEROX_ENCLAVE_5) != 0 + && WorldPointUtil.distanceToArea2D(packedPoint, NOT_WILDERNESS_1) != 0 + && WorldPointUtil.distanceToArea2D(packedPoint, NOT_WILDERNESS_2) != 0 + && WorldPointUtil.distanceToArea2D(packedPoint, NOT_WILDERNESS_3) != 0 + && WorldPointUtil.distanceToArea2D(packedPoint, NOT_WILDERNESS_4) != 0 + || WorldPointUtil.distanceToArea2D(packedPoint, WILDERNESS_UNDERGROUND) == 0; + } + + public static boolean isInWilderness(Set packedPoints) + { + for (int packedPoint : packedPoints) + { + if (isInWilderness(packedPoint)) + { + return true; + } + } + return false; + } + + public static boolean isInLevel20Wilderness(int packedPoint) + { + return WorldPointUtil.distanceToArea2D(packedPoint, WILDERNESS_ABOVE_GROUND_LEVEL_20) == 0 + || WorldPointUtil.distanceToArea2D(packedPoint, WILDERNESS_UNDERGROUND_LEVEL_20) == 0; + } + + public static boolean isInLevel30Wilderness(int packedPoint) + { + return WorldPointUtil.distanceToArea2D(packedPoint, WILDERNESS_ABOVE_GROUND_LEVEL_30) == 0 + || WorldPointUtil.distanceToArea2D(packedPoint, WILDERNESS_UNDERGROUND_LEVEL_30) == 0; + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/BankPickupRequirements.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/BankPickupRequirements.java new file mode 100644 index 00000000000..145143bc276 --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/BankPickupRequirements.java @@ -0,0 +1,471 @@ +package shortestpath.transport; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.runelite.api.Client; +import net.runelite.api.EnumComposition; +import net.runelite.api.EnumID; +import net.runelite.api.Item; +import net.runelite.api.ItemContainer; +import net.runelite.api.gameval.InventoryID; +import net.runelite.api.gameval.VarbitID; +import shortestpath.ItemVariations; +import shortestpath.pathfinder.PathStep; +import shortestpath.pathfinder.PathfinderConfig; +import shortestpath.pathfinder.TransportAvailability; +import shortestpath.transport.requirement.ItemRequirement; + +/** + * Determines what items need to be picked up from the bank for a given path. + * This handles transport-specific requirements like the Dramen staff for fairy rings. + * Multiple transports that connect the same edge are treated as alternatives (OR): + * if the player can satisfy any one of them, no pickup is needed; otherwise the + * cheapest single alternative that can be filled from the bank is chosen. + */ +@SuppressWarnings("unused") // Only static methods are used, incorrectly flagged +public final class BankPickupRequirements +{ + + /** + * Gets a list of items that need to be picked up from the bank at a given path step. + * + * @param client The game client + * @param bank The bank ItemContainer + * @param pathfinderConfig The pathfinder config for bank-aware transport lookups + * @param bankLocations Set of bank location coordinates + * @param path The current path + * @param pathIndex The current step index in the path + * @return List of item names to pick up, or empty list if none needed + */ + public static List getRequiredBankItems( + Client client, + ItemContainer bank, + PathfinderConfig pathfinderConfig, + Set bankLocations, + List path, + int pathIndex) + { + + List requiredItems = new ArrayList<>(); + + if (bank == null || path == null || pathIndex < 0 || pathIndex >= path.size()) + { + return requiredItems; + } + + // Check if this is a bank step + int currentPoint = path.get(pathIndex).getPackedPosition(); + if (!bankLocations.contains(currentPoint)) + { + return requiredItems; + } + + // Snapshot bank contents. + Map bankHas = new HashMap<>(); + for (Item bankItem : bank.getItems()) + { + if (bankItem.getId() >= 0 && bankItem.getQuantity() > 0) + { + bankHas.merge(bankItem.getId(), bankItem.getQuantity(), Integer::sum); + } + } + + // Map runeId → pouchId for any rune pouch sitting in the bank. + // Used so computeBankPickups can show "pick up rune pouch" instead of individual runes. + Map bankPouchRunes = buildBankPouchRunes(client, bankHas); + + // Snapshot what the player already has on them (inventory + equipment + rune pouch in hand). + Map playerHas = collectPlayerItems(client); + + // Each entry is one edge's pickup phrase, e.g. "Air rune (3), Law rune or Varrock teleport". + // A LinkedHashSet preserves order while deduplicating identical phrases across edges. + LinkedHashSet phrases = new LinkedHashSet<>(); + boolean usesFairyRing = false; + + // Walk each edge in the remaining path and collect alternative transports per edge. + for (int i = pathIndex; i < path.size() - 1; i++) + { + int stepPoint = path.get(i).getPackedPosition(); + int nextPoint = path.get(i + 1).getPackedPosition(); + boolean banked = path.get(i + 1).isBankVisited(); + TransportAvailability availability = pathfinderConfig.getTransportAvailability(banked); + + List edgeAlternatives = new ArrayList<>(); + for (Transport t : availability.getTransportsAt(stepPoint)) + { + if (t.getDestination() == nextPoint) + { + edgeAlternatives.add(t); + } + } + for (Transport t : availability.getUsableTeleports()) + { + if (t.getDestination() == nextPoint) + { + edgeAlternatives.add(t); + } + } + if (edgeAlternatives.isEmpty()) + { + continue; + } + + // Fairy rings handled once globally via the staff requirement below. + List nonFairy = new ArrayList<>(); + for (Transport t : edgeAlternatives) + { + if (TransportType.FAIRY_RING.equals(t.getType())) + { + usesFairyRing = true; + } + else + { + nonFairy.add(t); + } + } + if (nonFairy.isEmpty()) + { + continue; + } + + // If at least one alternative requires no items, nothing to pick up for this edge. + boolean anyAlternativeIsFree = false; + for (Transport t : nonFairy) + { + if (t.getItemRequirements() == null || t.getItemRequirements().size() == 0) + { + anyAlternativeIsFree = true; + break; + } + } + if (anyAlternativeIsFree) + { + continue; + } + + // If any alternative is fully satisfied by the player already, no pickup needed. + boolean satisfied = false; + for (Transport t : nonFairy) + { + if (transportSatisfiedBy(t, playerHas)) + { + satisfied = true; + break; + } + } + if (satisfied) + { + continue; + } + + // Otherwise, surface every alternative the bank can fully supply, joined, bankPouchRunes by " or ". + // Deduplicate alternatives that resolve to the exact same set of bank items. + LinkedHashSet altStrings = new LinkedHashSet<>(); + for (Transport t : nonFairy) + { + Map pickups = computeBankPickups(t, playerHas, bankHas, bankPouchRunes); + if (pickups == null || pickups.isEmpty()) + { + continue; // bank can't satisfy this alternative + } + altStrings.add(formatPickups(client, pickups)); + } + if (altStrings.isEmpty()) + { + continue; + } + phrases.add(String.join(" or ", altStrings)); + } + + // Fairy ring staff (Dramen / Lunar) is a single OR requirement across the whole trip. + // Not needed if the Lumbridge Elite diary is complete. + if (usesFairyRing && client.getVarbitValue(VarbitID.LUMBRIDGE_DIARY_ELITE_COMPLETE) != 1) + { + int[] staffIds = ItemVariations.DRAMEN_STAFF.getIds(); + if (!hasAnyItem(playerHas, staffIds, 1)) + { + int foundId = findItemIdInBank(bankHas, staffIds, 1); + if (foundId != -1) + { + Map single = new LinkedHashMap<>(); + single.put(foundId, 1L); + phrases.add(formatPickups(client, single)); + } + } + } + + requiredItems.addAll(phrases); + return requiredItems; + } + + /** + * Formats a pickup map (item id → quantity) as a comma-separated, human-readable string. + */ + private static String formatPickups(Client client, Map pickups) + { + List parts = new ArrayList<>(pickups.size()); + for (Map.Entry entry : pickups.entrySet()) + { + int itemId = entry.getKey(); + long qty = entry.getValue(); + String itemName = client.getItemDefinition(itemId).getName(); + if (itemName == null || itemName.isEmpty() || "null".equals(itemName)) + { + itemName = "Unknown item"; + } + boolean isCurrency = PathfinderConfig.CURRENCIES.contains(itemId); + if (isCurrency) + { + if (qty > 1) + { + itemName += " (" + String.format("%,d", qty) + ")"; + } + } + else + { + itemName = qty + " " + itemName; + } + parts.add(itemName); + } + return String.join(", ", parts); + } + + /** + * Returns true if the player has at least {@code requiredQty} of any id in {@code itemIds} + * across inventory/equipment/rune-pouch. + */ + private static boolean hasAnyItem(Map playerHas, int[] itemIds, int requiredQty) + { + if (itemIds == null) + { + return false; + } + for (int id : itemIds) + { + if (playerHas.getOrDefault(id, 0) >= requiredQty) + { + return true; + } + } + return false; + } + + /** + * Returns the item ID from {@code itemIds} present in the bank with at least + * {@code requiredQty}, or -1 if none qualifies. + */ + private static int findItemIdInBank(Map bankHas, int[] itemIds, int requiredQty) + { + if (itemIds == null) + { + return -1; + } + for (int id : itemIds) + { + if (bankHas.getOrDefault(id, 0) >= requiredQty) + { + return id; + } + } + return -1; + } + + /** + * Returns true if every requirement of the transport is already met by the player's + * inventory/equipment/rune-pouch (taking item-id, staff and offhand variations into account). + */ + public static boolean transportSatisfiedBy(Transport transport, Map playerHas) + { + if (transport.getItemRequirements() == null) + { + return true; + } + for (ItemRequirement req : transport.getItemRequirements().getRequirements()) + { + int qty = req.getQuantity() > 0 ? req.getQuantity() : 1; + if (hasAnyItem(playerHas, req.getItemIds(), qty) + || hasAnyItem(playerHas, req.getStaffIds(), 1) + || hasAnyItem(playerHas, req.getOffhandIds(), 1)) + { + continue; + } + return false; + } + return true; + } + + /** + * For an unsatisfied transport, returns the items (id → qty) that need to be picked up + * from the bank to satisfy it, or null if the bank can't supply them. + * When a required rune is only available via a bank rune pouch, the pouch itself is + * returned as the pickup item (qty 1, deduped across multiple rune requirements). + */ + private static Map computeBankPickups(Transport transport, + Map playerHas, + Map bankHas, + Map bankPouchRunes) + { + Map pickups = new LinkedHashMap<>(); + Set addedPouches = new HashSet<>(); // tracks pouch IDs already added to pickups + if (transport.getItemRequirements() == null) + { + return pickups; + } + for (ItemRequirement req : transport.getItemRequirements().getRequirements()) + { + int qty = req.getQuantity() > 0 ? req.getQuantity() : 1; + // Already satisfied by player? + if (hasAnyItem(playerHas, req.getItemIds(), qty) + || hasAnyItem(playerHas, req.getStaffIds(), 1) + || hasAnyItem(playerHas, req.getOffhandIds(), 1)) + { + continue; + } + // Prefer bank rune pouch over individual runes. This avoids surfacing combination + // rune variants (mist, dust, etc.) when the pouch already covers the requirement. + boolean satisfied = false; + if (req.getItemIds() != null) + { + for (int itemId : req.getItemIds()) + { + Integer pouchId = bankPouchRunes.get(itemId); + if (pouchId != null) + { + if (!addedPouches.contains(pouchId)) + { + addedPouches.add(pouchId); + pickups.put(pouchId, 1L); + } + satisfied = true; + break; + } + } + } + if (satisfied) + { + continue; + } + // Try to satisfy from bank directly. Use the canonical (first) item ID for display + // so we show "Air rune" rather than a combination rune variant like "Mist rune". + int foundId = findItemIdInBank(bankHas, req.getItemIds(), qty); + if (foundId != -1) + { + pickups.merge(req.getItemIds()[0], (long) qty, Long::sum); + continue; + } + foundId = findItemIdInBank(bankHas, req.getStaffIds(), 1); + if (foundId == -1) + { + foundId = findItemIdInBank(bankHas, req.getOffhandIds(), 1); + } + if (foundId == -1) + { + return null; // bank can't satisfy this requirement + } + pickups.merge(foundId, 1L, Long::sum); + } + return pickups; + } + + /** + * Builds a map of runeId → pouchId for every rune stored inside any rune pouch in the bank. + * The varbits that encode pouch contents are always current regardless of pouch location. + */ + private static Map buildBankPouchRunes(Client client, Map bankHas) + { + Map bankPouchRunes = new HashMap<>(); + for (Integer pouchId : PathfinderConfig.RUNE_POUCHES) + { + if (!bankHas.containsKey(pouchId)) + { + continue; + } + EnumComposition runePouchEnum = client.getEnum(EnumID.RUNEPOUCH_RUNE); + if (runePouchEnum == null) + { + break; + } + for (int i = 0; i < PathfinderConfig.RUNE_POUCH_RUNE_VARBITS.length; i++) + { + int runeEnumId = client.getVarbitValue(PathfinderConfig.RUNE_POUCH_RUNE_VARBITS[i]); + int runeId = runeEnumId > 0 ? runePouchEnum.getIntValue(runeEnumId) : 0; + int runeAmount = client.getVarbitValue(PathfinderConfig.RUNE_POUCH_AMOUNT_VARBITS[i]); + if (runeId > 0 && runeAmount > 0) + { + bankPouchRunes.put(runeId, pouchId); + } + } + break; // one pouch per bank + } + return bankPouchRunes; + } + + /** + * Snapshots what the player already has on them (inventory + equipment + rune pouch + * contents if the pouch is in inventory or equipped). + */ + public static Map collectPlayerItems(Client client) + { + Map totals = new HashMap<>(); + + ItemContainer inventory = client.getItemContainer(InventoryID.INV); + if (inventory != null) + { + for (Item item : inventory.getItems()) + { + if (item.getId() >= 0 && item.getQuantity() > 0) + { + totals.merge(item.getId(), item.getQuantity(), Integer::sum); + } + } + } + + ItemContainer equipment = client.getItemContainer(InventoryID.WORN); + if (equipment != null) + { + for (Item item : equipment.getItems()) + { + if (item.getId() >= 0 && item.getQuantity() > 0) + { + totals.merge(item.getId(), item.getQuantity(), Integer::sum); + } + } + } + + // Rune pouch contents — only when the pouch is actually on the player. + boolean hasPouch = false; + for (Integer pouchId : PathfinderConfig.RUNE_POUCHES) + { + if (totals.containsKey(pouchId)) + { + hasPouch = true; + break; + } + } + if (hasPouch) + { + EnumComposition runePouchEnum = client.getEnum(EnumID.RUNEPOUCH_RUNE); + if (runePouchEnum != null) + { + for (int i = 0; i < PathfinderConfig.RUNE_POUCH_RUNE_VARBITS.length; i++) + { + int runeEnumId = client.getVarbitValue(PathfinderConfig.RUNE_POUCH_RUNE_VARBITS[i]); + int runeId = runeEnumId > 0 ? runePouchEnum.getIntValue(runeEnumId) : 0; + int runeAmount = client.getVarbitValue(PathfinderConfig.RUNE_POUCH_AMOUNT_VARBITS[i]); + if (runeId > 0 && runeAmount > 0) + { + totals.merge(runeId, runeAmount, Integer::sum); + } + } + } + } + + return totals; + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/LoadInterner.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/LoadInterner.java new file mode 100644 index 00000000000..007c26cc228 --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/LoadInterner.java @@ -0,0 +1,48 @@ +package shortestpath.transport; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import net.runelite.api.Quest; +import shortestpath.transport.parser.VarRequirement; +import shortestpath.transport.requirement.TransportItems; + +/** + * Load-scoped deduplication pools for transport requirement objects and display strings (issue + * #491). Identical requirements and labels are extremely common across the permuted transport rows, + * so a single canonical instance is shared. The interner is local to a {@code loadAllFromResources} + * call and discarded afterwards, so the pools themselves are never retained. + */ +final class LoadInterner +{ + private final Map items = new HashMap<>(); + private final Map vars = new HashMap<>(); + private final Map, Set> varSets = new HashMap<>(); + private final Map, Set> questSets = new HashMap<>(); + private final Map strings = new HashMap<>(); + + TransportItems intern(TransportItems value) + { + return value == null ? null : items.computeIfAbsent(value, v -> v); + } + + VarRequirement intern(VarRequirement value) + { + return vars.computeIfAbsent(value, v -> v); + } + + Set internVarSet(Set value) + { + return varSets.computeIfAbsent(value, v -> v); + } + + Set internQuestSet(Set value) + { + return questSets.computeIfAbsent(value, v -> v); + } + + String internString(String value) + { + return value == null ? null : strings.computeIfAbsent(value, v -> v); + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/Transport.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/Transport.java new file mode 100644 index 00000000000..5cd3db06eda --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/Transport.java @@ -0,0 +1,643 @@ +package shortestpath.transport; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.Set; + +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.Quest; +import net.runelite.api.Skill; +import shortestpath.WorldPointUtil; +import shortestpath.leagues.LeagueRegion; +import shortestpath.transport.parser.FieldParser; +import shortestpath.transport.parser.ItemRequirementParser; +import shortestpath.transport.parser.QuestParser; +import shortestpath.transport.parser.SkillRequirementParser; +import shortestpath.transport.parser.TransportRecord; +import shortestpath.transport.parser.VarRequirement; +import shortestpath.transport.parser.VarRequirementParser; +import shortestpath.transport.parser.WorldPointParser; +import shortestpath.transport.requirement.TransportItems; + +/** + * This class represents a travel point between two WorldPoints. + */ +@Slf4j +public class Transport +{ + public static final int UNDEFINED_ORIGIN = WorldPointUtil.UNDEFINED; + public static final int UNDEFINED_DESTINATION = WorldPointUtil.UNDEFINED; + /** + * A location placeholder different from null to use for permutation transports + */ + public static final int LOCATION_PERMUTATION = WorldPointUtil.packWorldPoint(-1, -1, 1); + /** + * Shared, never-mutated all-zero skill array. Most transports require no skills, so they point + * at this singleton instead of each allocating their own {@code int[]} (issue #491). + */ + private static final int[] NO_SKILLS = new int[Skill.values().length + 3]; + /** + * The skill levels, total level, combat level and quest points required to use + * this transport. Defaults to {@link #NO_SKILLS} until real requirements are set. + */ + @Getter + private int[] skillLevels = NO_SKILLS; + /** + * Variable requirements (varbits and varplayers) for the transport to be valid. + * All must pass. + */ + // Defaults to a shared immutable empty set; only transports that actually declare var + // requirements pay for a real Set. Most transports declare none, so this avoids ~one + // HashSet (+ its HashMap) per transport (issue #491). + @Getter + private Set varRequirements = Collections.emptySet(); + /** + * The starting point of this transport + */ + @Getter + private int origin = UNDEFINED_ORIGIN; + /** + * The ending point of this transport + */ + @Setter + @Getter + private int destination = UNDEFINED_DESTINATION; + + /** + * The quests required to use this transport + */ + // Shared immutable empty set by default; see varRequirements above. + @Getter + private Set quests = Collections.emptySet(); + /** + * The item requirements to use this transport + */ + @Getter + private TransportItems itemRequirements; + /** + * The type of transport + */ + @Getter + private TransportType type; + /** + * The travel waiting time in number of ticks + */ + @Getter + private int duration; + /** + * Info to display for this transport. For spirit trees, fairy rings, + * and others, this is the destination option to pick. + */ + @Getter + private String displayInfo = null; + /** + * If this is an item transport, this tracks if it is consumable (as opposed to + * having infinite uses) + */ + @Getter + private boolean isConsumable = false; + /** + * The maximum wilderness level that the transport can be used in + */ + @Getter + private int maxWildernessLevel = -1; + /** + * Object information for this transport + */ + @Getter + private String objectInfo = null; + /** + * Per-transport seasonal-league region override. When set, the league-mode + * region gate ({@code PathfinderConfig#isTransportRegionAllowed}) uses + * this region for the destination chunk instead of the result of + * {@code LeagueRegionChecker#getRegion(destination)}. Used for shortcuts + * whose destination tile sits in a chunk that the wiki classifies under + * a different region (e.g. Trollheim Wilderness climb — destination chunk + * is Wilderness, but the wiki lists the shortcut under Asgarnia). + */ + @Getter + private LeagueRegion regionOverride = null; + + /** + * Creates a new transport from an origin-only transport + * and a destination-only transport, and merges requirements + */ + Transport(Transport origin, Transport destination) + { + TransportBuilder builder = new TransportBuilder() + .origin(origin.origin) + .destination(destination.destination) + .type(origin.type) + .startSkillLevels(origin.skillLevels) + .startSkillLevels(destination.skillLevels) + .quests(origin.quests) + .quests(destination.quests) + .itemRequirements(TransportItems.merge(origin.itemRequirements, destination.itemRequirements)) + .duration(Math.max(origin.duration, destination.duration)) + .displayInfo(destination.displayInfo) + .isConsumable(origin.isConsumable || destination.isConsumable) + .maxWildernessLevel(Math.max(origin.maxWildernessLevel, destination.maxWildernessLevel)) + .objectInfo(origin.objectInfo) + .varRequirements(origin.varRequirements) + .varRequirements(destination.varRequirements) + .regionOverride(destination.regionOverride != null ? destination.regionOverride : origin.regionOverride); + + Transport builtTransport = builder.build(); + + this.origin = builtTransport.origin; + this.destination = builtTransport.destination; + this.skillLevels = builtTransport.skillLevels; + this.quests = builtTransport.quests; + this.itemRequirements = builtTransport.itemRequirements; + this.type = builtTransport.type; + this.duration = builtTransport.duration; + this.displayInfo = builtTransport.displayInfo; + this.isConsumable = builtTransport.isConsumable; + this.maxWildernessLevel = builtTransport.maxWildernessLevel; + this.objectInfo = builtTransport.objectInfo; + this.varRequirements = builtTransport.varRequirements; + } + + Transport(TransportRecord record, TransportType transportType) + { + TransportBuilder builder = new TransportBuilder(); + builder.type(transportType); + + // Origin/Destination use hasKey because empty string means LOCATION_PERMUTATION + if (record.hasKey(TransportRecord.Fields.ORIGIN)) + { + builder.origin(record.getOrigin()); + } + if (record.hasKey(TransportRecord.Fields.DESTINATION)) + { + builder.destination(record.getDestination()); + } + if (record.has(TransportRecord.Fields.SKILLS)) + { + builder.skillLevels(record.getSkills()); + } + if (record.has(TransportRecord.Fields.ITEMS)) + { + builder.itemRequirements(record.getItems()); + } + if (record.has(TransportRecord.Fields.QUESTS)) + { + builder.quests(record.getQuests()); + } + if (record.has(TransportRecord.Fields.DURATION)) + { + builder.duration(record.getDuration()); + } + if (record.has(TransportRecord.Fields.DISPLAY_INFO)) + { + builder.displayInfo(record.getDisplayInfo()); + } + if (record.has(TransportRecord.Fields.CONSUMABLE)) + { + builder.isConsumable(record.getConsumable()); + } + if (record.has(TransportRecord.Fields.WILDERNESS_LEVEL)) + { + builder.maxWildernessLevel(record.getWildernessLevel()); + } + if (record.has(TransportRecord.Fields.OBJECT_INFO)) + { + builder.objectInfo(record.getObjectInfo()); + } + if (record.has(TransportRecord.Fields.VARBITS)) + { + builder.varbits(record.getVarbits()); + } + if (record.has(TransportRecord.Fields.VAR_PLAYERS)) + { + builder.varPlayers(record.getVarPlayers()); + } + if (record.has(TransportRecord.Fields.REGION_OVERRIDE)) + { + builder.regionOverride(record.getRegionOverride()); + } + + Transport builtTransport = builder.build(); + this.origin = builtTransport.origin; + this.destination = builtTransport.destination; + this.skillLevels = builtTransport.skillLevels; + this.quests = builtTransport.quests; + this.itemRequirements = builtTransport.itemRequirements; + this.type = builtTransport.type; + this.duration = builtTransport.duration; + this.displayInfo = builtTransport.displayInfo; + this.isConsumable = builtTransport.isConsumable; + this.maxWildernessLevel = builtTransport.maxWildernessLevel; + this.objectInfo = builtTransport.objectInfo; + this.varRequirements = builtTransport.varRequirements; + this.regionOverride = builtTransport.regionOverride; + } + + private Transport() + { + } + + /** + * Hands back a shared immutable empty set when the builder accumulated nothing, so empty + * requirement sets do not allocate a {@code HashSet}/{@code HashMap} per transport. A non-empty + * builder set is handed over directly (the single-use builder is discarded afterwards). + */ + private static Set compact(Set set) + { + return set.isEmpty() ? Collections.emptySet() : set; + } + + /** + * Quest requirements are keyed on the {@link Quest} enum, so a non-empty set is stored as a + * compact {@link EnumSet} (a single bitmask object) rather than a HashSet plus its HashMap and + * per-element nodes (issue #491). The empty case keeps the shared immutable empty set. + */ + private static Set compactQuests(Set quests) + { + return quests.isEmpty() ? Collections.emptySet() : EnumSet.copyOf(quests); + } + + /** + * Hands back the shared {@link #NO_SKILLS} singleton when no skill requirement is set, so + * all-zero skill arrays do not allocate a per-transport {@code int[]}. A non-empty builder array + * is handed over directly (the single-use builder is discarded afterwards). + */ + private static int[] compactSkills(int[] skills) + { + for (int level : skills) + { + if (level != 0) + { + return skills; + } + } + return NO_SKILLS; + } + + /** + * Load-time flyweight: replaces this transport's requirement objects with shared canonical + * instances from the supplied pools, so transports with identical item or var requirements share + * one {@code TransportItems}/{@code VarRequirement} instance (and the int[] arrays inside them) + * rather than each holding a distinct copy (issue #491). Identical requirements are extremely + * common across the permuted transport rows. The pools are local to loading and discarded after. + */ + void internRequirements(LoadInterner interner) + { + itemRequirements = interner.intern(itemRequirements); + displayInfo = interner.internString(displayInfo); + objectInfo = interner.internString(objectInfo); + if (!varRequirements.isEmpty()) + { + Set interned = new HashSet<>(varRequirements.size() * 2); + for (VarRequirement requirement : varRequirements) + { + interned.add(interner.intern(requirement)); + } + // Transports with identical var requirements (very common across permutations) share one + // read-only Set instead of each keeping a copy. + varRequirements = interner.internVarSet(interned); + } + if (!quests.isEmpty()) + { + quests = interner.internQuestSet(quests); + } + } + + @Override + public String toString() + { + return ("(" + + WorldPointUtil.unpackWorldX(origin) + ", " + + WorldPointUtil.unpackWorldY(origin) + ", " + + WorldPointUtil.unpackWorldPlane(origin) + ") to (" + + WorldPointUtil.unpackWorldX(destination) + ", " + + WorldPointUtil.unpackWorldY(destination) + ", " + + WorldPointUtil.unpackWorldPlane(destination) + ")"); + } + + /** + * Whether the transport has one or more quest requirements + */ + public boolean isQuestLocked() + { + return !quests.isEmpty(); + } + + /** + * Whether this transport is of the given type. + */ + public boolean isType(TransportType type) + { + return type.equals(this.type); + } + + /** + * Whether this transport's display info contains the given substring. + * Returns false if displayInfo is null. + */ + public boolean hasDisplayInfo(String substring) + { + return displayInfo != null && displayInfo.contains(substring); + } + + /** + * Whether this transport can be used at the given wilderness level. + */ + public boolean isUsableAtWildernessLevel(int wildernessLevel) + { + return !type.isTeleport() || wildernessLevel <= maxWildernessLevel; + } + + /** + * Gets varbit requirements (filtered from varRequirements). + * For backward compatibility with code that needs separate varbit access. + */ + public Set getVarbits() + { + Set varbits = new HashSet<>(); + for (VarRequirement req : varRequirements) + { + if (req.isVarbit()) + { + varbits.add(req); + } + } + return varbits; + } + + /** + * Whether this transport has a varbit requirement with the given ID. + */ + public boolean hasVarbit(int varbitId) + { + for (VarRequirement req : varRequirements) + { + if (req.isVarbit() && req.getId() == varbitId) + { + return true; + } + } + return false; + } + + /** + * Gets varplayer requirements (filtered from varRequirements). + * For backward compatibility with code that needs separate varplayer access. + */ + public Set getVarPlayers() + { + Set varPlayers = new HashSet<>(); + for (VarRequirement req : varRequirements) + { + if (req.isVarPlayer()) + { + varPlayers.add(req); + } + } + return varPlayers; + } + + public static class TransportBuilder + { + private final int[] skillLevels = new int[Skill.values().length + 3]; + private final Set varRequirements = new HashSet<>(); + private final FieldParser skillParser = new SkillRequirementParser(); + private final FieldParser itemParser = new ItemRequirementParser(); + private final FieldParser> questParser = new QuestParser(); + private final VarRequirementParser varbitParser = VarRequirementParser.forVarbits(); + private final VarRequirementParser varPlayerParser = VarRequirementParser.forVarPlayers(); + private final FieldParser worldPointParser = new WorldPointParser(); + private final Set quests = new HashSet<>(); + private int origin = UNDEFINED_ORIGIN; + private int destination = UNDEFINED_DESTINATION; + private TransportItems itemRequirements; + private TransportType type; + private int duration; + private String displayInfo = null; + private boolean isConsumable = false; + private int maxWildernessLevel = -1; + private String objectInfo = null; + private LeagueRegion regionOverride = null; + + public TransportBuilder origin(int origin) + { + this.origin = origin; + return this; + } + + public TransportBuilder origin(String value) + { + this.origin = worldPointParser.parse(value); + return this; + } + + public TransportBuilder destination(int destination) + { + this.destination = destination; + return this; + } + + public TransportBuilder destination(String value) + { + this.destination = worldPointParser.parse(value); + return this; + } + + public TransportBuilder skillLevels(String value) + { + int[] parsedSkills = skillParser.parse(value); + for (int i = 0; i < skillLevels.length; i++) + { + if (parsedSkills[i] > 0) + { + skillLevels[i] = parsedSkills[i]; + } + } + return this; + } + + public TransportBuilder startSkillLevels(int[] otherSkillLevels) + { + for (int i = 0; i < skillLevels.length; i++) + { + this.skillLevels[i] = Math.max(this.skillLevels[i], otherSkillLevels[i]); + } + return this; + } + + public TransportBuilder quests(Set quests) + { + this.quests.addAll(quests); + return this; + } + + public TransportBuilder quests(String value) + { + this.quests.addAll(questParser.parse(value)); + return this; + } + + public TransportBuilder itemRequirements(TransportItems itemRequirements) + { + this.itemRequirements = itemRequirements; + return this; + } + + public TransportBuilder itemRequirements(String value) + { + this.itemRequirements = itemParser.parse(value); + return this; + } + + public TransportBuilder type(TransportType type) + { + this.type = type; + return this; + } + + public TransportBuilder duration(int duration) + { + this.duration = Math.max(this.duration, duration); + return this; + } + + public TransportBuilder duration(String value) + { + if (value != null && !value.isEmpty()) + { + try + { + this.duration = Integer.parseInt(value); + } + catch (NumberFormatException e) + { + log.error("Invalid tick duration: {}", value); + } + } + return this; + } + + public TransportBuilder displayInfo(String displayInfo) + { + this.displayInfo = displayInfo; + return this; + } + + public TransportBuilder isConsumable(boolean isConsumable) + { + this.isConsumable |= isConsumable; + return this; + } + + public TransportBuilder isConsumable(String value) + { + this.isConsumable = "T".equals(value) || "yes".equalsIgnoreCase(value); + return this; + } + + public TransportBuilder maxWildernessLevel(int maxWildernessLevel) + { + this.maxWildernessLevel = Math.max(this.maxWildernessLevel, maxWildernessLevel); + return this; + } + + public TransportBuilder maxWildernessLevel(String value) + { + if (value != null && !value.isEmpty()) + { + try + { + this.maxWildernessLevel = Integer.parseInt(value); + } + catch (NumberFormatException e) + { + log.error("Invalid wilderness level: {}", value); + } + } + return this; + } + + public TransportBuilder objectInfo(String objectInfo) + { + this.objectInfo = objectInfo; + return this; + } + + public TransportBuilder regionOverride(LeagueRegion regionOverride) + { + if (regionOverride != null) + { + this.regionOverride = regionOverride; + } + return this; + } + + public TransportBuilder regionOverride(String value) + { + if (value != null && !value.isEmpty()) + { + try + { + this.regionOverride = LeagueRegion.valueOf(value.trim().toUpperCase()); + } + catch (IllegalArgumentException e) + { + log.error("Invalid region override: {}", value); + } + } + return this; + } + + public TransportBuilder varRequirements(Set requirements) + { + this.varRequirements.addAll(requirements); + return this; + } + + public TransportBuilder varbits(String value) + { + this.varRequirements.addAll(varbitParser.parse(value)); + return this; + } + + public TransportBuilder varPlayers(String value) + { + this.varRequirements.addAll(varPlayerParser.parse(value)); + return this; + } + + public Transport build() + { + Transport transport = new Transport(); + transport.origin = this.origin; + transport.destination = this.destination; + transport.skillLevels = compactSkills(this.skillLevels); + transport.quests = compactQuests(this.quests); + transport.itemRequirements = this.itemRequirements; + transport.type = this.type; + transport.duration = this.duration; + transport.displayInfo = this.displayInfo; + transport.isConsumable = this.isConsumable; + transport.maxWildernessLevel = this.maxWildernessLevel; + transport.objectInfo = this.objectInfo; + transport.varRequirements = compact(this.varRequirements); + transport.regionOverride = this.regionOverride; + + // Post-build validation/refinement + if (transport.type != null && transport.type.isTeleport()) + { + transport.duration = Math.max(transport.duration, 1); + } + + if (transport.type != null) + { + transport.type = transport.type.refine(transport.skillLevels); + } + + return transport; + } + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/TransportLoader.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/TransportLoader.java new file mode 100644 index 00000000000..5b5fc9eb1b2 --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/TransportLoader.java @@ -0,0 +1,161 @@ +package shortestpath.transport; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import lombok.extern.slf4j.Slf4j; +import shortestpath.ShortestPathPlugin; +import shortestpath.Util; +import shortestpath.WorldPointUtil; +import shortestpath.transport.parser.TransportRecord; +import shortestpath.transport.parser.TsvParser; + +@Slf4j +public class TransportLoader +{ + private static final TsvParser tsvParser = new TsvParser(); + + private static void addTransports( + Map> transports, String path, TransportType transportType, + int radiusThreshold) + { + try + { + String s = new String( + Util.readAllBytes(Objects.requireNonNull(ShortestPathPlugin.class.getResourceAsStream(path))), + StandardCharsets.UTF_8); + addTransportsFromContents(transports, s, transportType, radiusThreshold); + } + catch (IOException e) + { + throw new RuntimeException(e); + } + } + + public static void addTransportsFromContents( + Map> transports, + String contents, + TransportType transportType, + int radiusThreshold) + { + List records = tsvParser.parse(contents); + + Set newTransports = new HashSet<>(); + for (TransportRecord record : records) + { + Transport transport = new Transport(record, transportType); + newTransports.add(transport); + } + + /* + * A transport with origin A and destination B is one-way and must + * be duplicated as origin B and destination A to become two-way. + * Example: key-locked doors + * + * A transport with origin A and a missing destination is one-way, + * but can go from origin A to all destinations with a missing origin. + * Example: fairy ring AIQ -> + * + * A transport with a missing origin and destination B is one-way, + * but can go from all origins with a missing destination to destination B. + * Example: fairy ring -> AIQ + * + * Identical transports from origin A to destination A are skipped, and + * non-identical transports from origin A to destination A can be skipped + * by specifying a radius threshold to ignore almost identical coordinates. + * Example: fairy ring AIQ -> AIQ + */ + Set transportOrigins = new HashSet<>(); + Set transportDestinations = new HashSet<>(); + for (Transport transport : newTransports) + { + int origin = transport.getOrigin(); + int destination = transport.getDestination(); + // Logic to determine ordinary transport vs teleport vs permutation (e.g. fairy + // ring) + if ((origin == Transport.UNDEFINED_ORIGIN && destination == Transport.UNDEFINED_DESTINATION) + || (origin == Transport.LOCATION_PERMUTATION && destination == Transport.LOCATION_PERMUTATION)) + { + continue; + } + else if (origin != Transport.LOCATION_PERMUTATION && origin != Transport.UNDEFINED_ORIGIN + && destination == Transport.LOCATION_PERMUTATION) + { + transportOrigins.add(transport); + } + else if (origin == Transport.LOCATION_PERMUTATION && destination != Transport.UNDEFINED_DESTINATION) + { + transportDestinations.add(transport); + } + if (origin != Transport.LOCATION_PERMUTATION + && destination != Transport.UNDEFINED_DESTINATION && destination != Transport.LOCATION_PERMUTATION + && (origin == Transport.UNDEFINED_ORIGIN || origin != destination)) + { + transports.computeIfAbsent(origin, k -> new HashSet<>()).add(transport); + } + } + for (Transport origin : transportOrigins) + { + for (Transport destination : transportDestinations) + { + // The radius threshold prevents transport permutations from including (almost) + // same origin and destination + if (WorldPointUtil.distanceBetween2D(origin.getOrigin(), destination.getDestination()) > radiusThreshold) + { + Transport combined = new Transport(origin, destination); + transports + .computeIfAbsent(origin.getOrigin(), k -> new HashSet<>()) + .add(combined); + } + } + } + } + + public static HashMap> loadAllFromResources() + { + HashMap> transports = new HashMap<>(); + + for (TransportType type : TransportType.values()) + { + if (type.hasResourcePath()) + { + addTransports(transports, type.getResourcePath(), type, + type.hasRadiusThreshold() ? type.getRadiusThreshold() : 0); + } + } + + internRequirements(transports); + + return transports; + } + + /** + * Deduplicates the requirement objects shared across the loaded transports, so identical + * {@code TransportItems}/{@code VarRequirement} requirements point at one shared instance + * instead of a per-transport copy (issue #491). The interning pools are local and discarded + * once loading finishes. + */ + private static void internRequirements(Map> transports) + { + LoadInterner interner = new LoadInterner(); + Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); + for (Set set : transports.values()) + { + for (Transport transport : set) + { + if (visited.add(transport)) + { + transport.internRequirements(interner); + } + } + } + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/TransportType.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/TransportType.java new file mode 100644 index 00000000000..cb1a6d6a63d --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/TransportType.java @@ -0,0 +1,198 @@ +package shortestpath.transport; + +import java.util.function.Function; + +import lombok.Getter; +import net.runelite.api.Skill; +import shortestpath.ShortestPathConfig; + +@Getter +public enum TransportType +{ + TRANSPORT("/transports/transports.tsv", null, null, null, null), + AGILITY_SHORTCUT("/transports/agility_shortcuts.tsv", "useAgilityShortcuts", ShortestPathConfig::useAgilityShortcuts, "costAgilityShortcuts", ShortestPathConfig::costAgilityShortcuts) + { + @Override + public TransportType refine(int[] skillLevels) + { + if (skillLevels[Skill.RANGED.ordinal()] > 1 || skillLevels[Skill.STRENGTH.ordinal()] > 1) + { + return GRAPPLE_SHORTCUT; + } + return this; + } + }, + GRAPPLE_SHORTCUT(null, "useGrappleShortcuts", ShortestPathConfig::useGrappleShortcuts, "costGrappleShortcuts", ShortestPathConfig::costGrappleShortcuts), + BOAT("/transports/boats.tsv", "useBoats", ShortestPathConfig::useBoats, "costBoats", ShortestPathConfig::costBoats), + CANOE("/transports/canoes.tsv", "useCanoes", ShortestPathConfig::useCanoes, "costCanoes", ShortestPathConfig::costCanoes), + CHARTER_SHIP("/transports/charter_ships.tsv", "useCharterShips", ShortestPathConfig::useCharterShips, "costCharterShips", ShortestPathConfig::costCharterShips), + SHIP("/transports/ships.tsv", "useShips", ShortestPathConfig::useShips, "costShips", ShortestPathConfig::costShips), + FAIRY_RING("/transports/fairy_rings.tsv", "useFairyRings", ShortestPathConfig::useFairyRings, "costFairyRings", ShortestPathConfig::costFairyRings, 6), + GNOME_GLIDER("/transports/gnome_gliders.tsv", "useGnomeGliders", ShortestPathConfig::useGnomeGliders, "costGnomeGliders", ShortestPathConfig::costGnomeGliders, 6), + HOT_AIR_BALLOON("/transports/hot_air_balloons.tsv", "useHotAirBalloons", ShortestPathConfig::useHotAirBalloons, "costHotAirBalloons", ShortestPathConfig::costHotAirBalloons, 7), + MAGIC_CARPET("/transports/magic_carpets.tsv", "useMagicCarpets", ShortestPathConfig::useMagicCarpets, "costMagicCarpets", ShortestPathConfig::costMagicCarpets), + MAGIC_MUSHTREE("/transports/magic_mushtrees.tsv", "useMagicMushtrees", ShortestPathConfig::useMagicMushtrees, "costMagicMushtrees", ShortestPathConfig::costMagicMushtrees, 5), + MINECART("/transports/minecarts.tsv", "useMinecarts", ShortestPathConfig::useMinecarts, "costMinecarts", ShortestPathConfig::costMinecarts), + QUETZAL("/transports/quetzals.tsv", "useQuetzals", ShortestPathConfig::useQuetzals, "costQuetzals", ShortestPathConfig::costQuetzals, 5) + { + @Override + public TransportType sharesDestinationsWith() + { + return QUETZAL_WHISTLE; + } + }, + QUETZAL_WHISTLE("/transports/quetzal_whistle.tsv", "useQuetzals", ShortestPathConfig::useQuetzals, "costQuetzalWhistle", ShortestPathConfig::costQuetzals) + { + @Override + public boolean isTeleport() + { + return true; + } + + @Override + public TransportType sharesDestinationsWith() + { + return QUETZAL; + } + + @Override + public Function differentialCostFunction() + { + return ShortestPathConfig::costQuetzalWhistle; + } + }, + SEASONAL_TRANSPORTS("/transports/seasonal_transports.tsv", "useSeasonalTransports", ShortestPathConfig::useSeasonalTransports, "costSeasonalTransports", ShortestPathConfig::costSeasonalTransports), + SPIRIT_TREE("/transports/spirit_trees.tsv", "useSpiritTrees", ShortestPathConfig::useSpiritTrees, "costSpiritTrees", ShortestPathConfig::costSpiritTrees, 5), + TELEPORTATION_BOX("/transports/teleportation_boxes.tsv", null, null, "costTeleportationBoxes", ShortestPathConfig::costTeleportationBoxes), + TELEPORTATION_ITEM("/transports/teleportation_items.tsv", null, null, "costNonConsumableTeleportationItems", ShortestPathConfig::costNonConsumableTeleportationItems) + { + @Override + public boolean isTeleport() + { + return true; + } + }, + TELEPORTATION_LEVER("/transports/teleportation_levers.tsv", "useTeleportationLevers", ShortestPathConfig::useTeleportationLevers, "costTeleportationLevers", ShortestPathConfig::costTeleportationLevers), + TELEPORTATION_MINIGAME("/transports/teleportation_minigames.tsv", "useTeleportationMinigames", ShortestPathConfig::useTeleportationMinigames, "costTeleportationMinigames", ShortestPathConfig::costTeleportationMinigames) + { + @Override + public boolean isTeleport() + { + return true; + } + }, + TELEPORTATION_PORTAL("/transports/teleportation_portals.tsv", "useTeleportationPortals", ShortestPathConfig::useTeleportationPortals, "costTeleportationPortals", ShortestPathConfig::costTeleportationPortals), + TELEPORTATION_PORTAL_POH("/transports/teleportation_portals_poh.tsv", "useTeleportationPortalsPoh", ShortestPathConfig::useTeleportationPortalsPoh, null, null), + TELEPORTATION_SPELL("/transports/teleportation_spells.tsv", "useTeleportationSpells", ShortestPathConfig::useTeleportationSpells, "costTeleportationSpells", ShortestPathConfig::costTeleportationSpells) + { + @Override + public boolean isTeleport() + { + return true; + } + }, + TELEPORTATION_SPELL_HOME("/transports/teleportation_spells_home.tsv", "useTeleportationSpellsHome", ShortestPathConfig::useTeleportationSpellsHome, "costTeleportationSpellsHome", ShortestPathConfig::costTeleportationSpellsHome) + { + @Override + public boolean isTeleport() + { + return true; + } + }, + WILDERNESS_OBELISK("/transports/wilderness_obelisks.tsv", "useWildernessObelisks", ShortestPathConfig::useWildernessObelisks, "costWildernessObelisks", ShortestPathConfig::costWildernessObelisks), + ; + + private final String resourcePath; + private final String enabledKey; + private final Function enabledGetter; + private final String costKey; + private final Function costGetter; + private final Integer radiusThreshold; + + TransportType( + String resourcePath, + String enabledKey, + Function enabledGetter, + String costKey, + Function costGetter) + { + this(resourcePath, enabledKey, enabledGetter, costKey, costGetter, null); + } + + TransportType( + String resourcePath, + String enabledKey, + Function enabledGetter, + String costKey, + Function costGetter, + Integer radiusThreshold) + { + this.resourcePath = resourcePath; + this.enabledKey = enabledKey; + this.enabledGetter = enabledGetter; + this.costKey = costKey; + this.costGetter = costGetter; + this.radiusThreshold = radiusThreshold; + } + + public boolean hasResourcePath() + { + return resourcePath != null; + } + + public boolean hasRadiusThreshold() + { + return radiusThreshold != null; + } + + public boolean hasEnabledGetter() + { + return enabledGetter != null; + } + + public boolean hasCostGetter() + { + return costGetter != null; + } + + /* + * Indicates whether a TransportType is a teleport. + * Levers, portals and wilderness obelisks are considered transports + * and not teleports because they have a pre-defined origin and no + * wilderness level limit. + */ + public boolean isTeleport() + { + return false; + } + + + /** + * Stores which transport type this transport shares destinations with, if any. + * Used for delayed visit pathfinding so both types can compete in the priority queue. + */ + public TransportType sharesDestinationsWith() + { + return null; + } + + /** + * Additional cost applied on top of the base cost when this transport type + * shares destinations with another type. Represents the differential cost + * (e.g. how many extra tiles the whistle must save over a landing site + * to justify using a charge). + */ + public Function differentialCostFunction() + { + return null; + } + + + /** + * Refines the TransportType based on the required skill levels. + */ + public TransportType refine(int[] skillLevels) + { + return this; + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/TransportTypeConfig.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/TransportTypeConfig.java new file mode 100644 index 00000000000..1afc854185d --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/TransportTypeConfig.java @@ -0,0 +1,162 @@ +package shortestpath.transport; + +import java.util.EnumMap; +import java.util.Map; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import shortestpath.ShortestPathConfig; +import shortestpath.ShortestPathPlugin; +import shortestpath.TeleportationItem; + +/** + * Manages the enabled/disabled state and cost thresholds of each TransportType + * based on config. + * This centralizes the config reading logic and automatically wires config + * methods + * from TransportType to ShortestPathConfig. + * + *

+ * When adding a new TransportType with config options, you only need to: + *

    + *
  1. Add the config methods to ShortestPathConfig
  2. + *
  3. Add the enum entry to TransportType with method references for the + * enabledGetter and costGetter
  4. + *
+ * This class will automatically pick up the new config getters. + * + *

+ * Special cases: + *

    + *
  • {@link TransportType#TELEPORTATION_ITEM} and + * {@link TransportType#TELEPORTATION_BOX} + * have no enabledGetter because they are controlled by the + * {@link TeleportationItem} enum + * via {@code useTeleportationItems} config. The per-transport filtering is + * handled + * in {@code PathfinderConfig.checkTeleportationItemRules()}.
  • + *
  • {@link TransportType#TRANSPORT} has no enabledGetter because it's the + * base transport + * type and is always enabled.
  • + *
+ */ +@Slf4j +public class TransportTypeConfig +{ + private final Map enabledStates = new EnumMap<>(TransportType.class); + private final Map costThresholds = new EnumMap<>(TransportType.class); + private final ShortestPathConfig config; + @Getter + private TeleportationItem teleportationItemSetting; + + public TransportTypeConfig(ShortestPathConfig config) + { + this.config = config; + refresh(); + } + + /** + * Refreshes all transport type enabled states and cost thresholds from config. + * Uses the functional getters defined in TransportType to read config values. + */ + public void refresh() + { + // Cache the teleportation item setting + teleportationItemSetting = ShortestPathPlugin.override("useTeleportationItems", config.useTeleportationItems()); + + for (TransportType type : TransportType.values()) + { + enabledStates.put(type, getEnabledState(type)); + int cost = getCostThreshold(type); + costThresholds.put(type, cost); + } + } + + /** + * Determines the enabled state for a transport type. + * Uses the enabledGetter function from TransportType to look up the config + * value. + * + *

+ * Special handling for teleportation item types which are controlled by + * the TeleportationItem enum rather than a simple boolean. + */ + private boolean getEnabledState(TransportType type) + { + // Special handling for teleportation item types + if (type == TransportType.TELEPORTATION_ITEM || type == TransportType.TELEPORTATION_BOX) + { + // These are enabled unless TeleportationItem is NONE + // The detailed filtering (consumable, inventory, etc.) is done in + // PathfinderConfig + return teleportationItemSetting != TeleportationItem.NONE; + } + + // No enabled getter means always enabled (controlled elsewhere or not + // configurable) + if (!type.hasEnabledGetter()) + { + return true; + } + + boolean configValue = type.getEnabledGetter().apply(config); + return ShortestPathPlugin.override(type, configValue); + } + + /** + * Determines the cost threshold for a transport type. + * Uses the costGetter function from TransportType to look up the config value. + */ + private int getCostThreshold(TransportType type) + { + // No cost getter means no additional cost + if (!type.hasCostGetter()) + { + return 0; + } + + int configValue = type.getCostGetter().apply(config); + return ShortestPathPlugin.override(type, configValue); + } + + /** + * Checks if a transport type is enabled in config. + */ + public boolean isEnabled(TransportType type) + { + return enabledStates.getOrDefault(type, true); + } + + /** + * Gets the cost threshold for a transport type. + */ + public int getCost(TransportType type) + { + return costThresholds.getOrDefault(type, 0); + } + + /** + * Sets the enabled state for a transport type. + * Used for runtime modifications (e.g., disabling fairy rings without dramen + * staff). + */ + public void setEnabled(TransportType type, boolean enabled) + { + enabledStates.put(type, enabled); + } + + /** + * Disables a transport type unless a condition is met. + * If the condition is false, the type is disabled. + * If the condition is true, the current enabled state is preserved (not + * changed). + * Useful for quest/item requirements that can only restrict, not enable. + */ + public void disableUnless(TransportType type, boolean condition) + { + if (!condition) + { + enabledStates.put(type, false); + } + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/parser/FieldParser.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/parser/FieldParser.java new file mode 100644 index 00000000000..a98da8b0ede --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/parser/FieldParser.java @@ -0,0 +1,18 @@ +package shortestpath.transport.parser; + +/** + * Interface for parsing string field values into typed objects. + * Used by TransportRecord to parse TSV field values. + * + * @param The type of object produced by this parser + */ +public interface FieldParser +{ + /** + * Parses a string value into the target type. + * + * @param value The string value to parse, may be null or empty + * @return The parsed value + */ + T parse(String value); +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/parser/ItemRequirementParser.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/parser/ItemRequirementParser.java new file mode 100644 index 00000000000..a554c647a6f --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/parser/ItemRequirementParser.java @@ -0,0 +1,116 @@ +package shortestpath.transport.parser; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +import lombok.extern.slf4j.Slf4j; +import shortestpath.ItemVariations; +import shortestpath.Util; +import shortestpath.transport.requirement.ItemRequirement; +import shortestpath.transport.requirement.TransportItems; + +/** + * Parses item requirements from TSV field values. + * + *

+ * Format: {@code ITEM_NAME=quantity} with AND (&) and OR (|) operators + *

+ *

+ * Example: {@code AIR_RUNE=3&FIRE_RUNE=2} (need both) + *

+ *

+ * Example: {@code DRAMEN_STAFF=1|LUNAR_STAFF=1} (need either) + *

+ */ +@Slf4j +public class ItemRequirementParser implements FieldParser +{ + private static final String DELIM_STATE = "="; + private static final String DELIM_AND = "&"; + private static final String DELIM_OR = "|"; + + @Override + public TransportItems parse(String value) + { + if (value == null || value.isEmpty()) + { + return null; + } + + // Normalize the input + String normalized = value.replace(" ", "") + .replace(DELIM_AND + DELIM_AND, DELIM_AND) + .replace(DELIM_OR + DELIM_OR, DELIM_OR) + .toUpperCase(); + + List requirements = new ArrayList<>(); + + try + { + // Split by AND to get individual requirements + String[] andParts = normalized.split(DELIM_AND); + + for (String andPart : andParts) + { + ItemRequirement requirement = parseRequirement(andPart); + requirements.add(requirement); + } + + return requirements.isEmpty() ? null : new TransportItems(requirements); + } + catch (NumberFormatException e) + { + log.error("Invalid item or quantity: {}", value); + return null; + } + } + + /** + * Parses a single requirement which may have OR alternatives. + * Example: "AIR_RUNE=3|DUST_RUNE=3" + */ + private ItemRequirement parseRequirement(String part) + { + String[] orParts = part.split(Pattern.quote(DELIM_OR)); + + List itemIdsList = new ArrayList<>(); + List stavesList = new ArrayList<>(); + List offhandsList = new ArrayList<>(); + int maxQuantity = -1; + + for (String orPart : orParts) + { + String[] itemAndQuantity = orPart.split(DELIM_STATE); + if (itemAndQuantity.length != 2) + { + throw new NumberFormatException("Invalid format: " + part); + } + + String itemName = itemAndQuantity[0]; + int quantity = Integer.parseInt(itemAndQuantity[1]); + maxQuantity = Math.max(maxQuantity, quantity); + + ItemVariations variation = ItemVariations.fromName(itemName); + if (variation != null) + { + itemIdsList.add(variation.getIds()); + stavesList.add(ItemVariations.staves(variation)); + offhandsList.add(ItemVariations.offhands(variation)); + } + else + { + // Try parsing as raw item ID + itemIdsList.add(new int[]{Integer.parseInt(itemName)}); + stavesList.add(new int[0]); + offhandsList.add(new int[0]); + } + } + + return new ItemRequirement( + Util.concatenate(itemIdsList.toArray(new int[0][])), + Util.concatenate(stavesList.toArray(new int[0][])), + Util.concatenate(offhandsList.toArray(new int[0][])), + maxQuantity); + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/parser/QuestParser.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/parser/QuestParser.java new file mode 100644 index 00000000000..159964da043 --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/parser/QuestParser.java @@ -0,0 +1,45 @@ +package shortestpath.transport.parser; + +import java.util.HashSet; +import java.util.Set; + +import net.runelite.api.Quest; + +/** + * Parses quest requirements from TSV field values. + * + *

+ * Format: Quest names separated by semicolons + *

+ *

+ * Example: {@code Dragon Slayer I;Recipe for Disaster} + *

+ */ +public class QuestParser implements FieldParser> +{ + private static final String DELIM_MULTI = ";"; + + @Override + public Set parse(String value) + { + Set quests = new HashSet<>(); + if (value == null || value.isEmpty()) + { + return quests; + } + + String[] questNames = value.split(DELIM_MULTI); + for (String questName : questNames) + { + for (Quest quest : Quest.values()) + { + if (quest.getName().equals(questName)) + { + quests.add(quest); + break; + } + } + } + return quests; + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/parser/SkillRequirementParser.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/parser/SkillRequirementParser.java new file mode 100644 index 00000000000..736ae220b8f --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/parser/SkillRequirementParser.java @@ -0,0 +1,88 @@ +package shortestpath.transport.parser; + +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.Skill; + +/** + * Parses skill level requirements from TSV field values. + * + *

+ * Format: {@code level skillName} (space-separated), multiple separated by + * semicolons + *

+ *

+ * Example: {@code 70 Agility;50 Strength} + *

+ *

+ * Special skills: "Total level", "Combat level", "Quest points" + *

+ */ +@Slf4j +public class SkillRequirementParser implements FieldParser +{ + private static final String DELIM_SPACE = " "; + private static final String DELIM_MULTI = ";"; + + @Override + public int[] parse(String value) + { + int[] skillLevels = new int[Skill.values().length + 3]; + + if (value == null) + { + return skillLevels; + } + + String[] skillRequirements = value.split(DELIM_MULTI); + + try + { + for (String requirement : skillRequirements) + { + if (requirement.isEmpty()) + { + continue; + } + String[] levelAndSkill = requirement.split(DELIM_SPACE); + if (levelAndSkill.length != 2) + { + log.error("Invalid level and skill: '{}'", requirement); + continue; + } + + int level = Integer.parseInt(levelAndSkill[0]); + String skillName = levelAndSkill[1] == null ? "" : levelAndSkill[1]; + + Skill[] skills = Skill.values(); + int i = 0; + for (; i < skills.length; i++) + { + if (skills[i].getName().equals(skillName)) + { + skillLevels[i] = level; + } + } + if (skillName.toLowerCase().startsWith("total")) + { + skillLevels[i] = level; + } + i++; + if (skillName.toLowerCase().startsWith("combat")) + { + skillLevels[i] = level; + } + i++; + if (skillName.toLowerCase().startsWith("quest")) + { + skillLevels[i] = level; + } + } + } + catch (NumberFormatException e) + { + log.error("Invalid level and skill: {}", value); + } + + return skillLevels; + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/parser/TransportRecord.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/parser/TransportRecord.java new file mode 100644 index 00000000000..60224df23ec --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/parser/TransportRecord.java @@ -0,0 +1,178 @@ +package shortestpath.transport.parser; + +import java.util.Map; + +import lombok.Getter; + +/** + * Represents a single row from a TSV transport file. + * Provides a clean interface to access field values by name. + */ +@Getter +public class TransportRecord +{ + + /** + * -- GETTER -- + * Gets the underlying field map. + */ + private final Map fields; + + public TransportRecord(Map fields) + { + this.fields = Map.copyOf(fields); + } + + /** + * Gets a field value by name, or null if not present. + */ + public String get(String fieldName) + { + return fields.get(fieldName); + } + + /** + * Checks if a field is present and non-empty. + */ + public boolean has(String fieldName) + { + String value = fields.get(fieldName); + return value != null && !value.isEmpty(); + } + + /** + * Checks if a field key exists in the record (may have empty value). + */ + public boolean hasKey(String fieldName) + { + return fields.containsKey(fieldName); + } + + /** + * Gets the origin field value. + */ + public String getOrigin() + { + return get(Fields.ORIGIN); + } + + /** + * Gets the destination field value. + */ + public String getDestination() + { + return get(Fields.DESTINATION); + } + + /** + * Gets the skills field value. + */ + public String getSkills() + { + return get(Fields.SKILLS); + } + + /** + * Gets the items field value. + */ + public String getItems() + { + return get(Fields.ITEMS); + } + + /** + * Gets the quests field value. + */ + public String getQuests() + { + return get(Fields.QUESTS); + } + + /** + * Gets the duration field value. + */ + public String getDuration() + { + return get(Fields.DURATION); + } + + /** + * Gets the display info field value. + */ + public String getDisplayInfo() + { + return get(Fields.DISPLAY_INFO); + } + + /** + * Gets the consumable field value. + */ + public String getConsumable() + { + return get(Fields.CONSUMABLE); + } + + /** + * Gets the wilderness level field value. + */ + public String getWildernessLevel() + { + return get(Fields.WILDERNESS_LEVEL); + } + + /** + * Gets the object info field value. + */ + public String getObjectInfo() + { + return get(Fields.OBJECT_INFO); + } + + /** + * Gets the varbits field value. + */ + public String getVarbits() + { + return get(Fields.VARBITS); + } + + /** + * Gets the var players field value. + */ + public String getVarPlayers() + { + return get(Fields.VAR_PLAYERS); + } + + /** + * Gets the league region override field value. + */ + public String getRegionOverride() + { + return get(Fields.REGION_OVERRIDE); + } + + /** + * Standard field names used across TSV files + */ + public static final class Fields + { + public static final String ORIGIN = "Origin"; + public static final String DESTINATION = "Destination"; + public static final String SKILLS = "Skills"; + public static final String ITEMS = "Items"; + public static final String QUESTS = "Quests"; + public static final String DURATION = "Duration"; + public static final String DISPLAY_INFO = "Display info"; + public static final String CONSUMABLE = "Consumable"; + public static final String WILDERNESS_LEVEL = "Wilderness level"; + public static final String OBJECT_INFO = "menuOption menuTarget objectID"; + public static final String VARBITS = "Varbits"; + public static final String VAR_PLAYERS = "VarPlayers"; + public static final String REGION_OVERRIDE = "Region override"; + + private Fields() + { + } + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/parser/TsvParser.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/parser/TsvParser.java new file mode 100644 index 00000000000..6c502a5e0de --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/parser/TsvParser.java @@ -0,0 +1,94 @@ +package shortestpath.transport.parser; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Scanner; + +/** + * Parses TSV file contents into TransportRecord objects. + * + *

+ * TSV files should have a header line (optionally starting with #) + * followed by data lines. Empty lines and lines starting with # are ignored. + *

+ */ +public class TsvParser +{ + private static final String DELIM_COLUMN = "\t"; + private static final String PREFIX_COMMENT = "#"; + + /** + * Parses TSV content into a list of TransportRecords. + * The first line must be a header line (optionally starting with #). + */ + public List parse(String contents) + { + List records = new ArrayList<>(); + Scanner scanner = new Scanner(contents); + + if (!scanner.hasNextLine()) + { + scanner.close(); + return records; + } + + // Parse header line + String[] headers = parseHeaderLine(scanner.nextLine()); + + // Parse data lines + while (scanner.hasNextLine()) + { + String line = scanner.nextLine(); + + if (line.startsWith(PREFIX_COMMENT) || line.isBlank()) + { + continue; + } + + TransportRecord record = parseLine(line, headers); + records.add(record); + } + + scanner.close(); + return records; + } + + /** + * Parses the header line, stripping the comment prefix if present. + */ + private String[] parseHeaderLine(String headerLine) + { + String normalized = headerLine; + if (normalized.startsWith(PREFIX_COMMENT + " ")) + { + normalized = normalized.substring(2); + } + else if (normalized.startsWith(PREFIX_COMMENT)) + { + normalized = normalized.substring(1); + } + return normalized.split(DELIM_COLUMN); + } + + /** + * Parses a single data line into a TransportRecord. + */ + private TransportRecord parseLine(String line, String[] headers) + { + // Use -1 limit to preserve trailing empty strings + String[] fields = line.split(DELIM_COLUMN, -1); + Map fieldMap = new HashMap<>(); + + for (int i = 0; i < headers.length; i++) + { + if (i < fields.length) + { + fieldMap.put(headers[i], fields[i]); + } + } + + return new TransportRecord(fieldMap); + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/parser/VarCheckType.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/parser/VarCheckType.java new file mode 100644 index 00000000000..9d440573949 --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/parser/VarCheckType.java @@ -0,0 +1,23 @@ +package shortestpath.transport.parser; + +import lombok.Getter; + +/** + * The type of comparison to perform when checking a variable requirement. + */ +@Getter +public enum VarCheckType +{ + BIT_SET("&"), + COOLDOWN_MINUTES("@"), + EQUAL("="), + GREATER(">"), + SMALLER("<"); + + private final String code; + + VarCheckType(String code) + { + this.code = code; + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/parser/VarRequirement.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/parser/VarRequirement.java new file mode 100644 index 00000000000..264dbbb847d --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/parser/VarRequirement.java @@ -0,0 +1,136 @@ +package shortestpath.transport.parser; + +import java.util.Map; + +import lombok.Getter; + +/** + * Represents a variable-based requirement for a transport. + * This can be either a varbit or a varplayer requirement. + * + *

+ * Consolidates the previously separate TransportVarbit and TransportVarPlayer + * classes which had identical logic. + *

+ */ +@Getter +public class VarRequirement +{ + + private final VarType varType; + private final int id; + private final int value; + private final VarCheckType checkType; + + public VarRequirement(VarType varType, int id, int value, VarCheckType checkType) + { + this.varType = varType; + this.id = id; + this.value = value; + this.checkType = checkType; + } + + /** + * Creates a varbit requirement. + */ + public static VarRequirement varbit(int id, int value, VarCheckType checkType) + { + return new VarRequirement(VarType.VARBIT, id, value, checkType); + } + + /** + * Creates a varplayer requirement. + */ + public static VarRequirement varPlayer(int id, int value, VarCheckType checkType) + { + return new VarRequirement(VarType.VARPLAYER, id, value, checkType); + } + + /** + * Checks if this requirement is satisfied given the current variable values. + * + * @param values A map of variable IDs to their current values + * @return true if the requirement is satisfied + */ + public boolean check(Map values) + { + Integer currentValue = values.get(id); + if (currentValue == null) + { + return false; + } + return checkValue(currentValue); + } + + /** + * Same logic as {@link #check(Map)} but with the variable value already resolved (e.g. from the client). + */ + public boolean checkValue(int currentValue) + { + switch (checkType) + { + case EQUAL: + return currentValue == value; + case GREATER: + return currentValue > value; + case SMALLER: + return currentValue < value; + case BIT_SET: + return (currentValue & value) > 0; + case COOLDOWN_MINUTES: + return ((System.currentTimeMillis() / 60000) - currentValue) > value; + default: + return false; + } + } + + public boolean isVarbit() + { + return varType == VarType.VARBIT; + } + + public boolean isVarPlayer() + { + return varType == VarType.VARPLAYER; + } + + @Override + public int hashCode() + { + int result = varType.hashCode(); + result = 31 * result + id; + result = 31 * result + value; + result = 31 * result + checkType.hashCode(); + return result; + } + + @Override + public boolean equals(Object o) + { + if (this == o) + { + return true; + } + if (o == null || getClass() != o.getClass()) + { + return false; + } + VarRequirement that = (VarRequirement) o; + return id == that.id && value == that.value && varType == that.varType && checkType == that.checkType; + } + + @Override + public String toString() + { + return varType + "[" + id + " " + checkType.getCode() + " " + value + "]"; + } + + /** + * The type of variable this requirement checks. + */ + public enum VarType + { + VARBIT, + VARPLAYER + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/parser/VarRequirementParser.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/parser/VarRequirementParser.java new file mode 100644 index 00000000000..91359340a17 --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/parser/VarRequirementParser.java @@ -0,0 +1,101 @@ +package shortestpath.transport.parser; + +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Pattern; + +import lombok.extern.slf4j.Slf4j; + +/** + * Parses variable requirement strings into VarRequirement objects. + * Supports both varbit and varplayer requirements. + * + *

+ * Format: {@code } where check is one of: = > < & @ + *

+ *

+ * Multiple requirements are separated by semicolons. + *

+ *

+ * Example: {@code 1234=1;5678>10} + *

+ */ +@Slf4j +public class VarRequirementParser implements FieldParser> +{ + private static final String DELIM_MULTI = ";"; + + private final VarRequirement.VarType varType; + + /** + * Creates a parser for the specified variable type. + */ + public VarRequirementParser(VarRequirement.VarType varType) + { + this.varType = varType; + } + + /** + * Creates a parser for varbit requirements. + */ + public static VarRequirementParser forVarbits() + { + return new VarRequirementParser(VarRequirement.VarType.VARBIT); + } + + /** + * Creates a parser for varplayer requirements. + */ + public static VarRequirementParser forVarPlayers() + { + return new VarRequirementParser(VarRequirement.VarType.VARPLAYER); + } + + @Override + public Set parse(String value) + { + Set result = new HashSet<>(); + if (value == null || value.isEmpty()) + { + return result; + } + + try + { + for (String requirement : value.split(DELIM_MULTI)) + { + if (requirement.isEmpty()) + { + continue; + } + + VarRequirement parsed = parseRequirement(requirement); + if (parsed != null) + { + result.add(parsed); + } + } + } + catch (NumberFormatException e) + { + log.error("Invalid var requirement: {}", value); + } + return result; + } + + private VarRequirement parseRequirement(String requirement) + { + for (VarCheckType checkType : VarCheckType.values()) + { + String[] parts = requirement.split(Pattern.quote(checkType.getCode())); + if (parts.length == 2) + { + int id = Integer.parseInt(parts[0]); + int val = Integer.parseInt(parts[1]); + return new VarRequirement(varType, id, val, checkType); + } + } + log.error("Invalid var requirement: '{}'", requirement); + return null; + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/parser/WorldPointParser.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/parser/WorldPointParser.java new file mode 100644 index 00000000000..521055a35ab --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/parser/WorldPointParser.java @@ -0,0 +1,33 @@ +package shortestpath.transport.parser; + +import shortestpath.WorldPointUtil; +import shortestpath.transport.Transport; + +/** + * Parses world point coordinates from TSV field values. + * + *

+ * Format: {@code x y plane} (space-separated) + *

+ *

+ * Empty values are treated as location permutations (for fairy rings, etc.) + *

+ */ +public class WorldPointParser implements FieldParser +{ + private static final String DELIM_SPACE = " "; + + @Override + public Integer parse(String value) + { + if (value == null || value.isEmpty()) + { + return Transport.LOCATION_PERMUTATION; + } + String[] parts = value.split(DELIM_SPACE); + return parts.length == 3 ? WorldPointUtil.packWorldPoint( + Integer.parseInt(parts[0]), + Integer.parseInt(parts[1]), + Integer.parseInt(parts[2])) : Transport.LOCATION_PERMUTATION; + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/requirement/ItemRequirement.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/requirement/ItemRequirement.java new file mode 100644 index 00000000000..8fd52fbe398 --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/requirement/ItemRequirement.java @@ -0,0 +1,70 @@ +package shortestpath.transport.requirement; + +import java.util.Arrays; + +import lombok.Getter; + +/** + * Represents an item requirement with its variations and quantity. + * For example: AIR_RUNE=3 where AIR_RUNE can be substituted by DUST_RUNE, + * SMOKE_RUNE, etc. + */ +@Getter +public class ItemRequirement +{ + /** + * The item IDs that satisfy this requirement (variations) + */ + private final int[] itemIds; + + /** + * Staff IDs that can substitute runes for this requirement + */ + private final int[] staffIds; + + /** + * Offhand IDs that can substitute for this requirement + */ + private final int[] offhandIds; + + /** + * The quantity required + */ + private final int quantity; + + public ItemRequirement(int[] itemIds, int[] staffIds, int[] offhandIds, int quantity) + { + this.itemIds = itemIds; + this.staffIds = staffIds; + this.offhandIds = offhandIds; + this.quantity = quantity; + } + + @Override + public int hashCode() + { + int result = Arrays.hashCode(itemIds); + result = 31 * result + Arrays.hashCode(staffIds); + result = 31 * result + Arrays.hashCode(offhandIds); + result = 31 * result + quantity; + return result; + } + + @Override + public boolean equals(Object o) + { + if (this == o) + { + return true; + } + if (o == null || getClass() != o.getClass()) + { + return false; + } + ItemRequirement that = (ItemRequirement) o; + return quantity == that.quantity && + Arrays.equals(itemIds, that.itemIds) && + Arrays.equals(staffIds, that.staffIds) && + Arrays.equals(offhandIds, that.offhandIds); + } +} diff --git a/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/requirement/TransportItems.java b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/requirement/TransportItems.java new file mode 100644 index 00000000000..8e4dcc4c552 --- /dev/null +++ b/runelite-client/src/upstreamPlanner/src/main/java/shortestpath/transport/requirement/TransportItems.java @@ -0,0 +1,161 @@ +package shortestpath.transport.requirement; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import lombok.Getter; + +/** + * Represents all item requirements for a transport. + * All requirements must be satisfied. + */ +@Getter +public class TransportItems +{ + private final List requirements; + + public TransportItems(List requirements) + { + this.requirements = List.copyOf(requirements); + } + + /** + * Creates TransportItems from legacy array format for backwards compatibility. + */ + public TransportItems(int[][] items, int[][] staves, int[][] offhands, int[] quantities) + { + List reqs = new ArrayList<>(); + for (int i = 0; i < items.length; i++) + { + reqs.add(new ItemRequirement( + items[i], + staves != null && i < staves.length ? staves[i] : new int[0], + offhands != null && i < offhands.length ? offhands[i] : new int[0], + quantities[i])); + } + this.requirements = Collections.unmodifiableList(reqs); + } + + /** + * Merges two TransportItems, combining all requirements from both. + * If either is null, returns the other. + */ + public static TransportItems merge(TransportItems first, TransportItems second) + { + if (first == null) + { + return second; + } + if (second == null) + { + return first; + } + List merged = new ArrayList<>(); + merged.addAll(first.requirements); + merged.addAll(second.requirements); + return new TransportItems(merged); + } + + /** + * Gets the number of item requirements. + */ + public int size() + { + return requirements.size(); + } + + // Legacy getters for backwards compatibility + public int[][] getItems() + { + int[][] items = new int[requirements.size()][]; + for (int i = 0; i < requirements.size(); i++) + { + items[i] = requirements.get(i).getItemIds(); + } + return items; + } + + public int[][] getStaves() + { + int[][] staves = new int[requirements.size()][]; + for (int i = 0; i < requirements.size(); i++) + { + staves[i] = requirements.get(i).getStaffIds(); + } + return staves; + } + + public int[][] getOffhands() + { + int[][] offhands = new int[requirements.size()][]; + for (int i = 0; i < requirements.size(); i++) + { + offhands[i] = requirements.get(i).getOffhandIds(); + } + return offhands; + } + + public int[] getQuantities() + { + int[] quantities = new int[requirements.size()]; + for (int i = 0; i < requirements.size(); i++) + { + quantities[i] = requirements.get(i).getQuantity(); + } + return quantities; + } + + private String toString(int[][] array) + { + StringBuilder text = new StringBuilder(); + for (int[] inner : array) + { + text.append((text.length() == 0) ? "" : ", ").append(Arrays.toString(inner)); + } + return "[" + text + "]"; + } + + @Override + public int hashCode() + { + return requirements.hashCode(); + } + + @Override + public boolean equals(Object o) + { + if (this == o) + { + return true; + } + if (o == null || getClass() != o.getClass()) + { + return false; + } + TransportItems that = (TransportItems) o; + if (requirements.size() != that.requirements.size()) + { + return false; + } + for (int i = 0; i < requirements.size(); i++) + { + if (!requirements.get(i).equals(that.requirements.get(i))) + { + return false; + } + } + return true; + } + + @Override + public String toString() + { + return "[" + + toString(getItems()) + ", " + + toString(getStaves()) + ", " + + toString(getOffhands()) + ", " + + Arrays.toString(getQuantities()) + "]"; + } +} diff --git a/scripts/check-shortest-path-boundary.py b/scripts/check-shortest-path-boundary.py new file mode 100755 index 00000000000..c71efcf3ef9 --- /dev/null +++ b/scripts/check-shortest-path-boundary.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Reject shortest-path plugin-state access outside Microbot's compatibility seam.""" + +from __future__ import annotations + +import argparse +import re +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_SOURCE_ROOT = ( + REPOSITORY_ROOT + / "runelite-client/src/main/java/net/runelite/client/plugins/microbot" +) +FACADE = Path("util/walker/Rs2PathApi.java") +PLUGIN_IDENTITY = Path("breakhandler/breakhandlerv2/MicrobotPluginChoice.java") +PLUGIN_IMPORT = ( + "import net.runelite.client.plugins.microbot.shortestpath.ShortestPathPlugin;" +) +PLUGIN_REFERENCE = re.compile(r"\bShortestPathPlugin\s*(?:\.|;)") +DIRECT_PATHFINDER_REFERENCE = re.compile( + r"import\s+net\.runelite\.client\.plugins\.microbot\.shortestpath\.pathfinder\.Pathfinder\s*;" + r"|\bnew\s+Pathfinder\s*\(" +) +DIRECT_PATHFINDER_CONSTRUCTION = re.compile(r"\bnew\s+Pathfinder\s*\(") +DIRECT_PATHFINDER_CONFIG_REFERENCE = re.compile( + r"import\s+net\.runelite\.client\.plugins\.microbot\.shortestpath\.pathfinder\.PathfinderConfig\s*;" + r"|\bRs2PathApi\s*\.\s*getPathfinderConfig\s*\(" +) +DIRECT_ACTIVE_PATHFINDER_REFERENCE = re.compile( + r"\bRs2PathApi\s*\.\s*getPathfinder\s*\(" +) +LEGACY_ROUTE_HANDOFF_REFERENCE = re.compile(r"\b(?:LegacyRoutePlan|planLegacy)\b") +BANK_ROUTE_REPLAN_REFERENCE = re.compile( + r"getMissingTransportEdges\s*\(\s*getTransportEdgesForDestination\s*\(" +) +SHORTEST_PATH_IMPORT = re.compile( + r"^\s*import\s+net\.runelite\.client\.plugins\.microbot\.shortestpath(?:\.|;)" +) +CONCRETE_TRANSPORT_IMPORT = re.compile( + r"^\s*import\s+net\.runelite\.client\.plugins\.microbot\.shortestpath\.Transport\s*;" +) +EXECUTOR_REGISTRY_REFERENCE = re.compile(r"\bTransportExecutionRegistry\b") +PLANNER_CORE = Path("shortestpath/pathfinder") +MIGRATED_SYNCHRONOUS_PLANNING_SCOPES = ( + Path("util/bank"), + Path("util/depositbox"), + Path("util/leaguetransport"), + Path("util/npc"), + Path("util/skills/slayer"), + Path("util/walker/banking"), + Path("util/walker/lifecycle"), +) +MIGRATED_SYNCHRONOUS_PLANNING_FILES = { + Path("util/walker/Rs2Walker.java"), +} +MIGRATED_CONFIG_FILES = { + Path("util/walker/Rs2Walker.java"), +} +MIGRATED_CONCRETE_TRANSPORT_SCOPES = ( + Path("util/walker/door"), + Path("util/walker/obstacle"), + Path("util/walker/recovery"), +) +MIGRATED_CONCRETE_TRANSPORT_FILES = { + Path("util/walker/Rs2HotAirBalloon.java"), +} +OWNED_ROUTE_VALUES = { + Path("util/walker/Rs2ActiveRouteStatus.java"), + Path("util/walker/Rs2RouteRequest.java"), + Path("util/walker/Rs2RouteResult.java"), + Path("util/walker/Rs2RouteMetrics.java"), + Path("util/walker/Rs2PlanningSnapshot.java"), + Path("util/walker/Rs2PlannerShadowComparison.java"), + Path("util/walker/Rs2PlannerShadowStats.java"), + Path("util/walker/Rs2RoutePlanner.java"), + Path("util/walker/Rs2RoutePolicy.java"), + Path("util/walker/Rs2RouteStep.java"), + Path("util/walker/Rs2RouteTermination.java"), + Path("util/walker/Rs2TransportEdge.java"), + Path("util/walker/Rs2TransportExecutor.java"), + Path("util/walker/Rs2TransportItemRequirement.java"), + Path("util/walker/Rs2TransportLoadout.java"), + Path("util/walker/Rs2TransportType.java"), +} + + +def allowed_identity_reference(line: str) -> bool: + stripped = line.strip() + if stripped == PLUGIN_IMPORT: + return True + without_class_literal = re.sub( + r"\bShortestPathPlugin\s*\.\s*class\b", "", line + ) + return without_class_literal != line and not PLUGIN_REFERENCE.search( + without_class_literal + ) + + +def violations(source_root: Path) -> list[str]: + failures: list[str] = [] + for java_file in sorted(source_root.rglob("*.java")): + relative = java_file.relative_to(source_root) + if relative.is_relative_to(PLANNER_CORE): + for line_number, line in enumerate( + java_file.read_text(encoding="utf-8").splitlines(), start=1 + ): + if EXECUTOR_REGISTRY_REFERENCE.search(line): + failures.append( + f"planner-executor-coupling {relative}:{line_number}: {line.strip()}" + ) + if relative.parts and relative.parts[0] == "shortestpath": + continue + lines = java_file.read_text(encoding="utf-8").splitlines() + source = "\n".join(lines) + for match in BANK_ROUTE_REPLAN_REFERENCE.finditer(source): + line_number = source.count("\n", 0, match.start()) + 1 + failures.append( + f"bank-route-replan {relative}:{line_number}: " + "consume exact compared bank-route edges instead" + ) + for line_number, line in enumerate(lines, start=1): + if LEGACY_ROUTE_HANDOFF_REFERENCE.search(line): + failures.append( + f"legacy-route-handoff {relative}:{line_number}: {line.strip()}" + ) + if relative == FACADE: + continue + + in_migrated_scope = any( + relative.is_relative_to(scope) + for scope in MIGRATED_SYNCHRONOUS_PLANNING_SCOPES + ) + is_owned_route_value = relative in OWNED_ROUTE_VALUES + in_migrated_transport_scope = any( + relative.is_relative_to(scope) + for scope in MIGRATED_CONCRETE_TRANSPORT_SCOPES + ) + + for line_number, line in enumerate(lines, start=1): + if PLUGIN_REFERENCE.search(line): + if not ( + relative == PLUGIN_IDENTITY and allowed_identity_reference(line) + ): + failures.append( + f"plugin-state {relative}:{line_number}: {line.strip()}" + ) + + if in_migrated_scope and DIRECT_PATHFINDER_REFERENCE.search(line): + failures.append( + f"direct-pathfinder {relative}:{line_number}: {line.strip()}" + ) + + if in_migrated_scope and DIRECT_PATHFINDER_CONFIG_REFERENCE.search(line): + failures.append( + f"direct-pathfinder-config {relative}:{line_number}: {line.strip()}" + ) + + if ( + relative in MIGRATED_CONFIG_FILES + and DIRECT_PATHFINDER_CONFIG_REFERENCE.search(line) + ): + failures.append( + f"direct-pathfinder-config {relative}:{line_number}: {line.strip()}" + ) + + if DIRECT_ACTIVE_PATHFINDER_REFERENCE.search(line): + failures.append( + f"direct-active-pathfinder {relative}:{line_number}: {line.strip()}" + ) + + if ( + relative in MIGRATED_SYNCHRONOUS_PLANNING_FILES + and DIRECT_PATHFINDER_CONSTRUCTION.search(line) + ): + failures.append( + f"direct-pathfinder-construction {relative}:{line_number}: {line.strip()}" + ) + + if is_owned_route_value and SHORTEST_PATH_IMPORT.search(line): + failures.append( + f"owned-value-dependency {relative}:{line_number}: {line.strip()}" + ) + + if ( + (in_migrated_transport_scope or relative in MIGRATED_CONCRETE_TRANSPORT_FILES) + and CONCRETE_TRANSPORT_IMPORT.search(line) + ): + failures.append( + f"concrete-transport {relative}:{line_number}: {line.strip()}" + ) + return failures + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--source-root", + type=Path, + default=DEFAULT_SOURCE_ROOT, + help="Microbot Java source root (used by the regression tests)", + ) + args = parser.parse_args() + + failures = violations(args.source_root.resolve()) + if failures: + print("Shortest-path boundary violations (use util/walker/Rs2PathApi):") + for failure in failures: + print(f" {failure}") + return 1 + + print("shortest-path plugin-state boundary is clean") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-shortest-path-upstream.py b/scripts/check-shortest-path-upstream.py new file mode 100755 index 00000000000..b4bf523dea1 --- /dev/null +++ b/scripts/check-shortest-path-upstream.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Report whether Skretzo/shortest-path changed since Microbot's reviewed baseline.""" + +import argparse +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + + +BASELINE_PATH = Path(__file__).with_name("shortest-path-upstream-baseline.json") +GITHUB_API = "https://api.github.com" + + +def github_json(path: str) -> object: + request = urllib.request.Request( + GITHUB_API + path, + headers={ + "Accept": "application/vnd.github+json", + "User-Agent": "microbot-shortest-path-drift-checker", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + token = os.environ.get("GITHUB_TOKEN") + if token: + request.add_header("Authorization", f"Bearer {token}") + with urllib.request.urlopen(request, timeout=30) as response: + return json.load(response) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--allow-drift", + action="store_true", + help="report drift but return success (useful for scheduled informational checks)", + ) + parser.add_argument("--json", action="store_true", help="emit machine-readable JSON") + args = parser.parse_args() + + baseline = json.loads(BASELINE_PATH.read_text(encoding="utf-8")) + repository = baseline["repository"] + branch = baseline["branch"] + reviewed = baseline["reviewedCommit"] + + try: + branch_data = github_json( + f"/repos/{repository}/commits/{urllib.parse.quote(branch, safe='')}" + ) + current = branch_data["sha"] + changed_files = [] + compare_status = "identical" + total_commits = 0 + if current != reviewed: + comparison = github_json(f"/repos/{repository}/compare/{reviewed}...{current}") + compare_status = comparison["status"] + total_commits = comparison["total_commits"] + changed_files = [entry["filename"] for entry in comparison.get("files", [])] + except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, KeyError) as error: + print(f"Unable to check shortest-path upstream: {error}", file=sys.stderr) + return 1 + + scopes = baseline["trackedScopes"] + relevant = [] + for filename in changed_files: + matches = [scope for scope in scopes if filename.startswith(scope["upstreamPrefix"])] + if matches: + relevant.append( + { + "path": filename, + "policies": sorted({match["policy"] for match in matches}), + } + ) + + result = { + "repository": repository, + "branch": branch, + "reviewedCommit": reviewed, + "currentCommit": current, + "status": compare_status, + "commitsSinceReview": total_commits, + "changedFiles": changed_files, + "relevantChanges": relevant, + "drifted": current != reviewed, + } + + if args.json: + print(json.dumps(result, indent=2, sort_keys=True)) + elif current == reviewed: + print(f"shortest-path is aligned with reviewed commit {reviewed}") + else: + print(f"shortest-path drift detected: {reviewed} -> {current} ({total_commits} commits)") + if relevant: + print("Relevant changes:") + for entry in relevant: + print(f" {entry['path']} [{', '.join(entry['policies'])}]") + else: + print("No files in a tracked planner/data scope changed.") + + return 0 if current == reviewed or args.allow_drift else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-shortest-path-vendored-core.py b/scripts/check-shortest-path-vendored-core.py new file mode 100755 index 00000000000..c9359597a36 --- /dev/null +++ b/scripts/check-shortest-path-vendored-core.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""Verify the packaged Shortest Path planner pin and declared adapter patch surface.""" + +import argparse +import hashlib +import json +import subprocess +import sys +from pathlib import Path +from typing import Any + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_ROOT = REPOSITORY_ROOT / "runelite-client" / "src" / "upstreamPlanner" +DEFAULT_BASELINE = Path(__file__).with_name( + "shortest-path-vendored-core-baseline.json" +) +SOURCE_PREFIX = Path("src/main/java") +METADATA_FILES = ("ADAPTER_PATCHES.md", "LICENSE", "README.md", "UPSTREAM_REVISION") + + +def file_sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def source_manifest(root: Path) -> dict[str, str]: + source_root = root / SOURCE_PREFIX + return { + path.relative_to(source_root).as_posix(): file_sha256(path) + for path in sorted(source_root.rglob("*.java")) + } + + +def manifest_digest(manifest: dict[str, str]) -> str: + digest = hashlib.sha256() + for relative_path, file_digest in sorted(manifest.items()): + digest.update(relative_path.encode("utf-8")) + digest.update(b"\0") + digest.update(file_digest.encode("ascii")) + digest.update(b"\n") + return digest.hexdigest() + + +def current_baseline_values(root: Path) -> dict[str, Any]: + manifest = source_manifest(root) + return { + "sourceFileCount": len(manifest), + "sourceTreeSha256": manifest_digest(manifest), + "metadataSha256": { + name: file_sha256(root / name) for name in METADATA_FILES + }, + } + + +def verify_offline(root: Path, baseline: dict[str, Any]) -> list[str]: + failures: list[str] = [] + revision_path = root / "UPSTREAM_REVISION" + revision = revision_path.read_text(encoding="utf-8").strip() + expected_revision = baseline.get("revision") + if revision != expected_revision: + failures.append( + f"UPSTREAM_REVISION is {revision!r}, expected {expected_revision!r}" + ) + + current = current_baseline_values(root) + if current["sourceFileCount"] != baseline.get("sourceFileCount"): + failures.append( + "vendored Java source count is " + f"{current['sourceFileCount']}, expected {baseline.get('sourceFileCount')}" + ) + if current["sourceTreeSha256"] != baseline.get("sourceTreeSha256"): + failures.append( + "vendored Java source tree digest changed: " + f"{current['sourceTreeSha256']} != {baseline.get('sourceTreeSha256')}" + ) + + expected_metadata = baseline.get("metadataSha256", {}) + for name, digest in current["metadataSha256"].items(): + if digest != expected_metadata.get(name): + failures.append( + f"vendored metadata {name} digest changed: " + f"{digest} != {expected_metadata.get(name)}" + ) + + manifest = source_manifest(root) + patched = set(baseline.get("patchedUpstreamFiles", [])) + added = set(baseline.get("adapterAddedFiles", [])) + patch_policy = baseline.get("patchSurfacePolicy") + if not isinstance(patch_policy, dict): + failures.append("patchSurfacePolicy must be an object") + else: + maximum_patched = patch_policy.get("maximumPatchedUpstreamFiles") + maximum_added = patch_policy.get("maximumAdapterAddedFiles") + if not isinstance(maximum_patched, int) or isinstance(maximum_patched, bool) \ + or maximum_patched < 0: + failures.append( + "patchSurfacePolicy.maximumPatchedUpstreamFiles must be a non-negative integer" + ) + elif len(patched) > maximum_patched: + failures.append( + f"patched upstream file count {len(patched)} exceeds reviewed budget " + f"{maximum_patched}" + ) + if not isinstance(maximum_added, int) or isinstance(maximum_added, bool) \ + or maximum_added < 0: + failures.append( + "patchSurfacePolicy.maximumAdapterAddedFiles must be a non-negative integer" + ) + elif len(added) > maximum_added: + failures.append( + f"adapter-added file count {len(added)} exceeds reviewed budget {maximum_added}" + ) + if patch_policy.get("growthRequiresAdrAmendment") is not True: + failures.append( + "patchSurfacePolicy.growthRequiresAdrAmendment must be true" + ) + overlap = patched & added + if overlap: + failures.append( + f"files cannot be both patched and adapter-added: {sorted(overlap)}" + ) + missing_declared = (patched | added) - set(manifest) + if missing_declared: + failures.append( + f"declared adapter files are missing: {sorted(missing_declared)}" + ) + return failures + + +def git_head(checkout: Path) -> str: + completed = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=checkout, + check=True, + capture_output=True, + text=True, + ) + return completed.stdout.strip() + + +def verify_against_checkout( + root: Path, baseline: dict[str, Any], checkout: Path +) -> list[str]: + failures: list[str] = [] + expected_revision = baseline["revision"] + try: + checkout_revision = git_head(checkout) + except (OSError, subprocess.CalledProcessError) as error: + return [f"cannot read upstream checkout revision: {error}"] + if checkout_revision != expected_revision: + failures.append( + f"upstream checkout is {checkout_revision}, expected {expected_revision}" + ) + + manifest = source_manifest(root) + patched = set(baseline.get("patchedUpstreamFiles", [])) + added = set(baseline.get("adapterAddedFiles", [])) + upstream_source = checkout / SOURCE_PREFIX + observed_patched: set[str] = set() + observed_added: set[str] = set() + for relative_path in manifest: + vendored_path = root / SOURCE_PREFIX / relative_path + upstream_path = upstream_source / relative_path + if not upstream_path.exists(): + observed_added.add(relative_path) + if relative_path not in added: + failures.append( + f"undeclared adapter-added source: {relative_path}" + ) + continue + if vendored_path.read_bytes() != upstream_path.read_bytes(): + observed_patched.add(relative_path) + if relative_path not in patched: + failures.append(f"undeclared upstream source patch: {relative_path}") + + stale_patches = patched - observed_patched + if stale_patches: + failures.append( + f"declared patches no longer differ from upstream: {sorted(stale_patches)}" + ) + stale_added = added - observed_added + if stale_added: + failures.append( + f"declared adapter-added files now exist upstream: {sorted(stale_added)}" + ) + if (root / "LICENSE").read_bytes() != (checkout / "LICENSE").read_bytes(): + failures.append("vendored LICENSE does not match the pinned upstream checkout") + return failures + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--baseline", type=Path, default=DEFAULT_BASELINE) + parser.add_argument("--vendored-root", type=Path, default=DEFAULT_ROOT) + parser.add_argument( + "--upstream-checkout", + type=Path, + help="also prove undeclared files are byte-identical to this pinned checkout", + ) + parser.add_argument( + "--print-current", + action="store_true", + help="print current digest fields for an explicitly reviewed baseline update", + ) + args = parser.parse_args() + + if args.print_current: + print(json.dumps(current_baseline_values(args.vendored_root), indent=2)) + return 0 + + try: + baseline = json.loads(args.baseline.read_text(encoding="utf-8")) + failures = verify_offline(args.vendored_root, baseline) + if args.upstream_checkout: + failures.extend( + verify_against_checkout( + args.vendored_root, baseline, args.upstream_checkout + ) + ) + except (OSError, KeyError, ValueError, json.JSONDecodeError) as error: + print(f"Unable to verify vendored shortest-path core: {error}", file=sys.stderr) + return 1 + + if failures: + print("Vendored shortest-path core verification failed:", file=sys.stderr) + for failure in failures: + print(f" - {failure}", file=sys.stderr) + return 1 + print( + "vendored shortest-path core matches reviewed revision " + f"{baseline['revision']} with {baseline['sourceFileCount']} Java files" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/compare-shortest-path-planners.py b/scripts/compare-shortest-path-planners.py new file mode 100755 index 00000000000..6a0dce5bd93 --- /dev/null +++ b/scripts/compare-shortest-path-planners.py @@ -0,0 +1,447 @@ +#!/usr/bin/env python3 +"""Run Microbot and reviewed upstream planners against one immutable headless corpus.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Any + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +CORPUS_PATH = Path(__file__).with_name("shortest-path-planner-corpus.json") +UPSTREAM_BASELINE_PATH = Path(__file__).with_name( + "shortest-path-upstream-baseline.json" +) +HARNESS_ROOT = Path(__file__).with_name("shortest-path-planner-harness") +UPSTREAM_RUNNER = HARNESS_ROOT / "UpstreamPlannerComparisonMain.java" +UPSTREAM_INIT = HARNESS_ROOT / "upstream-planner-comparison.init.gradle" +UPSTREAM_IDENTITY_PATCH = HARNESS_ROOT / "upstream-exact-transport-identity.patch" +UPSTREAM_REPOSITORY = "https://github.com/Skretzo/shortest-path.git" + + +def load_json(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def run(command: list[str], cwd: Path) -> None: + subprocess.run(command, cwd=cwd, check=True) + + +def capture(command: list[str], cwd: Path) -> str: + return subprocess.run( + command, + cwd=cwd, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +def reviewed_commit() -> str: + baseline = load_json(UPSTREAM_BASELINE_PATH) + commit = baseline.get("reviewedCommit") + if not isinstance(commit, str) or len(commit) != 40: + raise ValueError("upstream baseline has no valid reviewedCommit") + return commit + + +def runelite_version() -> str: + properties = REPOSITORY_ROOT / "gradle.properties" + for line in properties.read_text(encoding="utf-8").splitlines(): + if line.startswith("project.build.version="): + return line.split("=", 1)[1].strip() + raise ValueError("gradle.properties has no project.build.version") + + +def prepare_upstream( + destination: Path, commit: str, checkout: Path | None +) -> None: + source = str(checkout.resolve()) if checkout else UPSTREAM_REPOSITORY + run(["git", "clone", "--quiet", "--no-checkout", source, str(destination)], + REPOSITORY_ROOT) + run(["git", "checkout", "--quiet", "--detach", commit], destination) + actual = capture(["git", "rev-parse", "HEAD"], destination) + if actual != commit: + raise RuntimeError(f"upstream checkout mismatch: expected {commit}, got {actual}") + + run(["git", "apply", "--check", str(UPSTREAM_IDENTITY_PATCH)], destination) + run(["git", "apply", str(UPSTREAM_IDENTITY_PATCH)], destination) + + target = ( + destination + / "src/test/java/shortestpath/pathfinder/UpstreamPlannerComparisonMain.java" + ) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(UPSTREAM_RUNNER, target) + + +def point_distance(point: dict[str, int] | None, target: dict[str, int]) -> int | None: + if point is None or point.get("plane") != target.get("plane"): + return None + return max(abs(point["x"] - target["x"]), abs(point["y"] - target["y"])) + + +def case_map(result: dict[str, Any]) -> dict[str, dict[str, Any]]: + cases = result.get("cases") + if not isinstance(cases, list): + raise ValueError("planner result has no cases array") + mapped: dict[str, dict[str, Any]] = {} + for case in cases: + case_id = case.get("id") + if not isinstance(case_id, str) or case_id in mapped: + raise ValueError(f"invalid or duplicate planner case id: {case_id!r}") + mapped[case_id] = case + return mapped + + +def selected_transport_ids(result: dict[str, Any]) -> list[str]: + selected = result.get("selectedTransports") + if not isinstance(selected, list): + raise ValueError("planner result has no selectedTransports array") + ids: list[str] = [] + for edge in selected: + edge_id = edge.get("id") if isinstance(edge, dict) else None + if not isinstance(edge_id, str): + raise ValueError(f"invalid selected transport edge: {edge!r}") + ids.append(edge_id) + return ids + + +def compare_upstream_adapters( + corpus: dict[str, Any], embedded: dict[str, Any], external: dict[str, Any] +) -> tuple[list[str], list[str]]: + """Prove the packaged adapter preserves the independently compiled pinned engine.""" + embedded_cases = case_map(embedded) + external_cases = case_map(external) + failures: list[str] = [] + expected_policy_divergences: list[str] = [] + if set(embedded_cases) != set(external_cases): + failures.append( + "embedded/external upstream case sets differ: " + f"{sorted(embedded_cases)} != {sorted(external_cases)}" + ) + return failures, expected_policy_divergences + definitions = {case["id"]: case for case in corpus.get("cases", [])} + compared_fields = ( + "supported", + "unsupportedReason", + "termination", + "reached", + "endpoint", + "pathLength", + "pathCost", + "selectedTransports", + "bankVisited", + ) + for case_id in sorted(embedded_cases): + packaged = embedded_cases[case_id] + independent = external_cases[case_id] + definition = definitions.get(case_id, {}) + if not bool(definition.get("expectedParity", True)): + reason = definition.get("expectedDivergenceReason") + expected_policy_divergences.append(f"{case_id}: {reason}") + continue + for field in compared_fields: + if packaged.get(field) != independent.get(field): + failures.append( + f"{case_id}: packaged upstream {field}={packaged.get(field)!r} " + f"!= independent upstream {independent.get(field)!r}" + ) + return failures, expected_policy_divergences + + +def compare_results( + corpus: dict[str, Any], + local: dict[str, Any], + upstream: dict[str, Any], + require_all: bool, +) -> tuple[dict[str, Any], int]: + schema_version = corpus.get("schemaVersion") + if schema_version != 3: + raise ValueError(f"unsupported planner corpus schema: {schema_version!r}") + for engine, result in (("local", local), ("upstream", upstream)): + if result.get("schemaVersion") != schema_version: + raise ValueError( + f"{engine} result schema {result.get('schemaVersion')!r} " + f"does not match corpus schema {schema_version}" + ) + local_cases = case_map(local) + upstream_cases = case_map(upstream) + comparisons: list[dict[str, Any]] = [] + failures: list[str] = [] + unsupported: list[str] = [] + + for definition in corpus.get("cases", []): + case_id = definition["id"] + local_case = local_cases.get(case_id) + upstream_case = upstream_cases.get(case_id) + if local_case is None or upstream_case is None: + failures.append(f"{case_id}: missing result from one or both engines") + continue + + supported = bool(local_case.get("supported")) and bool( + upstream_case.get("supported") + ) + row: dict[str, Any] = { + "id": case_id, + "category": definition.get("category"), + "supported": supported, + "local": local_case, + "upstream": upstream_case, + } + if not supported: + reason = ( + local_case.get("unsupportedReason") + or upstream_case.get("unsupportedReason") + or "engine did not provide a reason" + ) + unsupported.append(f"{case_id}: {reason}") + row["status"] = "UNSUPPORTED" + comparisons.append(row) + continue + + case_failures: list[str] = [] + expected_reached = bool(definition.get("expectedReached")) + expected_bank_visited = bool(definition.get("expectedBankVisited", False)) + transport_mode = definition.get("policy", {}).get("transportMode") + expected_transport_ids = { + "local": definition.get( + "expectedLocalTransportIds", + definition.get("expectedTransportIds", []), + ), + "upstream": definition.get( + "expectedUpstreamTransportIds", + definition.get("expectedTransportIds", []), + ), + } + expected_parity = bool(definition.get("expectedParity", True)) + divergence_reason = definition.get("expectedDivergenceReason") + if not expected_parity and not isinstance(divergence_reason, str): + case_failures.append("expected divergence has no documented reason") + row["expectedParity"] = expected_parity + row["expectedDivergenceReason"] = divergence_reason + for engine, result in (("local", local_case), ("upstream", upstream_case)): + if bool(result.get("reached")) != expected_reached: + case_failures.append( + f"{engine} reached={result.get('reached')} expected={expected_reached}" + ) + if bool(result.get("bankVisited")) != expected_bank_visited: + case_failures.append( + f"{engine} bankVisited={result.get('bankVisited')} " + f"expected={expected_bank_visited}" + ) + actual_transport_ids = selected_transport_ids(result) + if actual_transport_ids != expected_transport_ids[engine]: + case_failures.append( + f"{engine} selected transports {actual_transport_ids} " + f"expected {expected_transport_ids[engine]}" + ) + if transport_mode == "STATIC_COLLISION_ONLY" and result.get("transportsChecked") != 0: + case_failures.append( + f"{engine} checked transports in STATIC_COLLISION_ONLY policy" + ) + + if expected_parity: + if local_case.get("reached") != upstream_case.get("reached"): + case_failures.append("reachability differs") + if local_case.get("termination") != upstream_case.get("termination"): + case_failures.append( + "termination differs: " + f"{local_case.get('termination')} != {upstream_case.get('termination')}" + ) + if local_case.get("selectedTransports") != upstream_case.get("selectedTransports"): + case_failures.append("exact selected transport edges differ") + if local_case.get("bankVisited") != upstream_case.get("bankVisited"): + case_failures.append("bank-visited state differs") + else: + compared_fields = ( + "reached", + "termination", + "selectedTransports", + "bankVisited", + "pathCost", + ) + if all(local_case.get(field) == upstream_case.get(field) for field in compared_fields): + case_failures.append("documented planner divergence was not observed") + + target = definition["target"] + row["localEndpointDistance"] = point_distance(local_case.get("endpoint"), target) + row["upstreamEndpointDistance"] = point_distance( + upstream_case.get("endpoint"), target + ) + if expected_reached: + if local_case.get("endpoint") != target: + case_failures.append("local reached a non-target endpoint") + if upstream_case.get("endpoint") != target: + case_failures.append("upstream reached a non-target endpoint") + if expected_parity and local_case.get("pathCost") != upstream_case.get("pathCost"): + case_failures.append( + "reached-path cost differs: " + f"{local_case.get('pathCost')} != {upstream_case.get('pathCost')}" + ) + + row["status"] = ( + "FAIL" + if case_failures + else "PASS" if expected_parity else "EXPECTED_DIVERGENCE" + ) + row["differences"] = case_failures + comparisons.append(row) + failures.extend(f"{case_id}: {failure}" for failure in case_failures) + + extra_local = sorted(set(local_cases) - {case["id"] for case in corpus["cases"]}) + extra_upstream = sorted( + set(upstream_cases) - {case["id"] for case in corpus["cases"]} + ) + if extra_local or extra_upstream: + failures.append( + f"unexpected cases: local={extra_local}, upstream={extra_upstream}" + ) + + report = { + "schemaVersion": schema_version, + "localRevision": local.get("revision"), + "upstreamRevision": upstream.get("revision"), + "comparisons": comparisons, + "failures": failures, + "unsupported": unsupported, + } + if failures: + return report, 1 + if unsupported and require_all: + return report, 2 + return report, 0 + + +def print_summary(report: dict[str, Any]) -> None: + for comparison in report["comparisons"]: + local = comparison["local"] + upstream = comparison["upstream"] + print( + f"{comparison['status']:>19} {comparison['id']:<40} " + f"cost={local.get('pathCost')}/{upstream.get('pathCost')} " + f"nodes={local.get('nodesChecked')}/{upstream.get('nodesChecked')} " + f"transports={selected_transport_ids(local)}/" + f"{selected_transport_ids(upstream)} " + f"bank={local.get('bankVisited')}/{upstream.get('bankVisited')} " + f"ms={local.get('elapsedNanos', -1) / 1_000_000:.1f}/" + f"{upstream.get('elapsedNanos', -1) / 1_000_000:.1f}" + ) + for failure in report["failures"]: + print(f"FAIL: {failure}", file=sys.stderr) + for blocked in report["unsupported"]: + print(f"UNSUPPORTED: {blocked}", file=sys.stderr) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--upstream-checkout", + type=Path, + help="Existing Skretzo/shortest-path clone; copied before harness injection", + ) + parser.add_argument( + "--output-dir", + type=Path, + default=REPOSITORY_ROOT / "build/shortest-path-comparison", + ) + parser.add_argument( + "--require-all", + action="store_true", + help="Exit 2 when either engine explicitly rejects a corpus capability", + ) + args = parser.parse_args() + + commit = reviewed_commit() + corpus = load_json(CORPUS_PATH) + output_dir = args.output_dir.resolve() + output_dir.mkdir(parents=True, exist_ok=True) + local_output = output_dir / "local.json" + embedded_upstream_output = output_dir / "upstream-embedded.json" + upstream_output = output_dir / "upstream.json" + report_output = output_dir / "report.json" + local_revision = capture(["git", "rev-parse", "HEAD"], REPOSITORY_ROOT) + local_dirty = bool(capture(["git", "status", "--porcelain"], REPOSITORY_ROOT)) + + with tempfile.TemporaryDirectory(prefix="microbot-planner-comparison-") as temp: + upstream_root = Path(temp) / "upstream" + prepare_upstream(upstream_root, commit, args.upstream_checkout) + + run( + [ + "./gradlew", + ":client:exportLocalPlannerComparison", + f"-PplannerCorpus={CORPUS_PATH}", + f"-PplannerOutput={local_output}", + f"-PplannerRevision={local_revision}", + "--console=plain", + ], + REPOSITORY_ROOT, + ) + run( + [ + "./gradlew", + ":client:exportEmbeddedUpstreamPlannerComparison", + f"-PplannerCorpus={CORPUS_PATH}", + f"-PplannerOutput={embedded_upstream_output}", + f"-PplannerRevision={commit}", + "--console=plain", + ], + REPOSITORY_ROOT, + ) + run( + [ + "./gradlew", + "--no-daemon", + "-I", + str(UPSTREAM_INIT), + "exportPlannerComparison", + f"-PplannerCorpus={CORPUS_PATH}", + f"-PplannerOutput={upstream_output}", + f"-PplannerRevision={commit}", + f"-PplannerRuneliteVersion={runelite_version()}", + "--console=plain", + ], + upstream_root, + ) + + local = load_json(local_output) + embedded_upstream = load_json(embedded_upstream_output) + upstream = load_json(upstream_output) + if upstream.get("revision") != commit: + raise RuntimeError("upstream adapter did not report the reviewed commit") + if embedded_upstream.get("revision") != commit: + raise RuntimeError("packaged upstream adapter did not report the reviewed commit") + report, exit_code = compare_results(corpus, local, upstream, args.require_all) + adapter_failures, adapter_policy_divergences = compare_upstream_adapters( + corpus, embedded_upstream, upstream + ) + report["embeddedUpstreamRevision"] = embedded_upstream.get("revision") + report["embeddedUpstreamFailures"] = adapter_failures + report["embeddedUpstreamExpectedPolicyDivergences"] = adapter_policy_divergences + if adapter_failures: + report["failures"].extend(adapter_failures) + exit_code = 1 + report["runeliteVersion"] = runelite_version() + report["corpusSha256"] = hashlib.sha256(CORPUS_PATH.read_bytes()).hexdigest() + report["upstreamIdentityPatchSha256"] = hashlib.sha256( + UPSTREAM_IDENTITY_PATCH.read_bytes() + ).hexdigest() + report["localWorkingTreeDirty"] = local_dirty + report_output.write_text( + json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + print_summary(report) + print(f"Report: {report_output}") + return exit_code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/compare-shortest-path-transports.py b/scripts/compare-shortest-path-transports.py new file mode 100755 index 00000000000..560bfb3e827 --- /dev/null +++ b/scripts/compare-shortest-path-transports.py @@ -0,0 +1,835 @@ +#!/usr/bin/env python3 +"""Compare upstream and Microbot transport TSVs by semantic route identity.""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import io +import json +import re +import sys +import tarfile +import tempfile +import urllib.error +import urllib.request +from collections import defaultdict +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Iterable + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +BASELINE_PATH = Path(__file__).with_name("shortest-path-upstream-baseline.json") +TRANSPORT_BASELINE_PATH = Path(__file__).with_name("shortest-path-transport-baseline.json") +LOCAL_TRANSPORT_ROOT = ( + REPOSITORY_ROOT + / "runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath" +) +AGILITY_OBSTACLES_SOURCE = ( + REPOSITORY_ROOT / "runelite-client/src/main/java/net/runelite/client/plugins/agility/Obstacles.java" +) +OBJECT_ID_SOURCES = ( + REPOSITORY_ROOT / "runelite-api/src/main/java/net/runelite/api/gameval/ObjectID.java", + REPOSITORY_ROOT / "runelite-api/src/main/java/net/runelite/api/gameval/ObjectID1.java", +) +UPSTREAM_TRANSPORT_SUBPATH = Path("src/main/resources/transports") +HOME_TELEPORT_SUFFIX = "home teleport" +NETWORK_IDENTITY_FILES = { + "charter_ships.tsv", + "magic_carpets.tsv", + "ships.tsv", +} + +# Exact reviewed exceptions in the otherwise-imported ordinary transport family. These remain +# visible in upstream-only debt; classification records why each identity is not an unexplained +# omission and digest-pins the decision so an upstream coordinate change reopens the review. +ORDINARY_UPSTREAM_ONLY_CLASSIFICATIONS = { + "supersededPiscatorisGateAnchors": frozenset( + { + "2343 3662 0 -> 2343 3663 0", + "2343 3663 0 -> 2343 3662 0", + "2344 3662 0 -> 2344 3663 0", + "2344 3663 0 -> 2344 3662 0", + } + ), + "unsupportedInteractionlessDaeroTransition": frozenset( + {"2724 2747 0 -> 2770 2793 0"} + ), + "unsupportedIdlessMarimStaircases": frozenset( + { + "2795 2793 0 -> 2795 2797 1", + "2795 2797 1 -> 2795 2793 0", + "2796 2793 0 -> 2796 2797 1", + "2796 2797 1 -> 2796 2793 0", + "2799 2793 0 -> 2799 2797 1", + "2799 2797 1 -> 2799 2793 0", + "2800 2793 0 -> 2800 2797 1", + "2800 2797 1 -> 2800 2793 0", + } + ), + "intentionalDisabledVarrockPalaceTrellis": frozenset( + { + "3228 3470 0 -> 3228 3472 0", + "3228 3472 0 -> 3228 3470 0", + } + ), +} + +ORDINARY_FIELD_DRIFT_CLASSIFICATIONS = { + "concreteElementalWorkshopWallWithConservativeKeyRequirement": frozenset( + { + "2709 3495 0 -> 2709 3496 0", + "2709 3496 0 -> 2709 3495 0", + } + ), +} + +# Six reviewed ship identities are already represented by Microbot's current deck or landing +# coordinates. Keep them visible in upstream-only debt while distinguishing representation drift +# from the four genuinely missing Pandemonium routes imported in the same review slice. +SHIP_UPSTREAM_ONLY_CLASSIFICATIONS = { + "representedByCurrentCorsairCoveLandings": frozenset( + { + "2578 2840 0 -> rimmington", + "rimmington -> 2578 2840 0", + } + ), + "representedByCurrentArdougneShipDeck": frozenset( + { + "ardougne -> brimhaven", + "ardougne -> rimmington", + } + ), + "representedByCurrentVoidOutpostShipDeck": frozenset( + { + "2659 2676 0 -> 3041 3202 0", + "3041 3202 0 -> 2659 2676 0", + } + ), +} +COMPARABLE_FIELDS = ( + "objectId", + "skills", + "quests", + "varbits", + "varplayers", + "items", + "cost", + "members", + "wildernessLevel", + "consumable", + "duration", +) +FIELD_HEADERS = { + "objectId": ("interaction",), + "skills": ("skills",), + "quests": ("quests",), + "varbits": ("varbits",), + "varplayers": ("varplayers",), + "items": ("items",), + # Cost can be represented in a dedicated local Currency column or in upstream Items. + "cost": ("currency", "items"), + "members": ("ismembers",), + "wildernessLevel": ("wildernesslevel",), + "consumable": ("consumable",), + "duration": ("duration",), +} + + +def canonical_header(value: str) -> str: + compact = re.sub(r"[^a-z0-9]", "", value.lower()) + aliases = { + "varplayer": "varplayers", + "varplayers": "varplayers", + "itemids": "items", + "items": "items", + "menuoptionmenutargetobjectid": "interaction", + "displayinfo": "displayinfo", + } + return aliases.get(compact, compact) + + +def normalize_value(value: str) -> str: + return " ".join(value.strip().lower().split()) + + +def normalize_requirement(value: str) -> str: + tokens = [normalize_value(token) for token in re.split(r";|&&", value) if token.strip()] + return ";".join(sorted(tokens)) + + +def normalize_display_info(value: str) -> str: + normalized = normalize_value(value) + normalized = normalized.replace("grand exchange", "ge") + normalized = re.sub(r"^(?:\d+|[a-z]):\s*", "", normalized) + normalized = re.sub(r"\s+minigame teleport$", "", normalized) + return re.sub( + r"^rat pits(?: minigame teleport)?:\s*(?:\d+\.\s*)?", + "rat pits: ", + normalized, + ) + + +def interaction_object_id(value: str) -> str: + value = value.strip() + if not value: + return "" + tail = value.rsplit(";", 1)[-1] if ";" in value else value.rsplit(None, 1)[-1] + return tail if tail.isdigit() else "" + + +def normalize_cost(fields: dict[str, str]) -> str: + currency = normalize_value(fields.get("currency", "")) + if currency: + match = re.fullmatch(r"(\d[\d,]*)\s+coins?", currency) + if match: + return f"coins={match.group(1).replace(',', '')}" + return currency + + items = normalize_value(fields.get("items", "")) + match = re.search(r"(?:^|[;&|\s])coins\s*=\s*(\d[\d,]*)", items) + return f"coins={match.group(1).replace(',', '')}" if match else "" + + +def normalize_non_currency_items(fields: dict[str, str]) -> str: + items = fields.get("items", "") + without_coins = re.sub( + r"(?:^|[;&|\s])coins\s*=\s*\d[\d,]*", + " ", + items, + flags=re.IGNORECASE, + ) + return normalize_requirement(without_coins) + + +def parse_world_point(value: str) -> tuple[int, int, int] | None: + parts = value.split() + if len(parts) != 3: + return None + try: + return tuple(int(part) for part in parts) + except ValueError: + return None + + +@dataclass(frozen=True) +class TransportRow: + source: str + line: int + fields: dict[str, str] + + @property + def route_key(self) -> tuple[str, str, str] | None: + origin = normalize_value(self.fields.get("origin", "")) + destination = normalize_value(self.fields.get("destination", "")) + if not origin and not destination: + return None + label = normalize_display_info(self.fields.get("displayinfo", "")) if not origin else "" + return origin, destination, label + + @property + def display_info(self) -> str: + return normalize_display_info(self.fields.get("displayinfo", "")) + + @property + def object_id(self) -> int | None: + value = interaction_object_id(self.fields.get("interaction", "")) + return int(value) if value else None + + @property + def comparable_fingerprint(self) -> tuple[tuple[str, str], ...]: + values = { + "objectId": interaction_object_id(self.fields.get("interaction", "")), + "skills": normalize_requirement(self.fields.get("skills", "")), + "quests": normalize_requirement(self.fields.get("quests", "")), + "varbits": normalize_requirement(self.fields.get("varbits", "")), + "varplayers": normalize_requirement(self.fields.get("varplayers", "")), + "items": normalize_non_currency_items(self.fields), + "cost": normalize_cost(self.fields), + "members": normalize_value(self.fields.get("ismembers", "")), + "wildernessLevel": normalize_value(self.fields.get("wildernesslevel", "")), + "consumable": normalize_value(self.fields.get("consumable", "")), + "duration": normalize_value(self.fields.get("duration", "")), + } + return tuple((field, values[field]) for field in COMPARABLE_FIELDS) + + def has_comparable_field(self, field: str) -> bool: + return any(header in self.fields for header in FIELD_HEADERS[field]) + + +@dataclass(frozen=True) +class ComparisonSpec: + upstream_file: str + local_file: str + local_filter: Callable[[TransportRow], bool] = lambda row: True + network_identity: bool = False + + +def read_transport_tsv(path: Path) -> list[TransportRow]: + lines = path.read_text(encoding="utf-8").splitlines() + if not lines: + return [] + headers = [canonical_header(header.lstrip("# ")) for header in lines[0].split("\t")] + rows: list[TransportRow] = [] + for line_number, raw_line in enumerate(lines[1:], start=2): + if not raw_line.strip() or raw_line.lstrip().startswith("#"): + continue + values = next(csv.reader([raw_line], delimiter="\t")) + fields = { + header: values[index] if index < len(values) else "" + for index, header in enumerate(headers) + } + rows.append(TransportRow(path.name, line_number, fields)) + return rows + + +def load_agility_course_object_ids( + obstacles_source: Path = AGILITY_OBSTACLES_SOURCE, + object_id_sources: Iterable[Path] = OBJECT_ID_SOURCES, +) -> set[int]: + """Resolve RuneLite's explicit agility-course obstacle catalog to numeric object ids.""" + source = obstacles_source.read_text(encoding="utf-8") + marker = "public static final Set OBSTACLE_IDS = ImmutableSet.of(" + if marker not in source: + raise ValueError(f"unable to locate OBSTACLE_IDS in {obstacles_source}") + block = source.split(marker, 1)[1].split("\n\t);", 1)[0] + names = set(re.findall(r"ObjectID\.([A-Z0-9_]+)", block)) + if not names: + raise ValueError(f"OBSTACLE_IDS is empty in {obstacles_source}") + + values: dict[str, int] = {} + for path in object_id_sources: + object_source = path.read_text(encoding="utf-8") + values.update( + (name, int(value)) + for name, value in re.findall( + r"public static final int ([A-Z0-9_]+) = (\d+);", object_source + ) + ) + unresolved = sorted(names - values.keys()) + if unresolved: + raise ValueError( + "unresolved agility-course ObjectID constants: " + ", ".join(unresolved) + ) + return {values[name] for name in names} + + +def location_labels(rows: Iterable[TransportRow]) -> list[tuple[tuple[int, int, int], str]]: + labels = [] + for row in rows: + destination = parse_world_point(normalize_value(row.fields.get("destination", ""))) + label = row.display_info + if destination is not None and label: + labels.append((destination, label)) + return labels + + +def nearest_location_label( + value: str, + labels: Iterable[tuple[tuple[int, int, int], str]], + radius: int = 6, +) -> str | None: + point = parse_world_point(value) + if point is None: + return None + candidates = [] + for destination, label in labels: + distance = max(abs(point[0] - destination[0]), abs(point[1] - destination[1])) + if distance <= radius: + candidates.append((distance, abs(point[2] - destination[2]), label)) + return min(candidates)[2] if candidates else None + + +def semantic_route_key( + row: TransportRow, + labels: Iterable[tuple[tuple[int, int, int], str]], + network_identity: bool, +) -> tuple[str, str, str] | None: + if not network_identity: + return row.route_key + origin = normalize_value(row.fields.get("origin", "")) + destination = normalize_value(row.fields.get("destination", "")) + if not origin and not destination: + return None + origin_identity = nearest_location_label(origin, labels) or origin + destination_identity = row.display_info or nearest_location_label(destination, labels) or destination + return origin_identity, destination_identity, "" + + +def index_rows( + rows: Iterable[TransportRow], + network_identity: bool = False, +) -> dict[tuple[str, str, str], list[TransportRow]]: + rows = list(rows) + labels = location_labels(rows) if network_identity else [] + indexed: dict[tuple[str, str, str], list[TransportRow]] = defaultdict(list) + for row in rows: + key = semantic_route_key(row, labels, network_identity) + if key is not None: + indexed[key].append(row) + return dict(indexed) + + +def describe_route(key: tuple[str, str, str]) -> str: + origin, destination, label = key + route = f"{origin or ''} -> {destination or ''}" + return f"{route} [{label}]" if label else route + + +def exact_route_classifications( + routes: Iterable[str], + reviewed: dict[str, frozenset[str]], +) -> dict[str, list[str]]: + """Intersect current debt with exact reviewed identities without hiding unknown routes.""" + route_set = set(routes) + return { + name: sorted(route_set & reviewed_routes) + for name, reviewed_routes in reviewed.items() + } + + +def compare_spec( + upstream_root: Path, + local_root: Path, + spec: ComparisonSpec, +) -> dict[str, object]: + upstream_rows = read_transport_tsv(upstream_root / spec.upstream_file) + local_rows = [ + row for row in read_transport_tsv(local_root / spec.local_file) if spec.local_filter(row) + ] + upstream_index = index_rows(upstream_rows, spec.network_identity) + local_index = index_rows(local_rows, spec.network_identity) + upstream_keys = set(upstream_index) + local_keys = set(local_index) + shared_keys = upstream_keys & local_keys + upstream_only_keys = upstream_keys - local_keys + + # A missing schema column is unknown, not an empty requirement. For example, upstream item and + # minigame TSVs have no membership column while Microbot explicitly records member-only routes. + # Comparing blank to "Y" manufactures drift that cannot be resolved from the source artifact. + comparable_fields = tuple( + field + for field in COMPARABLE_FIELDS + if any(row.has_comparable_field(field) for row in upstream_rows) + and any(row.has_comparable_field(field) for row in local_rows) + ) + + field_drift = [] + for key in sorted(shared_keys): + upstream_fingerprints = { + tuple( + (field, dict(row.comparable_fingerprint)[field]) + for field in comparable_fields + ) + for row in upstream_index[key] + } + local_fingerprints = { + tuple( + (field, dict(row.comparable_fingerprint)[field]) + for field in comparable_fields + ) + for row in local_index[key] + } + if upstream_fingerprints != local_fingerprints: + upstream_by_field = { + field: {dict(fingerprint)[field] for fingerprint in upstream_fingerprints} + for field in comparable_fields + } + local_by_field = { + field: {dict(fingerprint)[field] for fingerprint in local_fingerprints} + for field in comparable_fields + } + field_drift.append( + { + "route": describe_route(key), + "upstreamVariants": len(upstream_fingerprints), + "localVariants": len(local_fingerprints), + "differingFields": [ + field + for field in comparable_fields + if upstream_by_field[field] != local_by_field[field] + ], + } + ) + + classifications: dict[str, list[str]] = {} + if spec.upstream_file == "agility_shortcuts.tsv": + course_object_ids = load_agility_course_object_ids() + course_keys = [ + key + for key in sorted(upstream_only_keys) + if upstream_index[key] + and all( + row.object_id is not None and row.object_id in course_object_ids + for row in upstream_index[key] + ) + ] + if course_keys: + classifications["knownAgilityCourseTraversal"] = [ + describe_route(key) for key in course_keys + ] + # Pin cross-file representation debt too. A shortcut copied into transports.tsv remains + # executable, but silently loses the Agility toggle, membership policy, unavailable-edge + # blocking and animation-aware executor path. Keep an explicit zero baseline once all such + # rows are converged so this category cannot return unnoticed. + generic_path = local_root / "transports.tsv" + generic_index = ( + index_rows(read_transport_tsv(generic_path)) if generic_path.exists() else {} + ) + classifications["representedAsLocalGenericTransport"] = [ + describe_route(key) + for key in sorted(upstream_only_keys & set(generic_index)) + ] + elif spec.upstream_file == "transports.tsv": + upstream_only_routes = {describe_route(key) for key in upstream_only_keys} + classifications.update( + exact_route_classifications( + upstream_only_routes, ORDINARY_UPSTREAM_ONLY_CLASSIFICATIONS + ) + ) + elif spec.upstream_file == "ships.tsv": + upstream_only_routes = {describe_route(key) for key in upstream_only_keys} + classifications.update( + exact_route_classifications( + upstream_only_routes, SHIP_UPSTREAM_ONLY_CLASSIFICATIONS + ) + ) + + drift_classifications: dict[str, list[str]] = {} + if spec.upstream_file == "transports.tsv": + drift_routes = {item["route"] for item in field_drift} + for name, reviewed_routes in ORDINARY_FIELD_DRIFT_CLASSIFICATIONS.items(): + drift_classifications[name] = sorted(drift_routes & reviewed_routes) + + result = { + "upstreamFile": spec.upstream_file, + "localFile": spec.local_file, + "upstreamRows": len(upstream_rows), + "localRows": len(local_rows), + "upstreamRoutes": len(upstream_keys), + "localRoutes": len(local_keys), + "sharedRoutes": len(shared_keys), + "upstreamOnlyRoutes": [describe_route(key) for key in sorted(upstream_only_keys)], + "localOnlyRoutes": [describe_route(key) for key in sorted(local_keys - upstream_keys)], + "comparableFieldDrift": field_drift, + } + if classifications: + result["upstreamOnlyClassifications"] = classifications + if drift_classifications: + result["comparableFieldDriftClassifications"] = drift_classifications + return result + + +def is_home_teleport(row: TransportRow) -> bool: + return row.display_info.endswith(HOME_TELEPORT_SUFFIX) + + +def is_quetzal_whistle(row: TransportRow) -> bool: + return row.display_info.startswith("quetzal whistle:") + + +def comparison_specs(upstream_root: Path, local_root: Path) -> tuple[list[ComparisonSpec], list[str], list[str]]: + upstream_files = {path.name for path in upstream_root.glob("*.tsv")} + local_files = {path.name for path in local_root.glob("*.tsv")} + shared = sorted(upstream_files & local_files) + specs = [] + for filename in shared: + if filename == "teleportation_spells.tsv": + local_filter = lambda row: not is_home_teleport(row) + elif filename == "teleportation_items.tsv": + local_filter = lambda row: not is_quetzal_whistle(row) + else: + local_filter = lambda row: True + specs.append( + ComparisonSpec( + filename, + filename, + local_filter, + network_identity=filename in NETWORK_IDENTITY_FILES, + ) + ) + if "teleportation_spells_home.tsv" in upstream_files and "teleportation_spells.tsv" in local_files: + specs.append( + ComparisonSpec( + "teleportation_spells_home.tsv", + "teleportation_spells.tsv", + is_home_teleport, + network_identity=False, + ) + ) + if "quetzal_whistle.tsv" in upstream_files and "teleportation_items.tsv" in local_files: + specs.append( + ComparisonSpec( + "quetzal_whistle.tsv", + "teleportation_items.tsv", + is_quetzal_whistle, + network_identity=False, + ) + ) + compared_upstream = {spec.upstream_file for spec in specs} + compared_local = {spec.local_file for spec in specs} + return specs, sorted(upstream_files - compared_upstream), sorted(local_files - compared_local) + + +def build_report(upstream_root: Path, local_root: Path) -> dict[str, object]: + specs, upstream_only_files, local_only_files = comparison_specs(upstream_root, local_root) + comparisons = [compare_spec(upstream_root, local_root, spec) for spec in specs] + return { + "comparisons": comparisons, + "upstreamOnlyFiles": upstream_only_files, + "localOnlyFiles": local_only_files, + "totals": { + "upstreamOnlyRoutes": sum(len(item["upstreamOnlyRoutes"]) for item in comparisons), + "localOnlyRoutes": sum(len(item["localOnlyRoutes"]) for item in comparisons), + "sharedRoutes": sum(item["sharedRoutes"] for item in comparisons), + "comparableFieldDrift": sum( + len(item["comparableFieldDrift"]) for item in comparisons + ), + }, + } + + +def comparison_id(comparison: dict[str, object]) -> str: + return f"{comparison['upstreamFile']} -> {comparison['localFile']}" + + +def content_digest(value: object) -> str: + encoded = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def build_transport_baseline(report: dict[str, object], reviewed_commit: str) -> dict[str, object]: + comparisons = {} + for comparison in report["comparisons"]: + upstream_only = comparison["upstreamOnlyRoutes"] + local_only = comparison["localOnlyRoutes"] + drift = comparison["comparableFieldDrift"] + baseline_comparison = { + "sharedRoutes": comparison["sharedRoutes"], + "upstreamOnlyRoutes": len(upstream_only), + "upstreamOnlyDigest": content_digest(upstream_only), + "localOnlyRoutes": len(local_only), + "localOnlyDigest": content_digest(local_only), + "comparableFieldDrift": len(drift), + "comparableFieldDriftDigest": content_digest(drift), + } + classifications = comparison.get("upstreamOnlyClassifications", {}) + if classifications: + baseline_comparison["upstreamOnlyClassifications"] = { + name: { + "routes": len(routes), + "digest": content_digest(routes), + } + for name, routes in sorted(classifications.items()) + } + drift_classifications = comparison.get("comparableFieldDriftClassifications", {}) + if drift_classifications: + baseline_comparison["comparableFieldDriftClassifications"] = { + name: { + "routes": len(routes), + "digest": content_digest(routes), + } + for name, routes in sorted(drift_classifications.items()) + } + comparisons[comparison_id(comparison)] = baseline_comparison + return { + "schemaVersion": 2, + "reviewedCommit": reviewed_commit, + "upstreamOnlyFiles": report["upstreamOnlyFiles"], + "localOnlyFiles": report["localOnlyFiles"], + "comparisons": comparisons, + } + + +def baseline_differences(expected: dict[str, object], actual: dict[str, object]) -> list[str]: + differences = [] + for field in ("schemaVersion", "reviewedCommit", "upstreamOnlyFiles", "localOnlyFiles"): + if expected.get(field) != actual.get(field): + differences.append(f"{field}: expected {expected.get(field)!r}, got {actual.get(field)!r}") + + expected_comparisons = expected.get("comparisons", {}) + actual_comparisons = actual.get("comparisons", {}) + for name in sorted(set(expected_comparisons) | set(actual_comparisons)): + if name not in expected_comparisons: + differences.append(f"{name}: new comparison") + continue + if name not in actual_comparisons: + differences.append(f"{name}: comparison removed") + continue + for field in actual_comparisons[name]: + expected_value = expected_comparisons[name].get(field) + actual_value = actual_comparisons[name][field] + if expected_value != actual_value: + differences.append( + f"{name} {field}: expected {expected_value!r}, got {actual_value!r}" + ) + + # Reviewed decisions explain why a specific drift digest is intentional. Binding the rationale + # to the digest makes the decision stale as soon as any route or differing field changes. + reviewed_differences = expected.get("reviewedDifferences", {}) + if not isinstance(reviewed_differences, dict): + differences.append("reviewedDifferences: expected an object") + return differences + for name, review in reviewed_differences.items(): + if not isinstance(review, dict): + differences.append(f"{name} reviewed difference: expected an object") + continue + rationale = review.get("rationale") + if not isinstance(rationale, str) or not rationale.strip(): + differences.append(f"{name} reviewed difference: missing rationale") + comparison = actual_comparisons.get(name) + if comparison is None: + differences.append(f"{name} reviewed difference: comparison is absent") + continue + reviewed_digest = review.get("comparableFieldDriftDigest") + actual_digest = comparison.get("comparableFieldDriftDigest") + if reviewed_digest != actual_digest: + differences.append( + f"{name} reviewed difference digest: expected {reviewed_digest!r}, " + f"got {actual_digest!r}" + ) + return differences + + +def print_text_report(report: dict[str, object], limit: int) -> None: + totals = report["totals"] + print( + "Transport route coverage: " + f"{totals['sharedRoutes']} shared, " + f"{totals['upstreamOnlyRoutes']} upstream-only, " + f"{totals['localOnlyRoutes']} Microbot-only" + ) + print(f"Comparable field drift: {totals['comparableFieldDrift']} routes") + if report["upstreamOnlyFiles"]: + print("Upstream-only files: " + ", ".join(report["upstreamOnlyFiles"])) + if report["localOnlyFiles"]: + print("Microbot-only files: " + ", ".join(report["localOnlyFiles"])) + + for comparison in report["comparisons"]: + upstream_only = comparison["upstreamOnlyRoutes"] + drift = comparison["comparableFieldDrift"] + if not upstream_only and not drift: + continue + print( + f"\n{comparison['upstreamFile']} -> {comparison['localFile']}: " + f"{len(upstream_only)} upstream-only routes, {len(drift)} field differences" + ) + classifications = comparison.get("upstreamOnlyClassifications", {}) + classified_count = sum(len(routes) for routes in classifications.values()) + for name, routes in classifications.items(): + if routes: + print(f" classified: {len(routes)} {name}") + if classified_count: + print(f" world-or-unresolved: {len(upstream_only) - classified_count}") + for route in upstream_only[:limit]: + print(f" missing: {route}") + if len(upstream_only) > limit: + print(f" ... {len(upstream_only) - limit} more missing routes") + drift_classifications = comparison.get("comparableFieldDriftClassifications", {}) + classified_drift_count = sum(len(routes) for routes in drift_classifications.values()) + for name, routes in drift_classifications.items(): + if routes: + print(f" classified drift: {len(routes)} {name}") + if classified_drift_count: + print(f" world-or-unresolved drift: {len(drift) - classified_drift_count}") + for item in drift[:limit]: + print( + f" requirements: {item['route']} " + f"({item['upstreamVariants']} upstream variants, {item['localVariants']} local variants; " + f"fields: {', '.join(item['differingFields'])})" + ) + if len(drift) > limit: + print(f" ... {len(drift) - limit} more field differences") + + +def safe_extract(archive_bytes: bytes, destination: Path) -> Path: + with tarfile.open(fileobj=io.BytesIO(archive_bytes), mode="r:gz") as archive: + members = archive.getmembers() + roots = {Path(member.name).parts[0] for member in members if Path(member.name).parts} + if len(roots) != 1: + raise ValueError("unexpected upstream archive layout") + root_name = next(iter(roots)) + destination_resolved = destination.resolve() + for member in members: + target = (destination / member.name).resolve() + if destination_resolved not in target.parents and target != destination_resolved: + raise ValueError(f"unsafe archive member: {member.name}") + archive.extractall(destination) + return destination / root_name / UPSTREAM_TRANSPORT_SUBPATH + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--upstream-root", type=Path, help="path to upstream transports directory") + parser.add_argument("--local-root", type=Path, default=LOCAL_TRANSPORT_ROOT) + parser.add_argument("--json", action="store_true", help="emit the complete report as JSON") + parser.add_argument( + "--print-baseline", + action="store_true", + help="emit a compact exact semantic-debt baseline as JSON", + ) + parser.add_argument( + "--check-baseline", + action="store_true", + help="return exit code 3 when the exact semantic-debt baseline changed", + ) + parser.add_argument( + "--baseline", + type=Path, + default=TRANSPORT_BASELINE_PATH, + help="semantic-debt baseline used by --check-baseline", + ) + parser.add_argument("--limit", type=int, default=10, help="examples per changed file in text output") + parser.add_argument( + "--fail-on-upstream-only", + action="store_true", + help="return exit code 2 when upstream has route identities missing in Microbot", + ) + args = parser.parse_args() + + baseline = json.loads(BASELINE_PATH.read_text(encoding="utf-8")) + try: + if args.upstream_root: + report = build_report(args.upstream_root, args.local_root) + else: + repository = baseline["repository"] + commit = baseline["reviewedCommit"] + url = f"https://github.com/{repository}/archive/{commit}.tar.gz" + request = urllib.request.Request(url, headers={"User-Agent": "microbot-transport-diff"}) + with urllib.request.urlopen(request, timeout=60) as response: + archive_bytes = response.read() + with tempfile.TemporaryDirectory(prefix="microbot-transport-diff-") as temp_dir: + upstream_root = safe_extract(archive_bytes, Path(temp_dir)) + report = build_report(upstream_root, args.local_root) + except (OSError, ValueError, KeyError, urllib.error.HTTPError, urllib.error.URLError) as error: + print(f"Unable to compare Shortest Path transports: {error}", file=sys.stderr) + return 1 + + transport_baseline = build_transport_baseline(report, baseline["reviewedCommit"]) + if args.print_baseline: + print(json.dumps(transport_baseline, indent=2, sort_keys=True)) + elif args.json: + print(json.dumps(report, indent=2, sort_keys=True)) + else: + print_text_report(report, max(0, args.limit)) + if args.fail_on_upstream_only and report["totals"]["upstreamOnlyRoutes"]: + return 2 + if args.check_baseline: + try: + expected_baseline = json.loads(args.baseline.read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + print(f"Unable to read transport baseline: {error}", file=sys.stderr) + return 1 + differences = baseline_differences(expected_baseline, transport_baseline) + if differences: + print("Shortest Path transport semantic baseline changed:", file=sys.stderr) + for difference in differences: + print(f" {difference}", file=sys.stderr) + return 3 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/evaluate-walker-rollout-evidence.py b/scripts/evaluate-walker-rollout-evidence.py new file mode 100755 index 00000000000..f6707e9c232 --- /dev/null +++ b/scripts/evaluate-walker-rollout-evidence.py @@ -0,0 +1,567 @@ +#!/usr/bin/env python3 +"""Evaluate paired normal-canary and forced-rollback walker release evidence.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + + +DEFAULT_BASELINE = Path(__file__).with_name("shortest-path-upstream-baseline.json") +PLANNER_MODE = "UPSTREAM_F2P_CANARY" +REQUIRED_COVERAGE = ("ACTIVE_ROUTE", "UNDERGROUND_COORDINATES") +REQUIRED_EXECUTORS = ("OBJECT",) +DEFAULT_MAXIMUM_CANARY_PLANNING_MS = 2_000.0 +DEFAULT_MAXIMUM_CANARY_NON_SEARCH_OVERHEAD_MS = 250.0 + + +def load_json(path: Path) -> dict[str, Any]: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError(f"{path}: expected a JSON object") + return value + + +def non_negative_int(mapping: dict[str, Any], key: str, prefix: str) -> int: + value = mapping.get(key) + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise ValueError(f"{prefix}.{key} must be a non-negative integer") + return value + + +def outcome_row( + mapping: dict[str, Any], key: str, prefix: str, failures: list[str] +) -> dict[str, int]: + raw = mapping.get(key) + if not isinstance(raw, dict): + raise ValueError(f"{prefix}.{key} must be an object") + row = { + field: non_negative_int(raw, field, f"{prefix}.{key}") + for field in ("completed", "matches", "divergences", "failures") + } + if row["completed"] != row["matches"] + row["divergences"] + row["failures"]: + failures.append( + f"{prefix}.{key}.completed does not equal matches + divergences + failures" + ) + return row + + +def phase_summary( + result: dict[str, Any], + *, + label: str, + expected_engine: str, + expect_local_fallback: bool, + minimum_comparisons: int, + minimum_arrivals: int, + maximum_canary_planning_ms: float, + maximum_canary_non_search_overhead_ms: float, + required_routes: set[str], + failures: list[str], + shortfalls: list[str], + warnings: list[str], +) -> dict[str, Any]: + prefix = label + failure_count_before = len(failures) + shortfall_count_before = len(shortfalls) + if result.get("script") != "F2P Web Walker Harness": + failures.append(f"{prefix}.script must be 'F2P Web Walker Harness'") + if result.get("exitCode") != 0 or result.get("exitReason") != "completed": + failures.append(f"{prefix} harness did not exit successfully") + errors = result.get("errors") + if not isinstance(errors, list): + raise ValueError(f"{prefix}.errors must be an array") + if errors: + failures.append(f"{prefix}.errors is not empty") + if result.get("plannerMode") != PLANNER_MODE: + failures.append(f"{prefix}.plannerMode must be {PLANNER_MODE}") + if result.get("expectLocalFallback") is not expect_local_fallback: + failures.append( + f"{prefix}.expectLocalFallback must be {str(expect_local_fallback).lower()}" + ) + if result.get("shadowSettled") is not True: + shortfalls.append(f"{prefix} planner evidence was not settled") + + checks = result.get("checks") + if not isinstance(checks, list) or not checks: + raise ValueError(f"{prefix}.checks must be a non-empty array") + failed_checks = [ + check.get("name", "unnamed") + for check in checks + if not isinstance(check, dict) or check.get("passed") is not True + ] + if failed_checks: + failures.append(f"{prefix} failed harness checks: {failed_checks}") + + selected = result.get("selectedRoutes") + if not isinstance(selected, list) or any(not isinstance(value, str) for value in selected): + raise ValueError(f"{prefix}.selectedRoutes must be an array of strings") + selected_routes = set(selected) + missing_routes = required_routes - selected_routes + if missing_routes: + shortfalls.append(f"{prefix} is missing required route(s): {sorted(missing_routes)}") + + routes = result.get("routes") + if not isinstance(routes, list) or not routes: + raise ValueError(f"{prefix}.routes must be a non-empty array") + route_summaries: list[dict[str, Any]] = [] + observed_route_ids: set[str] = set() + for index, route in enumerate(routes): + if not isinstance(route, dict): + raise ValueError(f"{prefix}.routes[{index}] must be an object") + route_id = route.get("id") + if not isinstance(route_id, str) or not route_id: + raise ValueError(f"{prefix}.routes[{index}].id must be a string") + if route_id in observed_route_ids: + failures.append(f"{prefix} contains duplicate route {route_id}") + observed_route_ids.add(route_id) + repetitions = non_negative_int(route, "repetitions", f"{prefix}.routes[{index}]") + successful = non_negative_int( + route, "successfulAttempts", f"{prefix}.routes[{index}]" + ) + passed = ( + route.get("passed") is True + and route.get("walkerState") == "ARRIVED" + and successful == repetitions + and repetitions > 0 + ) + if not passed: + failures.append(f"{prefix} route {route_id} did not complete every repetition") + route_summaries.append( + { + "id": route_id, + "repetitions": repetitions, + "successfulAttempts": successful, + "status": "PASS" if passed else "FAIL", + } + ) + if required_routes - observed_route_ids: + shortfalls.append( + f"{prefix}.routes has no outcome for {sorted(required_routes - observed_route_ids)}" + ) + + snapshot = result.get("shadowEvidence") + if not isinstance(snapshot, dict): + raise ValueError(f"{prefix}.shadowEvidence must be an object") + if snapshot.get("schemaVersion") != 2: + failures.append(f"{prefix}.shadowEvidence.schemaVersion must be 2") + if snapshot.get("enabled") is not True: + failures.append(f"{prefix}.shadowEvidence.enabled must be true") + if snapshot.get("plannerMode") != PLANNER_MODE: + failures.append(f"{prefix}.shadowEvidence.plannerMode must be {PLANNER_MODE}") + if snapshot.get("candidateEngineId") != expected_engine: + failures.append(f"{prefix}.candidateEngineId does not match the reviewed engine") + started_at = non_negative_int( + snapshot, "startedAtEpochMillis", f"{prefix}.shadowEvidence" + ) + + totals_raw = snapshot.get("totals") + if not isinstance(totals_raw, dict): + raise ValueError(f"{prefix}.shadowEvidence.totals must be an object") + totals = { + field: non_negative_int(totals_raw, field, f"{prefix}.totals") + for field in ( + "submitted", + "completed", + "matches", + "divergences", + "failures", + "staleResults", + "discarded", + "pending", + "routeShapeDifferences", + "upstreamCanarySelections", + "localFallbackDivergences", + "localFallbackFailures", + ) + } + if totals["completed"] != ( + totals["matches"] + totals["divergences"] + totals["failures"] + ): + failures.append(f"{prefix}.totals.completed accounting is invalid") + if totals["submitted"] != ( + totals["completed"] + totals["discarded"] + totals["pending"] + ): + failures.append(f"{prefix}.totals.submitted accounting is invalid") + if totals["completed"] < minimum_comparisons: + shortfalls.append( + f"{prefix} has {totals['completed']} completed comparison(s); " + f"{minimum_comparisons} required" + ) + for field in ("staleResults", "discarded", "pending"): + if totals[field]: + failures.append(f"{prefix}.totals.{field} must be zero") + if totals["routeShapeDifferences"]: + warnings.append( + f"{prefix} observed {totals['routeShapeDifferences']} equal-cost route-shape difference(s)" + ) + + performance_raw = snapshot.get("canaryPerformance") + if not isinstance(performance_raw, dict): + raise ValueError(f"{prefix}.shadowEvidence.canaryPerformance must be an object") + performance = { + field: non_negative_int(performance_raw, field, f"{prefix}.canaryPerformance") + for field in ( + "planningSamples", + "planningNanosTotal", + "planningNanosMax", + "localSearchNanosTotal", + "localSearchNanosMax", + "upstreamSearchSamples", + "upstreamSearchNanosTotal", + "upstreamSearchNanosMax", + ) + } + if performance["planningSamples"] != totals["completed"]: + failures.append( + f"{prefix}.canaryPerformance.planningSamples must equal completed comparisons" + ) + if performance["planningNanosTotal"] < performance["planningNanosMax"]: + failures.append(f"{prefix} canary planning total is smaller than its maximum") + if performance["localSearchNanosTotal"] < performance["localSearchNanosMax"]: + failures.append(f"{prefix} local search total is smaller than its maximum") + if performance["upstreamSearchNanosTotal"] < performance["upstreamSearchNanosMax"]: + failures.append(f"{prefix} upstream search total is smaller than its maximum") + if performance["planningNanosTotal"] < ( + performance["localSearchNanosTotal"] + performance["upstreamSearchNanosTotal"] + ): + failures.append( + f"{prefix} canary readiness time does not contain both measured planner searches" + ) + if performance["planningSamples"] and performance["localSearchNanosTotal"] == 0: + failures.append(f"{prefix} has no measurable local planner time") + planning_average_ms = ( + performance["planningNanosTotal"] / performance["planningSamples"] / 1_000_000.0 + if performance["planningSamples"] else 0.0 + ) + planning_max_ms = performance["planningNanosMax"] / 1_000_000.0 + planning_local_ratio = ( + performance["planningNanosTotal"] / performance["localSearchNanosTotal"] + if performance["localSearchNanosTotal"] else 0.0 + ) + non_search_overhead_nanos = max( + 0, + performance["planningNanosTotal"] + - performance["localSearchNanosTotal"] + - performance["upstreamSearchNanosTotal"], + ) + non_search_overhead_average_ms = ( + non_search_overhead_nanos / performance["planningSamples"] / 1_000_000.0 + if performance["planningSamples"] else 0.0 + ) + performance.update( + { + "planningAverageMs": planning_average_ms, + "planningMaxMs": planning_max_ms, + "planningLocalRatio": planning_local_ratio, + "nonSearchOverheadAverageMs": non_search_overhead_average_ms, + } + ) + if planning_max_ms > maximum_canary_planning_ms: + failures.append( + f"{prefix} canary planning maximum {planning_max_ms:.1f} ms exceeds " + f"{maximum_canary_planning_ms:.1f} ms" + ) + if non_search_overhead_average_ms > maximum_canary_non_search_overhead_ms: + failures.append( + f"{prefix} average canary non-search overhead " + f"{non_search_overhead_average_ms:.1f} ms exceeds " + f"{maximum_canary_non_search_overhead_ms:.1f} ms" + ) + + if expect_local_fallback: + expected_zero = ("matches", "divergences", "upstreamCanarySelections", "localFallbackDivergences") + for field in expected_zero: + if totals[field] != 0: + failures.append(f"{prefix}.totals.{field} must be zero in forced rollback") + if totals["failures"] != totals["completed"]: + failures.append(f"{prefix} must fail every upstream comparison") + if totals["localFallbackFailures"] != totals["completed"]: + failures.append(f"{prefix} must locally fall back for every planner failure") + if performance["upstreamSearchSamples"] != 0: + failures.append( + f"{prefix}.canaryPerformance.upstreamSearchSamples must be zero " + "for the injected pre-search failure" + ) + latest_failure = snapshot.get("latestFailure") + if not isinstance(latest_failure, dict): + failures.append(f"{prefix}.latestFailure must preserve the forced failure") + else: + if latest_failure.get("status") != "FAILED": + failures.append(f"{prefix}.latestFailure.status must be FAILED") + if latest_failure.get("shadowEngineId") != expected_engine: + failures.append(f"{prefix}.latestFailure has the wrong engine") + failure_type = latest_failure.get("failureType") + if not isinstance(failure_type, str) or not failure_type: + failures.append(f"{prefix}.latestFailure must expose an exception class") + if "failureMessage" in latest_failure or "message" in latest_failure: + failures.append(f"{prefix}.latestFailure must not expose an exception message") + else: + for field in ( + "divergences", + "failures", + "localFallbackDivergences", + "localFallbackFailures", + ): + if totals[field] != 0: + failures.append(f"{prefix}.totals.{field} must be zero in a normal canary") + if totals["matches"] != totals["completed"]: + failures.append(f"{prefix} must semantically match every comparison") + if totals["upstreamCanarySelections"] != totals["completed"]: + failures.append(f"{prefix} must select upstream for every matching comparison") + if performance["upstreamSearchSamples"] != totals["completed"]: + failures.append( + f"{prefix}.canaryPerformance.upstreamSearchSamples must equal " + "completed comparisons" + ) + + execution_raw = snapshot.get("execution") + if not isinstance(execution_raw, dict): + raise ValueError(f"{prefix}.shadowEvidence.execution must be an object") + execution = { + field: non_negative_int(execution_raw, field, f"{prefix}.execution") + for field in ( + "terminal", + "arrived", + "unreachable", + "exited", + "recoveryTerminal", + "recoveryArrived", + "recoveryUnreachable", + "recoveryExited", + ) + } + if execution["terminal"] != ( + execution["arrived"] + execution["unreachable"] + execution["exited"] + ): + failures.append(f"{prefix}.execution.terminal accounting is invalid") + if execution["recoveryTerminal"] != ( + execution["recoveryArrived"] + + execution["recoveryUnreachable"] + + execution["recoveryExited"] + ): + failures.append(f"{prefix}.execution.recoveryTerminal accounting is invalid") + if execution["unreachable"] or execution["exited"]: + failures.append(f"{prefix} contains a terminal non-arrival") + if execution["arrived"] < minimum_arrivals: + shortfalls.append( + f"{prefix} has {execution['arrived']} terminal arrival(s); {minimum_arrivals} required" + ) + + coverage = snapshot.get("coverage") + if not isinstance(coverage, dict): + raise ValueError(f"{prefix}.shadowEvidence.coverage must be an object") + coverage_summary = { + key: outcome_row(coverage, key, f"{prefix}.coverage", failures) + for key in REQUIRED_COVERAGE + } + executors = snapshot.get("transportExecutors") + if not isinstance(executors, dict): + raise ValueError(f"{prefix}.shadowEvidence.transportExecutors must be an object") + executor_summary = { + key: outcome_row(executors, key, f"{prefix}.transportExecutors", failures) + for key in REQUIRED_EXECUTORS + } + for key, row in {**coverage_summary, **executor_summary}.items(): + if row["completed"] < minimum_comparisons: + shortfalls.append( + f"{prefix} has {row['completed']} {key} comparison(s); " + f"{minimum_comparisons} required" + ) + + status = "FAIL" if len(failures) > failure_count_before else ( + "INSUFFICIENT" if len(shortfalls) > shortfall_count_before else "PASS" + ) + return { + "status": status, + "startedAtEpochMillis": started_at, + "selectedRoutes": sorted(selected_routes), + "routes": route_summaries, + "totals": totals, + "execution": execution, + "canaryPerformance": performance, + "coverage": coverage_summary, + "transportExecutors": executor_summary, + } + + +def evaluate( + normal: dict[str, Any], + rollback: dict[str, Any], + reviewed_commit: str, + *, + minimum_comparisons: int = 10, + minimum_arrivals: int = 10, + maximum_canary_planning_ms: float = DEFAULT_MAXIMUM_CANARY_PLANNING_MS, + maximum_canary_non_search_overhead_ms: float = ( + DEFAULT_MAXIMUM_CANARY_NON_SEARCH_OVERHEAD_MS + ), + required_routes: set[str] | None = None, +) -> dict[str, Any]: + if len(reviewed_commit) != 40: + raise ValueError("reviewed_commit must be a full 40-character revision") + if minimum_comparisons <= 0 or minimum_arrivals <= 0: + raise ValueError("minimum comparison and arrival requirements must be positive") + if (maximum_canary_planning_ms <= 0 + or maximum_canary_non_search_overhead_ms <= 0): + raise ValueError("canary performance thresholds must be positive") + routes = {"F2P-17"} if required_routes is None else set(required_routes) + if not routes: + raise ValueError("at least one required route is needed") + failures: list[str] = [] + shortfalls: list[str] = [] + warnings: list[str] = [] + expected_engine = f"shortest-path-upstream@{reviewed_commit}" + normal_summary = phase_summary( + normal, + label="normal", + expected_engine=expected_engine, + expect_local_fallback=False, + minimum_comparisons=minimum_comparisons, + minimum_arrivals=minimum_arrivals, + maximum_canary_planning_ms=maximum_canary_planning_ms, + maximum_canary_non_search_overhead_ms=maximum_canary_non_search_overhead_ms, + required_routes=routes, + failures=failures, + shortfalls=shortfalls, + warnings=warnings, + ) + rollback_summary = phase_summary( + rollback, + label="rollback", + expected_engine=expected_engine, + expect_local_fallback=True, + minimum_comparisons=minimum_comparisons, + minimum_arrivals=minimum_arrivals, + maximum_canary_planning_ms=maximum_canary_planning_ms, + maximum_canary_non_search_overhead_ms=maximum_canary_non_search_overhead_ms, + required_routes=routes, + failures=failures, + shortfalls=shortfalls, + warnings=warnings, + ) + if normal_summary["startedAtEpochMillis"] == rollback_summary["startedAtEpochMillis"]: + failures.append("normal and rollback evidence came from the same client session") + if normal_summary["selectedRoutes"] != rollback_summary["selectedRoutes"]: + failures.append("normal and rollback evidence selected different route sets") + + verdict = "REJECTED" if failures else ( + "INSUFFICIENT_EVIDENCE" if shortfalls else "ACCEPTED" + ) + return { + "schemaVersion": 1, + "verdict": verdict, + "reviewedCommit": reviewed_commit, + "candidateEngineId": expected_engine, + "requiredRoutes": sorted(routes), + "minimumComparisonsPerPhase": minimum_comparisons, + "minimumArrivalsPerPhase": minimum_arrivals, + "maximumCanaryPlanningMillis": maximum_canary_planning_ms, + "maximumCanaryNonSearchOverheadMillis": maximum_canary_non_search_overhead_ms, + "normal": normal_summary, + "rollback": rollback_summary, + "failures": failures, + "evidenceShortfalls": shortfalls, + "warnings": warnings, + } + + +def markdown(report: dict[str, Any]) -> str: + lines = [ + "# Walker F2P planner rollout evidence", + "", + f"**Verdict:** `{report['verdict']}`", + "", + f"Candidate: `{report['candidateEngineId']}`", + "", + f"Required routes: {', '.join(report['requiredRoutes'])}.", + "", + "| Phase | Completed | Matches | Planner failures | Upstream selections | " + "Local failure fallbacks | Arrivals | Ready avg ms | Ready max ms | " + "Non-search avg ms | Ready/local | Status |", + "|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|", + ] + for key, label in (("normal", "Normal canary"), ("rollback", "Forced rollback")): + phase = report[key] + totals = phase["totals"] + execution = phase["execution"] + performance = phase["canaryPerformance"] + lines.append( + f"| {label} | {totals['completed']} | {totals['matches']} | " + f"{totals['failures']} | {totals['upstreamCanarySelections']} | " + f"{totals['localFallbackFailures']} | {execution['arrived']} | " + f"{performance['planningAverageMs']:.1f} | {performance['planningMaxMs']:.1f} | " + f"{performance['nonSearchOverheadAverageMs']:.1f} | " + f"{performance['planningLocalRatio']:.3f} | {phase['status']} |" + ) + for heading, key in ( + ("Evidence shortfalls", "evidenceShortfalls"), + ("Failures", "failures"), + ("Notes", "warnings"), + ): + if report[key]: + lines.extend(["", f"## {heading}", ""]) + lines.extend(f"- {value}" for value in report[key]) + return "\n".join(lines) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("normal", type=Path) + parser.add_argument("rollback", type=Path) + parser.add_argument("--baseline", type=Path, default=DEFAULT_BASELINE) + parser.add_argument("--minimum-comparisons", type=int, default=10) + parser.add_argument("--minimum-arrivals", type=int, default=10) + parser.add_argument( + "--maximum-canary-planning-ms", + type=float, + default=DEFAULT_MAXIMUM_CANARY_PLANNING_MS, + ) + parser.add_argument( + "--maximum-canary-non-search-overhead-ms", + type=float, + default=DEFAULT_MAXIMUM_CANARY_NON_SEARCH_OVERHEAD_MS, + ) + parser.add_argument("--required-route", action="append", default=[]) + parser.add_argument("--json-output", type=Path) + parser.add_argument("--markdown-output", type=Path) + args = parser.parse_args() + try: + baseline = load_json(args.baseline) + reviewed_commit = baseline.get("reviewedCommit") + if not isinstance(reviewed_commit, str): + raise ValueError("upstream baseline has no reviewedCommit") + report = evaluate( + load_json(args.normal), + load_json(args.rollback), + reviewed_commit, + minimum_comparisons=args.minimum_comparisons, + minimum_arrivals=args.minimum_arrivals, + maximum_canary_planning_ms=args.maximum_canary_planning_ms, + maximum_canary_non_search_overhead_ms=( + args.maximum_canary_non_search_overhead_ms + ), + required_routes=set(args.required_route) if args.required_route else None, + ) + except (OSError, ValueError, json.JSONDecodeError) as error: + print(f"ERROR: {error}", file=sys.stderr) + return 2 + + json_text = json.dumps(report, indent=2, sort_keys=True) + "\n" + markdown_text = markdown(report) + if args.json_output: + args.json_output.parent.mkdir(parents=True, exist_ok=True) + args.json_output.write_text(json_text, encoding="utf-8") + if args.markdown_output: + args.markdown_output.parent.mkdir(parents=True, exist_ok=True) + args.markdown_output.write_text(markdown_text, encoding="utf-8") + if not args.json_output and not args.markdown_output: + print(markdown_text, end="") + return 0 if report["verdict"] == "ACCEPTED" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/evaluate-walker-shadow-evidence.py b/scripts/evaluate-walker-shadow-evidence.py new file mode 100755 index 00000000000..5e6c27c9477 --- /dev/null +++ b/scripts/evaluate-walker-shadow-evidence.py @@ -0,0 +1,872 @@ +#!/usr/bin/env python3 +"""Evaluate one or more fresh-client walker shadow snapshots against the live rollout gate.""" + +from __future__ import annotations + +import argparse +import copy +import json +import sys +from pathlib import Path +from typing import Any + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_BASELINE = Path(__file__).with_name("shortest-path-upstream-baseline.json") +DEFAULT_REQUIRED_COVERAGE = { + "ACTIVE_ROUTE": 75, + "ACTIVE_REPLAN": 15, + "RECOVERY_REPLAN": 10, + "SURFACE_COORDINATES_ONLY": 60, + "UNDERGROUND_COORDINATES": 20, + "WALKING_ONLY_SELECTED": 10, + "USES_TRANSPORT": 20, + "SELECTS_ITEM_GATED_TRANSPORT": 5, + "BANK_ROUTE_FROM_BANK": 10, + "BANK_ROUTE_FROM_BANK_SELECTS_ITEM_GATED_TRANSPORT": 5, + "LIVE_COLLISION_CONSULTED": 25, +} +DEFAULT_MINIMUM_DISTINCT_TRANSPORT_EXECUTORS = 4 +DEFAULT_MINIMUM_WALKER_ARRIVALS = 50 +DEFAULT_MINIMUM_RECOVERY_ARRIVALS = 5 +DEFAULT_REQUIRED_EXECUTOR_GROUPS = { + "LOCAL_TRANSITION": { + "minimum": 5, + "executors": ("OBJECT", "BARROWS_DIG"), + }, + "TELEPORT": { + "minimum": 5, + "executors": ( + "ITEM_TELEPORT", + "MINIGAME_TELEPORT", + "SPELL_TELEPORT", + "POH", + "SEASONAL", + ), + }, + "NETWORK": { + "minimum": 5, + "executors": ( + "CANOE", + "FAIRY_RING", + "GNOME_GLIDER", + "HOT_AIR_BALLOON", + "MAGIC_CARPET", + "MAGIC_MUSHTREE", + "QUETZAL", + "SPIRIT_TREE", + "WILDERNESS_OBELISK", + ), + }, + "TERMINAL_TRAVEL": { + "minimum": 3, + "executors": ("CHARTER_SHIP", "TERMINAL_TRAVEL"), + }, +} + +MEMBERS_REQUIRED_COVERAGE = { + "ACTIVE_ROUTE": 20, + "MEMBERS_WORLD_POLICY": 30, + "USES_TRANSPORT": 15, + "SELECTS_MEMBERS_TRANSPORT": 5, + "SELECTS_NON_ITEM_REQUIREMENT_GATED_TRANSPORT": 5, +} +MEMBERS_REQUIRED_EXECUTOR_GROUPS = { + "MEMBERS_NETWORK": { + "minimum": 5, + "executors": ( + "FAIRY_RING", + "GNOME_GLIDER", + "HOT_AIR_BALLOON", + "MAGIC_CARPET", + "MAGIC_MUSHTREE", + "QUETZAL", + "SPIRIT_TREE", + "TERMINAL_TRAVEL", + "WILDERNESS_OBELISK", + ), + }, +} +MEMBERS_MINIMUM_COMPLETED = 30 +MEMBERS_MINIMUM_DISTINCT_TRANSPORT_EXECUTORS = 2 +MEMBERS_MINIMUM_WALKER_ARRIVALS = 10 +MEMBERS_MINIMUM_RECOVERY_ARRIVALS = 1 +MEMBERS_MINIMUM_SESSIONS = 3 + + +def load_json(path: Path) -> dict[str, Any]: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError(f"{path}: expected a JSON object") + return value + + +def non_negative_int(mapping: dict[str, Any], key: str, prefix: str) -> int: + value = mapping.get(key) + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise ValueError(f"{prefix}.{key} must be a non-negative integer") + return value + + +def validate_terminal_diagnostic( + value: Any, + *, + field: str, + expected_status: str, + expected_engine: str, + failures: list[str], +) -> None: + if not isinstance(value, dict): + raise ValueError(f"snapshot.{field} must be an object or null") + if value.get("status") != expected_status: + failures.append(f"{field}.status must be {expected_status}") + if value.get("shadowEngineId") != expected_engine: + failures.append(f"{field}.shadowEngineId does not match the candidate engine") + for key in ( + "terminationMatches", + "endpointMatches", + "costComparable", + "costMatches", + "selectedTransportsMatch", + "pathMatches", + ): + if not isinstance(value.get(key), bool): + failures.append(f"{field}.{key} must be a boolean") + + failure_type = value.get("failureType") + if expected_status == "DIVERGENCE": + if failure_type is not None: + failures.append(f"{field}.failureType must be null for a divergence") + semantic_fields = ( + "terminationMatches", + "endpointMatches", + "costComparable", + "costMatches", + "selectedTransportsMatch", + ) + if all(value.get(key) is True for key in semantic_fields): + failures.append( + f"{field} does not contain a termination, endpoint, cost or transport mismatch" + ) + elif not isinstance(failure_type, str) or not failure_type: + failures.append(f"{field}.failureType must name the failed exception class") + + +def evaluate( + snapshot: dict[str, Any], + reviewed_commit: str, + *, + minimum_completed: int = 100, + required_coverage: dict[str, int] | None = None, + minimum_distinct_transport_executors: int = DEFAULT_MINIMUM_DISTINCT_TRANSPORT_EXECUTORS, + required_executor_groups: dict[str, dict[str, Any]] | None = None, + minimum_walker_arrivals: int = DEFAULT_MINIMUM_WALKER_ARRIVALS, + minimum_recovery_arrivals: int = DEFAULT_MINIMUM_RECOVERY_ARRIVALS, + minimum_sessions: int = 1, + expected_planner_mode: str | None = None, + evidence_profile: str = "f2p", +) -> dict[str, Any]: + if snapshot.get("schemaVersion") != 2: + raise ValueError("snapshot.schemaVersion must be 2") + if len(reviewed_commit) != 40: + raise ValueError("reviewed_commit must be a full 40-character revision") + if minimum_completed <= 0: + raise ValueError("minimum_completed must be positive") + if minimum_distinct_transport_executors <= 0: + raise ValueError("minimum_distinct_transport_executors must be positive") + if minimum_walker_arrivals <= 0 or minimum_recovery_arrivals <= 0: + raise ValueError("walker arrival requirements must be positive") + if minimum_sessions <= 0: + raise ValueError("minimum_sessions must be positive") + requirements = dict( + DEFAULT_REQUIRED_COVERAGE if required_coverage is None else required_coverage + ) + if any(not isinstance(value, int) or value < 0 for value in requirements.values()): + raise ValueError("coverage requirements must be non-negative integers") + executor_group_requirements = dict( + DEFAULT_REQUIRED_EXECUTOR_GROUPS + if required_executor_groups is None + else required_executor_groups + ) + + failures: list[str] = [] + shortfalls: list[str] = [] + warnings: list[str] = [] + expected_engine = f"shortest-path-upstream@{reviewed_commit}" + actual_engine = snapshot.get("candidateEngineId") + if actual_engine != expected_engine: + failures.append( + f"candidateEngineId={actual_engine!r}, expected {expected_engine!r}" + ) + if snapshot.get("enabled") is not True: + shortfalls.append("upstream planner shadow mode was not enabled at capture") + planner_mode = snapshot.get("plannerMode") + if expected_planner_mode is not None and planner_mode != expected_planner_mode: + failures.append( + f"plannerMode={planner_mode!r}, expected {expected_planner_mode!r}" + ) + started_at_epoch_millis = non_negative_int( + snapshot, "startedAtEpochMillis", "snapshot" + ) + session_count = snapshot.get("sessionCount", 1) + if not isinstance(session_count, int) or isinstance(session_count, bool) or session_count <= 0: + raise ValueError("snapshot.sessionCount must be a positive integer") + if session_count < minimum_sessions: + shortfalls.append( + f"only {session_count} fresh client session(s); {minimum_sessions} required" + ) + + totals = snapshot.get("totals") + if not isinstance(totals, dict): + raise ValueError("snapshot has no totals object") + submitted = non_negative_int(totals, "submitted", "totals") + completed = non_negative_int(totals, "completed", "totals") + matches = non_negative_int(totals, "matches", "totals") + divergences = non_negative_int(totals, "divergences", "totals") + planner_failures = non_negative_int(totals, "failures", "totals") + stale = non_negative_int(totals, "staleResults", "totals") + discarded = non_negative_int(totals, "discarded", "totals") + pending = non_negative_int(totals, "pending", "totals") + route_shape_differences = non_negative_int( + totals, "routeShapeDifferences", "totals" + ) + if matches + divergences + planner_failures != completed: + failures.append( + "totals.completed does not equal matches + divergences + failures" + ) + if submitted != completed + discarded + pending: + failures.append("submitted does not equal completed + discarded + pending") + if divergences: + failures.append(f"observed {divergences} unexplained planner divergence(s)") + if planner_failures: + failures.append(f"observed {planner_failures} upstream planner failure(s)") + if completed < minimum_completed: + shortfalls.append( + f"only {completed} completed comparison(s); {minimum_completed} required" + ) + if pending: + shortfalls.append( + f"{pending} shadow comparison(s) still pending; capture after the queue settles" + ) + if discarded: + warnings.append( + f"{discarded} queued comparison(s) were discarded by the bounded sampler" + ) + if stale: + warnings.append( + f"{stale} completed result(s) belonged to superseded route generations" + ) + if route_shape_differences: + warnings.append( + f"{route_shape_differences} completed comparison(s) used a different exact " + "route shape" + ) + latest_route_shape_difference = snapshot.get("latestRouteShapeDifference") + if route_shape_differences == 0 and latest_route_shape_difference is not None: + failures.append( + "latestRouteShapeDifference is present while totals.routeShapeDifferences is zero" + ) + elif route_shape_differences and latest_route_shape_difference is None: + warnings.append( + "route-shape differences have no preserved coordinate-free comparison; " + "capture evidence from a client containing the current diagnostic field" + ) + elif latest_route_shape_difference is not None: + if not isinstance(latest_route_shape_difference, dict): + raise ValueError("snapshot.latestRouteShapeDifference must be an object or null") + if latest_route_shape_difference.get("status") != "MATCH": + failures.append("latestRouteShapeDifference.status must be MATCH") + if latest_route_shape_difference.get("shadowEngineId") != expected_engine: + failures.append( + "latestRouteShapeDifference.shadowEngineId does not match the candidate engine" + ) + for key in ( + "terminationMatches", + "endpointMatches", + "costComparable", + "costMatches", + "selectedTransportsMatch", + ): + if latest_route_shape_difference.get(key) is not True: + failures.append(f"latestRouteShapeDifference.{key} must be true") + if latest_route_shape_difference.get("pathMatches") is not False: + failures.append("latestRouteShapeDifference.pathMatches must be false") + + latest_divergence = snapshot.get("latestDivergence") + if divergences == 0 and latest_divergence is not None: + failures.append( + "latestDivergence is present while totals.divergences is zero" + ) + elif divergences and latest_divergence is None: + failures.append( + "planner divergences have no preserved coordinate-free diagnostic" + ) + elif latest_divergence is not None: + validate_terminal_diagnostic( + latest_divergence, + field="latestDivergence", + expected_status="DIVERGENCE", + expected_engine=expected_engine, + failures=failures, + ) + + latest_failure = snapshot.get("latestFailure") + if planner_failures == 0 and latest_failure is not None: + failures.append("latestFailure is present while totals.failures is zero") + elif planner_failures and latest_failure is None: + failures.append( + "upstream planner failures have no preserved coordinate-free diagnostic" + ) + elif latest_failure is not None: + validate_terminal_diagnostic( + latest_failure, + field="latestFailure", + expected_status="FAILED", + expected_engine=expected_engine, + failures=failures, + ) + + coverage = snapshot.get("coverage") + if not isinstance(coverage, dict): + raise ValueError("snapshot has no coverage object") + coverage_rows: list[dict[str, Any]] = [] + for tag, required in requirements.items(): + raw = coverage.get(tag) + if not isinstance(raw, dict): + failures.append(f"coverage.{tag} is missing") + coverage_rows.append( + { + "tag": tag, + "required": required, + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0, + "status": "MISSING", + } + ) + continue + tag_completed = non_negative_int(raw, "completed", f"coverage.{tag}") + tag_matches = non_negative_int(raw, "matches", f"coverage.{tag}") + tag_divergences = non_negative_int(raw, "divergences", f"coverage.{tag}") + tag_failures = non_negative_int(raw, "failures", f"coverage.{tag}") + if tag_matches + tag_divergences + tag_failures != tag_completed: + failures.append( + f"coverage.{tag}.completed does not equal its outcome counters" + ) + if tag_divergences: + failures.append( + f"coverage.{tag} contains {tag_divergences} divergence(s)" + ) + if tag_failures: + failures.append(f"coverage.{tag} contains {tag_failures} failure(s)") + if tag_completed < required: + shortfalls.append( + f"coverage.{tag} has {tag_completed} completed comparison(s); " + f"{required} required" + ) + coverage_rows.append( + { + "tag": tag, + "required": required, + "completed": tag_completed, + "matches": tag_matches, + "divergences": tag_divergences, + "failures": tag_failures, + "status": ( + "FAIL" + if tag_divergences or tag_failures + else "PASS" if tag_completed >= required else "INSUFFICIENT" + ), + } + ) + + transport_executors = snapshot.get("transportExecutors") + if not isinstance(transport_executors, dict): + raise ValueError("snapshot has no transportExecutors object") + executor_outcomes: dict[str, dict[str, int]] = {} + for executor, raw in transport_executors.items(): + if not isinstance(executor, str) or not isinstance(raw, dict): + raise ValueError("transportExecutors must map names to outcome objects") + executor_completed = non_negative_int( + raw, "completed", f"transportExecutors.{executor}" + ) + executor_matches = non_negative_int( + raw, "matches", f"transportExecutors.{executor}" + ) + executor_divergences = non_negative_int( + raw, "divergences", f"transportExecutors.{executor}" + ) + executor_failures = non_negative_int( + raw, "failures", f"transportExecutors.{executor}" + ) + if executor_matches + executor_divergences + executor_failures != executor_completed: + failures.append( + f"transportExecutors.{executor}.completed does not equal its outcome counters" + ) + if executor_divergences: + failures.append( + f"transportExecutors.{executor} contains " + f"{executor_divergences} divergence(s)" + ) + if executor_failures: + failures.append( + f"transportExecutors.{executor} contains {executor_failures} failure(s)" + ) + executor_outcomes[executor] = { + "completed": executor_completed, + "matches": executor_matches, + "divergences": executor_divergences, + "failures": executor_failures, + } + + observed_executors = sorted( + executor + for executor, outcomes in executor_outcomes.items() + if outcomes["completed"] > 0 + ) + if len(observed_executors) < minimum_distinct_transport_executors: + shortfalls.append( + f"only {len(observed_executors)} distinct transport executor(s) observed; " + f"{minimum_distinct_transport_executors} required" + ) + executor_group_rows: list[dict[str, Any]] = [] + for group, raw_requirement in executor_group_requirements.items(): + if not isinstance(raw_requirement, dict): + raise ValueError(f"executor group {group} must be an object") + minimum = raw_requirement.get("minimum") + executors = raw_requirement.get("executors") + if not isinstance(minimum, int) or isinstance(minimum, bool) or minimum < 0: + raise ValueError(f"executor group {group}.minimum must be non-negative") + if not isinstance(executors, (list, tuple)) or not all( + isinstance(value, str) for value in executors + ): + raise ValueError(f"executor group {group}.executors must be names") + group_completed = sum( + executor_outcomes.get(executor, {}).get("completed", 0) + for executor in executors + ) + if group_completed < minimum: + shortfalls.append( + f"transport executor group {group} has {group_completed} completed " + f"comparison(s); {minimum} required" + ) + executor_group_rows.append( + { + "group": group, + "executors": list(executors), + "completed": group_completed, + "required": minimum, + "status": "PASS" if group_completed >= minimum else "INSUFFICIENT", + } + ) + + execution = snapshot.get("execution") + if not isinstance(execution, dict): + raise ValueError("snapshot has no execution object") + terminal = non_negative_int(execution, "terminal", "execution") + arrived = non_negative_int(execution, "arrived", "execution") + unreachable = non_negative_int(execution, "unreachable", "execution") + exited = non_negative_int(execution, "exited", "execution") + recovery_terminal = non_negative_int( + execution, "recoveryTerminal", "execution" + ) + recovery_arrived = non_negative_int( + execution, "recoveryArrived", "execution" + ) + recovery_unreachable = non_negative_int( + execution, "recoveryUnreachable", "execution" + ) + recovery_exited = non_negative_int(execution, "recoveryExited", "execution") + if arrived + unreachable + exited != terminal: + failures.append("execution.terminal does not equal its outcome counters") + if recovery_arrived + recovery_unreachable + recovery_exited != recovery_terminal: + failures.append( + "execution.recoveryTerminal does not equal its outcome counters" + ) + if recovery_terminal > terminal or recovery_arrived > arrived: + failures.append("recovery execution outcomes are not a subset of all outcomes") + if arrived < minimum_walker_arrivals: + shortfalls.append( + f"only {arrived} blocking walk arrival(s); {minimum_walker_arrivals} required" + ) + if recovery_arrived < minimum_recovery_arrivals: + shortfalls.append( + f"only {recovery_arrived} recovered walk arrival(s); " + f"{minimum_recovery_arrivals} required" + ) + if recovery_unreachable: + failures.append( + f"observed {recovery_unreachable} recovery-triggered unreachable walk(s)" + ) + if recovery_exited: + failures.append(f"observed {recovery_exited} recovery-triggered exited walk(s)") + if unreachable: + warnings.append(f"observed {unreachable} terminal unreachable walk(s)") + if exited: + warnings.append(f"observed {exited} terminal exited walk(s)") + + if failures: + verdict = "REJECTED" + elif shortfalls: + verdict = "INSUFFICIENT_EVIDENCE" + else: + verdict = "ACCEPTED" + return { + "schemaVersion": 1, + "evidenceProfile": evidence_profile, + "verdict": verdict, + "candidateEngineId": actual_engine, + "reviewedCommit": reviewed_commit, + "startedAtEpochMillis": started_at_epoch_millis, + "plannerMode": planner_mode, + "sessionCount": session_count, + "minimumSessions": minimum_sessions, + "minimumCompleted": minimum_completed, + "totals": dict(totals), + "coverage": coverage_rows, + "transportExecutors": executor_outcomes, + "observedTransportExecutors": observed_executors, + "minimumDistinctTransportExecutors": minimum_distinct_transport_executors, + "transportExecutorGroups": executor_group_rows, + "execution": dict(execution), + "minimumWalkerArrivals": minimum_walker_arrivals, + "minimumRecoveryArrivals": minimum_recovery_arrivals, + "latestRouteShapeDifference": latest_route_shape_difference, + "latestDivergence": latest_divergence, + "latestFailure": latest_failure, + "failures": failures, + "evidenceShortfalls": shortfalls, + "warnings": warnings, + } + + +def merge_snapshots( + snapshots: list[dict[str, Any]], + reviewed_commit: str, + *, + expected_planner_mode: str | None = None, +) -> dict[str, Any]: + if not snapshots: + raise ValueError("at least one snapshot is required") + + expected_engine = f"shortest-path-upstream@{reviewed_commit}" + starts: list[int] = [] + zero_coverage = {tag: 0 for tag in DEFAULT_REQUIRED_COVERAGE} + zero_groups = { + group: {"minimum": 0, "executors": requirement["executors"]} + for group, requirement in DEFAULT_REQUIRED_EXECUTOR_GROUPS.items() + } + for index, snapshot in enumerate(snapshots, start=1): + session_report = evaluate( + snapshot, + reviewed_commit, + minimum_completed=1, + required_coverage=zero_coverage, + minimum_distinct_transport_executors=1, + required_executor_groups=zero_groups, + minimum_walker_arrivals=1, + minimum_recovery_arrivals=1, + expected_planner_mode=expected_planner_mode, + ) + if session_report["failures"]: + raise ValueError( + f"snapshot {index} is internally invalid: " + + "; ".join(session_report["failures"]) + ) + if snapshot.get("candidateEngineId") != expected_engine: + raise ValueError( + f"snapshot {index} candidateEngineId does not match {expected_engine}" + ) + starts.append( + non_negative_int(snapshot, "startedAtEpochMillis", f"snapshot[{index}]") + ) + + if len(set(starts)) != len(starts): + raise ValueError( + "duplicate startedAtEpochMillis values would count the same client session twice" + ) + + merged = copy.deepcopy(snapshots[0]) + merged["enabled"] = all(snapshot.get("enabled") is True for snapshot in snapshots) + merged["candidateEngineId"] = expected_engine + if expected_planner_mode is not None: + merged["plannerMode"] = expected_planner_mode + merged["startedAtEpochMillis"] = min(starts) + merged["sessionCount"] = len(snapshots) + merged["sessionStartedAtEpochMillis"] = starts + + def sum_named_objects(container: str) -> dict[str, dict[str, int]]: + names: set[str] = set() + for index, snapshot in enumerate(snapshots, start=1): + value = snapshot.get(container) + if not isinstance(value, dict): + raise ValueError(f"snapshot {index} has no {container} object") + names.update(value) + combined: dict[str, dict[str, int]] = {} + for name in sorted(names): + combined[name] = { + outcome: sum( + non_negative_int( + snapshot.get(container, {}).get(name, {}), + outcome, + f"snapshot[{index}].{container}.{name}", + ) + if name in snapshot.get(container, {}) + else 0 + for index, snapshot in enumerate(snapshots, start=1) + ) + for outcome in ("completed", "matches", "divergences", "failures") + } + return combined + + total_keys = ( + "submitted", + "completed", + "matches", + "divergences", + "failures", + "staleResults", + "discarded", + "pending", + "routeShapeDifferences", + ) + merged["totals"] = { + key: sum( + non_negative_int(snapshot.get("totals", {}), key, f"snapshot[{index}].totals") + for index, snapshot in enumerate(snapshots, start=1) + ) + for key in total_keys + } + merged["coverage"] = sum_named_objects("coverage") + merged["transportExecutors"] = sum_named_objects("transportExecutors") + merged["transportTypes"] = sum_named_objects("transportTypes") + + execution_keys = ( + "terminal", + "arrived", + "unreachable", + "exited", + "recoveryTerminal", + "recoveryArrived", + "recoveryUnreachable", + "recoveryExited", + ) + merged["execution"] = { + key: sum( + non_negative_int( + snapshot.get("execution", {}), key, f"snapshot[{index}].execution" + ) + for index, snapshot in enumerate(snapshots, start=1) + ) + for key in execution_keys + } + merged["latest"] = next( + (snapshot.get("latest") for snapshot in reversed(snapshots) if snapshot.get("latest")), + None, + ) + merged["latestRouteShapeDifference"] = next( + ( + snapshot.get("latestRouteShapeDifference") + for snapshot in reversed(snapshots) + if snapshot.get("latestRouteShapeDifference") + ), + None, + ) + merged["latestDivergence"] = next( + ( + snapshot.get("latestDivergence") + for snapshot in reversed(snapshots) + if snapshot.get("latestDivergence") + ), + None, + ) + merged["latestFailure"] = next( + ( + snapshot.get("latestFailure") + for snapshot in reversed(snapshots) + if snapshot.get("latestFailure") + ), + None, + ) + return merged + + +def markdown(report: dict[str, Any]) -> str: + totals = report["totals"] + lines = [ + "# Walker live planner shadow evidence", + "", + f"**Verdict:** `{report['verdict']}`", + "", + f"Candidate: `{report['candidateEngineId']}`", + "", + f"Evidence profile: `{report['evidenceProfile']}`; planner mode: " + f"`{report['plannerMode']}`.", + "", + f"Fresh client sessions: {report['sessionCount']} / " + f"{report['minimumSessions']} required.", + "", + f"Completed: {totals['completed']} / {report['minimumCompleted']} required; " + f"matches: {totals['matches']}; divergences: {totals['divergences']}; " + f"failures: {totals['failures']}.", + "", + "| Coverage | Completed | Required | Matches | Divergences | Failures | Status |", + "|---|---:|---:|---:|---:|---:|---|", + ] + for row in report["coverage"]: + lines.append( + f"| {row['tag']} | {row['completed']} | {row['required']} | " + f"{row['matches']} | {row['divergences']} | {row['failures']} | " + f"{row['status']} |" + ) + lines.extend( + [ + "", + "## Transport executor diversity", + "", + f"Observed distinct executors: {len(report['observedTransportExecutors'])} / " + f"{report['minimumDistinctTransportExecutors']} required.", + "", + "| Group | Completed | Required | Executors | Status |", + "|---|---:|---:|---|---|", + ] + ) + for row in report["transportExecutorGroups"]: + lines.append( + f"| {row['group']} | {row['completed']} | {row['required']} | " + f"{', '.join(row['executors'])} | {row['status']} |" + ) + execution = report["execution"] + lines.extend( + [ + "", + "## Walker execution outcomes", + "", + f"Arrived: {execution['arrived']} / {report['minimumWalkerArrivals']} required; " + f"unreachable: {execution['unreachable']}; exited: {execution['exited']}.", + "", + f"Recovered arrivals: {execution['recoveryArrived']} / " + f"{report['minimumRecoveryArrivals']} required; recovered unreachable: " + f"{execution['recoveryUnreachable']}; recovered exited: " + f"{execution['recoveryExited']}.", + ] + ) + latest_route_shape_difference = report.get("latestRouteShapeDifference") + if latest_route_shape_difference is not None: + lines.extend( + [ + "", + "## Latest route-shape diagnostic", + "", + f"Invocation: `{latest_route_shape_difference.get('invocation')}`; " + f"semantic status: `{latest_route_shape_difference.get('status')}`; " + "exact path match: " + f"`{latest_route_shape_difference.get('pathMatches')}`.", + ] + ) + for heading, key in ( + ("Latest semantic divergence", "latestDivergence"), + ("Latest planner failure", "latestFailure"), + ): + diagnostic = report.get(key) + if diagnostic is not None: + lines.extend( + [ + "", + f"## {heading}", + "", + f"Invocation: `{diagnostic.get('invocation')}`; " + f"status: `{diagnostic.get('status')}`; " + f"failure type: `{diagnostic.get('failureType')}`.", + "", + "Semantic equality — " + f"termination: `{diagnostic.get('terminationMatches')}`; " + f"endpoint: `{diagnostic.get('endpointMatches')}`; " + f"cost comparable: `{diagnostic.get('costComparable')}`; " + f"cost: `{diagnostic.get('costMatches')}`; " + f"selected transports: `{diagnostic.get('selectedTransportsMatch')}`.", + ] + ) + for heading, key in ( + ("Evidence shortfalls", "evidenceShortfalls"), + ("Failures", "failures"), + ("Notes", "warnings"), + ): + if report[key]: + lines.extend(["", f"## {heading}", ""]) + lines.extend(f"- {value}" for value in report[key]) + return "\n".join(lines) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("snapshot", type=Path, nargs="+") + parser.add_argument("--baseline", type=Path, default=DEFAULT_BASELINE) + parser.add_argument("--profile", choices=("f2p", "members"), default="f2p") + parser.add_argument("--minimum-completed", type=int) + parser.add_argument("--json-output", type=Path) + parser.add_argument("--markdown-output", type=Path) + args = parser.parse_args() + try: + baseline = load_json(args.baseline) + reviewed_commit = baseline.get("reviewedCommit") + if not isinstance(reviewed_commit, str): + raise ValueError("upstream baseline has no reviewedCommit") + snapshots = [load_json(path) for path in args.snapshot] + members_profile = args.profile == "members" + expected_planner_mode = "SHADOW" if members_profile else None + merged = snapshots[0] if len(snapshots) == 1 else merge_snapshots( + snapshots, reviewed_commit, expected_planner_mode=expected_planner_mode + ) + minimum_completed = args.minimum_completed + if minimum_completed is None: + minimum_completed = ( + MEMBERS_MINIMUM_COMPLETED if members_profile else 100 + ) + report = evaluate( + merged, + reviewed_commit, + minimum_completed=minimum_completed, + required_coverage=(MEMBERS_REQUIRED_COVERAGE if members_profile else None), + minimum_distinct_transport_executors=( + MEMBERS_MINIMUM_DISTINCT_TRANSPORT_EXECUTORS + if members_profile + else DEFAULT_MINIMUM_DISTINCT_TRANSPORT_EXECUTORS + ), + required_executor_groups=( + MEMBERS_REQUIRED_EXECUTOR_GROUPS if members_profile else None + ), + minimum_walker_arrivals=( + MEMBERS_MINIMUM_WALKER_ARRIVALS + if members_profile + else DEFAULT_MINIMUM_WALKER_ARRIVALS + ), + minimum_recovery_arrivals=( + MEMBERS_MINIMUM_RECOVERY_ARRIVALS + if members_profile + else DEFAULT_MINIMUM_RECOVERY_ARRIVALS + ), + minimum_sessions=(MEMBERS_MINIMUM_SESSIONS if members_profile else 1), + expected_planner_mode=expected_planner_mode, + evidence_profile=args.profile, + ) + except (OSError, ValueError, json.JSONDecodeError) as error: + print(f"ERROR: {error}", file=sys.stderr) + return 2 + + json_text = json.dumps(report, indent=2, sort_keys=True) + "\n" + markdown_text = markdown(report) + if args.json_output: + args.json_output.parent.mkdir(parents=True, exist_ok=True) + args.json_output.write_text(json_text, encoding="utf-8") + if args.markdown_output: + args.markdown_output.parent.mkdir(parents=True, exist_ok=True) + args.markdown_output.write_text(markdown_text, encoding="utf-8") + if not args.json_output and not args.markdown_output: + print(markdown_text, end="") + return 0 if report["verdict"] == "ACCEPTED" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/report-shortest-path-planner-performance.py b/scripts/report-shortest-path-planner-performance.py new file mode 100755 index 00000000000..d124c34a898 --- /dev/null +++ b/scripts/report-shortest-path-planner-performance.py @@ -0,0 +1,400 @@ +#!/usr/bin/env python3 +"""Evaluate repeated planner-comparison reports for production-switch readiness.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import statistics +import sys +from pathlib import Path +from typing import Any + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_CORPUS = Path(__file__).with_name("shortest-path-planner-corpus.json") +IDENTITY_FIELDS = ( + "schemaVersion", + "localRevision", + "upstreamRevision", + "embeddedUpstreamRevision", + "runeliteVersion", + "corpusSha256", + "upstreamIdentityPatchSha256", +) + + +def load_json(path: Path) -> dict[str, Any]: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError(f"{path}: expected a JSON object") + return value + + +def ratio(numerator: float, denominator: float) -> float | None: + return numerator / denominator if denominator > 0 else None + + +def median(values: list[float]) -> float: + if not values: + raise ValueError("cannot calculate a median without samples") + return float(statistics.median(values)) + + +def metric(case: dict[str, Any], engine: str, field: str) -> float: + engine_result = case.get(engine) + value = engine_result.get(field) if isinstance(engine_result, dict) else None + if not isinstance(value, (int, float)) or isinstance(value, bool) or value < 0: + raise ValueError( + f"{case.get('id')}: {engine}.{field} must be a non-negative number" + ) + return float(value) + + +def comparison_map(report: dict[str, Any]) -> dict[str, dict[str, Any]]: + comparisons = report.get("comparisons") + if not isinstance(comparisons, list): + raise ValueError("comparison report has no comparisons array") + result: dict[str, dict[str, Any]] = {} + for comparison in comparisons: + case_id = comparison.get("id") if isinstance(comparison, dict) else None + if not isinstance(case_id, str) or case_id in result: + raise ValueError(f"invalid or duplicate comparison id: {case_id!r}") + result[case_id] = comparison + return result + + +def corpus_case_map(corpus: dict[str, Any]) -> dict[str, dict[str, Any]]: + cases = corpus.get("cases") + if not isinstance(cases, list): + raise ValueError("planner corpus has no cases array") + result: dict[str, dict[str, Any]] = {} + for case in cases: + case_id = case.get("id") if isinstance(case, dict) else None + if not isinstance(case_id, str) or case_id in result: + raise ValueError(f"invalid or duplicate corpus case id: {case_id!r}") + result[case_id] = case + return result + + +def exclusion_reason(definition: dict[str, Any]) -> str | None: + if not bool(definition.get("expectedParity", True)): + return "documented input-policy divergence" + transport_mode = definition.get("policy", {}).get("transportMode") + if transport_mode == "BANK_AWARE_EXPLICIT_CATALOG": + return ( + "bank-aware workflow shapes differ: Microbot composes searches while " + "the reviewed upstream searches bank state in one pass" + ) + return None + + +def evaluate_reports( + reports: list[dict[str, Any]], + corpus: dict[str, Any], + *, + corpus_sha256: str | None = None, + minimum_samples: int = 5, + maximum_suite_ratio: float = 1.5, + maximum_case_ratio: float = 3.0, + case_ratio_slack_millis: float = 100.0, + maximum_case_millis: float = 2000.0, +) -> dict[str, Any]: + if not reports: + raise ValueError("at least one comparison report is required") + if minimum_samples <= 0: + raise ValueError("minimum_samples must be positive") + if maximum_suite_ratio <= 0 or maximum_case_ratio <= 0: + raise ValueError("performance ratios must be positive") + if case_ratio_slack_millis < 0 or maximum_case_millis <= 0: + raise ValueError("performance millisecond thresholds are invalid") + + definitions = corpus_case_map(corpus) + reported_corpus_hash = reports[0].get("corpusSha256") + identity = {field: reports[0].get(field) for field in IDENTITY_FIELDS} + failures: list[str] = [] + evidence_shortfalls: list[str] = [] + performance_failures: list[str] = [] + warnings: list[str] = [ + "Node expansion and peak-heap deltas are diagnostic only; the engines use " + "different data structures and heap baselines." + ] + + if corpus.get("schemaVersion") != 3: + failures.append(f"unsupported corpus schema: {corpus.get('schemaVersion')!r}") + if reported_corpus_hash is None: + failures.append("comparison report is missing corpusSha256") + elif corpus_sha256 is not None and reported_corpus_hash != corpus_sha256: + failures.append( + "comparison corpusSha256 does not match the supplied corpus bytes: " + f"{reported_corpus_hash!r} != {corpus_sha256!r}" + ) + + maps: list[dict[str, dict[str, Any]]] = [] + for index, report in enumerate(reports, start=1): + label = f"sample {index}" + for field in IDENTITY_FIELDS: + if report.get(field) != identity[field]: + failures.append( + f"{label}: {field}={report.get(field)!r} differs from " + f"{identity[field]!r}" + ) + for field in ("failures", "unsupported", "embeddedUpstreamFailures"): + values = report.get(field, []) + if not isinstance(values, list): + failures.append(f"{label}: {field} is not an array") + elif values: + failures.append(f"{label}: {field} is not empty: {values}") + if report.get("localWorkingTreeDirty") is not False: + evidence_shortfalls.append( + f"{label}: localWorkingTreeDirty must be false so results identify " + "the code under review" + ) + maps.append(comparison_map(report)) + + expected_ids = set(definitions) + for index, mapped in enumerate(maps, start=1): + if set(mapped) != expected_ids: + failures.append( + f"sample {index}: comparison case set differs from the corpus" + ) + + included_ids: list[str] = [] + excluded: list[dict[str, str]] = [] + for case_id, definition in definitions.items(): + reason = exclusion_reason(definition) + if reason is None: + included_ids.append(case_id) + else: + excluded.append({"id": case_id, "reason": reason}) + + if len(reports) < minimum_samples: + evidence_shortfalls.append( + f"only {len(reports)} independent sample(s); at least " + f"{minimum_samples} are required" + ) + + case_rows: list[dict[str, Any]] = [] + suite_local_samples = [0.0 for _ in reports] + suite_upstream_samples = [0.0 for _ in reports] + for case_id in included_ids: + definition = definitions[case_id] + local_elapsed: list[float] = [] + upstream_elapsed: list[float] = [] + local_nodes: list[float] = [] + upstream_nodes: list[float] = [] + local_heap: list[float] = [] + upstream_heap: list[float] = [] + for sample_index, mapped in enumerate(maps): + comparison = mapped.get(case_id) + if comparison is None: + continue + if comparison.get("status") != "PASS": + failures.append( + f"sample {sample_index + 1}: {case_id} status is " + f"{comparison.get('status')!r}, expected 'PASS'" + ) + local_value = metric(comparison, "local", "elapsedNanos") + upstream_value = metric(comparison, "upstream", "elapsedNanos") + local_elapsed.append(local_value) + upstream_elapsed.append(upstream_value) + local_nodes.append(metric(comparison, "local", "nodesChecked")) + upstream_nodes.append(metric(comparison, "upstream", "nodesChecked")) + local_heap.append(metric(comparison, "local", "peakHeapDeltaBytes")) + upstream_heap.append(metric(comparison, "upstream", "peakHeapDeltaBytes")) + suite_local_samples[sample_index] += local_value + suite_upstream_samples[sample_index] += upstream_value + + if len(local_elapsed) != len(reports): + continue + local_median_ms = median(local_elapsed) / 1_000_000.0 + upstream_median_ms = median(upstream_elapsed) / 1_000_000.0 + local_max_ms = max(local_elapsed) / 1_000_000.0 + upstream_max_ms = max(upstream_elapsed) / 1_000_000.0 + relative_budget_ms = max( + local_max_ms * maximum_case_ratio, case_ratio_slack_millis + ) + case_failures: list[str] = [] + if len(reports) >= minimum_samples: + if upstream_max_ms > relative_budget_ms: + case_failures.append( + f"upstream max {upstream_max_ms:.3f} ms exceeds relative/noise " + f"budget {relative_budget_ms:.3f} ms" + ) + if upstream_max_ms > maximum_case_millis: + case_failures.append( + f"upstream max {upstream_max_ms:.3f} ms exceeds absolute " + f"budget {maximum_case_millis:.3f} ms" + ) + performance_failures.extend(f"{case_id}: {value}" for value in case_failures) + case_rows.append( + { + "id": case_id, + "category": definition.get("category"), + "sampleCount": len(local_elapsed), + "localMedianMillis": local_median_ms, + "upstreamMedianMillis": upstream_median_ms, + "upstreamToLocalMedianRatio": ratio( + upstream_median_ms, local_median_ms + ), + "localMaxMillis": local_max_ms, + "upstreamMaxMillis": upstream_max_ms, + "relativeOrNoiseBudgetMillis": relative_budget_ms, + "absoluteBudgetMillis": maximum_case_millis, + "localMedianNodes": median(local_nodes), + "upstreamMedianNodes": median(upstream_nodes), + "upstreamToLocalNodeRatio": ratio( + median(upstream_nodes), median(local_nodes) + ), + "localMedianPeakHeapDeltaBytes": median(local_heap), + "upstreamMedianPeakHeapDeltaBytes": median(upstream_heap), + "status": "FAIL" if case_failures else "PASS", + "failures": case_failures, + } + ) + + suite_local_median_ms = median(suite_local_samples) / 1_000_000.0 + suite_upstream_median_ms = median(suite_upstream_samples) / 1_000_000.0 + suite_ratio = ratio(suite_upstream_median_ms, suite_local_median_ms) + if ( + len(reports) >= minimum_samples + and suite_ratio is not None + and suite_ratio > maximum_suite_ratio + ): + performance_failures.append( + f"suite upstream/local median elapsed ratio {suite_ratio:.3f} exceeds " + f"{maximum_suite_ratio:.3f}" + ) + + if failures: + verdict = "REJECTED" + elif evidence_shortfalls: + verdict = "INSUFFICIENT_EVIDENCE" + elif performance_failures: + verdict = "REJECTED" + else: + verdict = "ACCEPTED" + + return { + "schemaVersion": 1, + "verdict": verdict, + "sampleCount": len(reports), + "minimumSamples": minimum_samples, + "identity": identity, + "thresholds": { + "maximumSuiteUpstreamToLocalMedianRatio": maximum_suite_ratio, + "maximumCaseUpstreamToLocalMaxRatio": maximum_case_ratio, + "caseRatioSlackMillis": case_ratio_slack_millis, + "maximumCaseUpstreamMaxMillis": maximum_case_millis, + }, + "comparability": { + "includedCaseIds": included_ids, + "excludedCases": excluded, + }, + "suite": { + "localMedianMillis": suite_local_median_ms, + "upstreamMedianMillis": suite_upstream_median_ms, + "upstreamToLocalMedianRatio": suite_ratio, + }, + "cases": case_rows, + "failures": failures, + "evidenceShortfalls": evidence_shortfalls, + "performanceFailures": performance_failures, + "warnings": warnings, + } + + +def markdown(report: dict[str, Any]) -> str: + suite = report["suite"] + lines = [ + "# Shortest-path planner performance evidence", + "", + f"**Verdict:** `{report['verdict']}`", + "", + f"Samples: {report['sampleCount']} / {report['minimumSamples']} required.", + "", + "The production-core switch remains a separate decision from correctness parity " + "and live shadow-route acceptance.", + "", + "## Comparable suite", + "", + f"- Local median total: {suite['localMedianMillis']:.3f} ms", + f"- Upstream median total: {suite['upstreamMedianMillis']:.3f} ms", + f"- Upstream/local ratio: {suite['upstreamToLocalMedianRatio']:.3f}", + "", + "| Case | Local median ms | Upstream median ms | Ratio | Upstream max ms | Status |", + "|---|---:|---:|---:|---:|---|", + ] + for case in report["cases"]: + case_ratio = case["upstreamToLocalMedianRatio"] + ratio_text = "n/a" if case_ratio is None else f"{case_ratio:.3f}" + lines.append( + f"| {case['id']} | {case['localMedianMillis']:.3f} | " + f"{case['upstreamMedianMillis']:.3f} | {ratio_text} | " + f"{case['upstreamMaxMillis']:.3f} | {case['status']} |" + ) + for heading, key in ( + ("Evidence shortfalls", "evidenceShortfalls"), + ("Validation failures", "failures"), + ("Performance failures", "performanceFailures"), + ("Notes", "warnings"), + ): + values = report[key] + if values: + lines.extend(["", f"## {heading}", ""]) + lines.extend(f"- {value}" for value in values) + excluded = report["comparability"]["excludedCases"] + if excluded: + lines.extend(["", "## Excluded from core timing", ""]) + lines.extend(f"- `{value['id']}`: {value['reason']}" for value in excluded) + return "\n".join(lines) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("reports", nargs="+", type=Path) + parser.add_argument("--corpus", type=Path, default=DEFAULT_CORPUS) + parser.add_argument("--minimum-samples", type=int, default=5) + parser.add_argument("--maximum-suite-ratio", type=float, default=1.5) + parser.add_argument("--maximum-case-ratio", type=float, default=3.0) + parser.add_argument("--case-ratio-slack-millis", type=float, default=100.0) + parser.add_argument("--maximum-case-millis", type=float, default=2000.0) + parser.add_argument("--json-output", type=Path) + parser.add_argument("--markdown-output", type=Path) + args = parser.parse_args() + + try: + corpus_bytes = args.corpus.read_bytes() + corpus = json.loads(corpus_bytes) + if not isinstance(corpus, dict): + raise ValueError(f"{args.corpus}: expected a JSON object") + result = evaluate_reports( + [load_json(path) for path in args.reports], + corpus, + corpus_sha256=hashlib.sha256(corpus_bytes).hexdigest(), + minimum_samples=args.minimum_samples, + maximum_suite_ratio=args.maximum_suite_ratio, + maximum_case_ratio=args.maximum_case_ratio, + case_ratio_slack_millis=args.case_ratio_slack_millis, + maximum_case_millis=args.maximum_case_millis, + ) + except (OSError, ValueError, json.JSONDecodeError) as error: + print(f"ERROR: {error}", file=sys.stderr) + return 2 + + json_text = json.dumps(result, indent=2, sort_keys=True) + "\n" + markdown_text = markdown(result) + if args.json_output: + args.json_output.parent.mkdir(parents=True, exist_ok=True) + args.json_output.write_text(json_text, encoding="utf-8") + if args.markdown_output: + args.markdown_output.parent.mkdir(parents=True, exist_ok=True) + args.markdown_output.write_text(markdown_text, encoding="utf-8") + if not args.json_output and not args.markdown_output: + print(markdown_text, end="") + return 0 if result["verdict"] == "ACCEPTED" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run-f2p-webwalker-harness.sh b/scripts/run-f2p-webwalker-harness.sh index 2873748c947..dcc49adffc7 100755 --- a/scripts/run-f2p-webwalker-harness.sh +++ b/scripts/run-f2p-webwalker-harness.sh @@ -6,6 +6,18 @@ TIMEOUT_MS="${MICROBOT_WEBWALKER_TIMEOUT_MS:-1800000}" LEG_TIMEOUT_MS="${MICROBOT_WEBWALKER_LEG_TIMEOUT_MS:-240000}" OUTPUT_DIR="${MICROBOT_WEBWALKER_OUTPUT_DIR:-$HOME/.runelite/test-results/f2p-webwalker}" USE_TELEPORTATION_SPELLS="${MICROBOT_WEBWALKER_USE_TELEPORTATION_SPELLS:-}" +UPSTREAM_PLANNER_SHADOW="${MICROBOT_WEBWALKER_UPSTREAM_PLANNER_SHADOW:-false}" +PLANNER_MODE="${MICROBOT_WEBWALKER_PLANNER_MODE:-}" +FORCE_UPSTREAM_FAILURE="${MICROBOT_WEBWALKER_FORCE_UPSTREAM_FAILURE:-false}" +EXPECT_LOCAL_FALLBACK="${MICROBOT_WEBWALKER_EXPECT_LOCAL_FALLBACK:-false}" + +if [[ -z "$PLANNER_MODE" ]]; then + if [[ "$UPSTREAM_PLANNER_SHADOW" == "true" ]]; then + PLANNER_MODE="SHADOW" + else + PLANNER_MODE="LOCAL" + fi +fi cd "$(dirname "$0")/.." @@ -22,6 +34,9 @@ CMD=( "-Dmicrobot.test.output=$OUTPUT_DIR" -Dmicrobot.test.webwalker.stopOnFailure=true "-Dmicrobot.test.webwalker.walkTimeoutMs=$LEG_TIMEOUT_MS" + "-Dmicrobot.test.webwalker.plannerMode=$PLANNER_MODE" + "-Dmicrobot.test.webwalker.expectLocalFallback=$EXPECT_LOCAL_FALLBACK" + "-Dmicrobot.test.walker.forceUpstreamPlannerFailure=$FORCE_UPSTREAM_FAILURE" ) if [[ "$CASE_ID" != "all" ]]; then diff --git a/scripts/run-ge-lumbridge-teleport-harness.sh b/scripts/run-ge-lumbridge-teleport-harness.sh index 7c9ef9c6ef0..a1a34076072 100755 --- a/scripts/run-ge-lumbridge-teleport-harness.sh +++ b/scripts/run-ge-lumbridge-teleport-harness.sh @@ -6,6 +6,7 @@ TIMEOUT_MS="${MICROBOT_GE_LUMBRIDGE_TIMEOUT_MS:-5400000}" LEG_TIMEOUT_MS="${MICROBOT_GE_LUMBRIDGE_LEG_TIMEOUT_MS:-300000}" OUTPUT_DIR="${MICROBOT_GE_LUMBRIDGE_OUTPUT_DIR:-$HOME/.runelite/test-results/ge-lumbridge-teleport}" MONITOR_INTERVAL="${MICROBOT_GE_LUMBRIDGE_MONITOR_INTERVAL:-0.2}" +UPSTREAM_PLANNER_SHADOW="${MICROBOT_GE_LUMBRIDGE_UPSTREAM_PLANNER_SHADOW:-false}" cd "$(dirname "$0")/.." @@ -25,6 +26,7 @@ CMD=( "-Dmicrobot.test.output=$OUTPUT_DIR" "-Dmicrobot.test.geLumbridge.iterations=$ITERATIONS" "-Dmicrobot.test.geLumbridge.walkTimeoutMs=$LEG_TIMEOUT_MS" + "-Dmicrobot.test.geLumbridge.upstreamPlannerShadow=$UPSTREAM_PLANNER_SHADOW" ) set +e diff --git a/scripts/run-members-webwalker-harness.sh b/scripts/run-members-webwalker-harness.sh new file mode 100755 index 00000000000..28386dd7af3 --- /dev/null +++ b/scripts/run-members-webwalker-harness.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Members evidence is shadow-only until its independent gate passes. Do not allow an inherited +# canary/default override to turn this harness into a selection experiment. +export MICROBOT_WEBWALKER_PLANNER_MODE="SHADOW" +export MICROBOT_WEBWALKER_OUTPUT_DIR="${MICROBOT_MEMBERS_WEBWALKER_OUTPUT_DIR:-$HOME/.runelite/test-results/members-webwalker}" + +exec "$(dirname "$0")/run-f2p-webwalker-harness.sh" members diff --git a/scripts/shortest-path-planner-corpus.json b/scripts/shortest-path-planner-corpus.json new file mode 100644 index 00000000000..6e592c08f70 --- /dev/null +++ b/scripts/shortest-path-planner-corpus.json @@ -0,0 +1,422 @@ +{ + "schemaVersion": 3, + "policyContract": { + "transportModes": ["STATIC_COLLISION_ONLY", "EXPLICIT_CATALOG", "BANK_AWARE_EXPLICIT_CATALOG"], + "liveCollision": false, + "restrictions": [], + "avoidDangerousNpcs": false, + "description": "Engine-neutral static, explicit-catalog, source-aware item-provider, start-at-bank, and separate-bank workflow slices. Every selected catalog edge must retain its exact immutable corpus identity." + }, + "cases": [ + { + "id": "lumbridge-short-overland", + "category": "overland", + "start": { "x": 3222, "y": 3218, "plane": 0 }, + "target": { "x": 3232, "y": 3218, "plane": 0 }, + "policy": { + "transportMode": "STATIC_COLLISION_ONLY", + "avoidWilderness": true, + "cutoffMillis": 12000 + }, + "expectedReached": true + }, + { + "id": "lumbridge-grand-exchange-overland", + "category": "overland", + "start": { "x": 3222, "y": 3218, "plane": 0 }, + "target": { "x": 3164, "y": 3485, "plane": 0 }, + "policy": { + "transportMode": "STATIC_COLLISION_ONLY", + "avoidWilderness": true, + "cutoffMillis": 12000 + }, + "expectedReached": true + }, + { + "id": "ambiguous-network-transport", + "category": "network", + "start": { "x": 3222, "y": 3218, "plane": 0 }, + "target": { "x": 3164, "y": 3485, "plane": 0 }, + "policy": { + "transportMode": "EXPLICIT_CATALOG", + "avoidWilderness": true, + "cutoffMillis": 12000 + }, + "transports": [ + { + "id": "slow-gnome-glider", + "origin": { "x": 3222, "y": 3218, "plane": 0 }, + "destination": { "x": 3164, "y": 3485, "plane": 0 }, + "type": "GNOME_GLIDER", + "duration": 9, + "displayInfo": "Synthetic slow network alternative" + }, + { + "id": "fast-spirit-tree", + "origin": { "x": 3222, "y": 3218, "plane": 0 }, + "destination": { "x": 3164, "y": 3485, "plane": 0 }, + "type": "SPIRIT_TREE", + "duration": 5, + "displayInfo": "Synthetic selected network alternative" + } + ], + "expectedReached": true, + "expectedTransportIds": ["fast-spirit-tree"] + }, + { + "id": "bank-required-network-without-bank", + "category": "bank", + "start": { "x": 3222, "y": 3218, "plane": 0 }, + "target": { "x": 3164, "y": 3485, "plane": 0 }, + "policy": { + "transportMode": "EXPLICIT_CATALOG", + "avoidWilderness": true, + "cutoffMillis": 12000 + }, + "transports": [ + { + "id": "banked-slow-gnome-glider", + "origin": { "x": 3222, "y": 3218, "plane": 0 }, + "destination": { "x": 3164, "y": 3485, "plane": 0 }, + "type": "GNOME_GLIDER", + "duration": 9, + "displayInfo": "Synthetic bank-gated slow alternative", + "availability": "AFTER_BANK" + }, + { + "id": "banked-fast-spirit-tree", + "origin": { "x": 3222, "y": 3218, "plane": 0 }, + "destination": { "x": 3164, "y": 3485, "plane": 0 }, + "type": "SPIRIT_TREE", + "duration": 5, + "displayInfo": "Synthetic bank-gated selected alternative", + "availability": "AFTER_BANK" + } + ], + "expectedReached": true, + "expectedTransportIds": [], + "expectedBankVisited": false + }, + { + "id": "hot-air-balloon-network", + "category": "network-executor", + "start": { "x": 2461, "y": 3111, "plane": 0 }, + "target": { "x": 3299, "y": 3482, "plane": 0 }, + "policy": { + "transportMode": "EXPLICIT_CATALOG", + "avoidWilderness": true, + "cutoffMillis": 12000 + }, + "transports": [ + { + "id": "castle-wars-to-varrock-balloon", + "origin": { "x": 2461, "y": 3111, "plane": 0 }, + "destination": { "x": 3299, "y": 3482, "plane": 0 }, + "type": "HOT_AIR_BALLOON", + "duration": 7, + "displayInfo": "Varrock" + } + ], + "expectedReached": true, + "expectedTransportIds": ["castle-wars-to-varrock-balloon"] + }, + { + "id": "bank-required-network-from-bank", + "category": "bank", + "start": { "x": 3222, "y": 3218, "plane": 0 }, + "target": { "x": 3164, "y": 3485, "plane": 0 }, + "policy": { + "transportMode": "BANK_AWARE_EXPLICIT_CATALOG", + "avoidWilderness": true, + "cutoffMillis": 12000 + }, + "bankLocations": [ + { "x": 3222, "y": 3218, "plane": 0 } + ], + "transports": [ + { + "id": "banked-slow-gnome-glider", + "origin": { "x": 3222, "y": 3218, "plane": 0 }, + "destination": { "x": 3164, "y": 3485, "plane": 0 }, + "type": "GNOME_GLIDER", + "duration": 9, + "displayInfo": "Synthetic bank-gated slow alternative", + "availability": "AFTER_BANK" + }, + { + "id": "banked-fast-spirit-tree", + "origin": { "x": 3222, "y": 3218, "plane": 0 }, + "destination": { "x": 3164, "y": 3485, "plane": 0 }, + "type": "SPIRIT_TREE", + "duration": 5, + "displayInfo": "Synthetic bank-gated selected alternative", + "availability": "AFTER_BANK" + } + ], + "expectedReached": true, + "expectedTransportIds": ["banked-fast-spirit-tree"], + "expectedBankVisited": true + }, + { + "id": "bank-required-network-via-detour", + "category": "bank", + "start": { "x": 3200, "y": 3200, "plane": 0 }, + "target": { "x": 3164, "y": 3485, "plane": 0 }, + "policy": { + "transportMode": "BANK_AWARE_EXPLICIT_CATALOG", + "avoidWilderness": true, + "cutoffMillis": 12000 + }, + "bankLocations": [ + { "x": 3222, "y": 3218, "plane": 0 } + ], + "transports": [ + { + "id": "detour-banked-slow-gnome-glider", + "origin": { "x": 3222, "y": 3218, "plane": 0 }, + "destination": { "x": 3164, "y": 3485, "plane": 0 }, + "type": "GNOME_GLIDER", + "duration": 9, + "displayInfo": "Synthetic detour bank-gated slow alternative", + "availability": "AFTER_BANK" + }, + { + "id": "detour-banked-fast-spirit-tree", + "origin": { "x": 3222, "y": 3218, "plane": 0 }, + "destination": { "x": 3164, "y": 3485, "plane": 0 }, + "type": "SPIRIT_TREE", + "duration": 5, + "displayInfo": "Synthetic detour bank-gated selected alternative", + "availability": "AFTER_BANK" + } + ], + "expectedReached": true, + "expectedTransportIds": ["detour-banked-fast-spirit-tree"], + "expectedBankVisited": true + }, + { + "id": "spell-with-raw-runes", + "category": "spell-provider", + "start": { "x": 3222, "y": 3218, "plane": 0 }, + "target": { "x": 3164, "y": 3485, "plane": 0 }, + "policy": { + "transportMode": "EXPLICIT_CATALOG", + "avoidWilderness": true, + "cutoffMillis": 12000 + }, + "inventoryItems": [ + { "id": 554, "quantity": 2 }, + { "id": 555, "quantity": 2 }, + { "id": 563, "quantity": 2 }, + { "id": 1963, "quantity": 1 } + ], + "transports": [ + { + "id": "ape-atoll-spell-raw-runes", + "origin": { "x": 3222, "y": 3218, "plane": 0 }, + "destination": { "x": 3164, "y": 3485, "plane": 0 }, + "type": "TELEPORTATION_SPELL", + "duration": 4, + "displayInfo": "Synthetic Ape Atoll spell requirement", + "items": "FIRE_RUNE=2&&WATER_RUNE=2&&LAW_RUNE=2&&BANANA=1" + } + ], + "expectedReached": true, + "expectedTransportIds": ["ape-atoll-spell-raw-runes"] + }, + { + "id": "spell-with-staff-and-tome", + "category": "spell-provider", + "start": { "x": 3222, "y": 3218, "plane": 0 }, + "target": { "x": 3164, "y": 3485, "plane": 0 }, + "policy": { + "transportMode": "EXPLICIT_CATALOG", + "avoidWilderness": true, + "cutoffMillis": 12000 + }, + "inventoryItems": [ + { "id": 1383, "quantity": 1 }, + { "id": 20714, "quantity": 1 }, + { "id": 563, "quantity": 2 }, + { "id": 1963, "quantity": 1 } + ], + "transports": [ + { + "id": "ape-atoll-spell-staff-and-tome", + "origin": { "x": 3222, "y": 3218, "plane": 0 }, + "destination": { "x": 3164, "y": 3485, "plane": 0 }, + "type": "TELEPORTATION_SPELL", + "duration": 4, + "displayInfo": "Synthetic Ape Atoll spell requirement", + "items": "FIRE_RUNE=2&&WATER_RUNE=2&&LAW_RUNE=2&&BANANA=1" + } + ], + "expectedReached": true, + "expectedTransportIds": ["ape-atoll-spell-staff-and-tome"] + }, + { + "id": "spell-with-banked-raw-runes", + "category": "spell-provider-bank", + "start": { "x": 3222, "y": 3218, "plane": 0 }, + "target": { "x": 3164, "y": 3485, "plane": 0 }, + "policy": { + "transportMode": "BANK_AWARE_EXPLICIT_CATALOG", + "avoidWilderness": true, + "cutoffMillis": 12000 + }, + "bankLocations": [ + { "x": 3222, "y": 3218, "plane": 0 } + ], + "bankItems": [ + { "id": 554, "quantity": 2 }, + { "id": 555, "quantity": 2 }, + { "id": 563, "quantity": 2 }, + { "id": 1963, "quantity": 1 } + ], + "transports": [ + { + "id": "ape-atoll-spell-banked-raw-runes", + "origin": { "x": 3222, "y": 3218, "plane": 0 }, + "destination": { "x": 3164, "y": 3485, "plane": 0 }, + "type": "TELEPORTATION_SPELL", + "duration": 4, + "displayInfo": "Synthetic banked Ape Atoll spell requirement", + "items": "FIRE_RUNE=2&&WATER_RUNE=2&&LAW_RUNE=2&&BANANA=1" + } + ], + "expectedReached": true, + "expectedTransportIds": ["ape-atoll-spell-banked-raw-runes"], + "expectedBankVisited": true + }, + { + "id": "spell-with-combination-staff", + "category": "spell-provider-divergence", + "start": { "x": 3222, "y": 3218, "plane": 0 }, + "target": { "x": 3164, "y": 3485, "plane": 0 }, + "policy": { + "transportMode": "EXPLICIT_CATALOG", + "avoidWilderness": true, + "cutoffMillis": 12000 + }, + "inventoryItems": [ + { "id": 30634, "quantity": 1 }, + { "id": 563, "quantity": 2 }, + { "id": 1963, "quantity": 1 } + ], + "transports": [ + { + "id": "ape-atoll-spell-twinflame", + "origin": { "x": 3222, "y": 3218, "plane": 0 }, + "destination": { "x": 3164, "y": 3485, "plane": 0 }, + "type": "TELEPORTATION_SPELL", + "duration": 4, + "displayInfo": "Synthetic Ape Atoll spell requirement", + "items": "FIRE_RUNE=2&&WATER_RUNE=2&&LAW_RUNE=2&&BANANA=1" + } + ], + "expectedReached": true, + "expectedLocalTransportIds": ["ape-atoll-spell-twinflame"], + "expectedUpstreamTransportIds": [], + "expectedParity": false, + "expectedDivergenceReason": "The reviewed upstream consumes a staff substitution once per requirement set, so it rejects one Twinflame staff for both FIRE_RUNE and WATER_RUNE clauses; Microbot intentionally selects one equipment loadout and reuses the same combination staff for every clause it provides." + }, + { + "id": "spell-missing-ordinary-item", + "category": "spell-provider", + "start": { "x": 3222, "y": 3218, "plane": 0 }, + "target": { "x": 3164, "y": 3485, "plane": 0 }, + "policy": { + "transportMode": "EXPLICIT_CATALOG", + "avoidWilderness": true, + "cutoffMillis": 12000 + }, + "inventoryItems": [ + { "id": 30634, "quantity": 1 }, + { "id": 563, "quantity": 2 } + ], + "transports": [ + { + "id": "ape-atoll-spell-missing-banana", + "origin": { "x": 3222, "y": 3218, "plane": 0 }, + "destination": { "x": 3164, "y": 3485, "plane": 0 }, + "type": "TELEPORTATION_SPELL", + "duration": 4, + "displayInfo": "Synthetic Ape Atoll spell requirement", + "items": "FIRE_RUNE=2&&WATER_RUNE=2&&LAW_RUNE=2&&BANANA=1" + } + ], + "expectedReached": true, + "expectedTransportIds": [] + }, + { + "id": "white-wolf-tunnel-underground", + "category": "underground", + "start": { "x": 2876, "y": 9878, "plane": 0 }, + "target": { "x": 2820, "y": 9882, "plane": 0 }, + "policy": { + "transportMode": "STATIC_COLLISION_ONLY", + "avoidWilderness": true, + "cutoffMillis": 12000 + }, + "expectedReached": true + }, + { + "id": "white-wolf-surface-tunnel-surface", + "category": "surface-underground-surface", + "start": { "x": 2878, "y": 3482, "plane": 0 }, + "target": { "x": 2819, "y": 3486, "plane": 0 }, + "policy": { + "transportMode": "EXPLICIT_CATALOG", + "avoidWilderness": true, + "cutoffMillis": 12000 + }, + "transports": [ + { + "id": "white-wolf-east-stairs-down", + "origin": { "x": 2877, "y": 3482, "plane": 0 }, + "destination": { "x": 2876, "y": 9878, "plane": 0 }, + "type": "TRANSPORT", + "duration": 1, + "displayInfo": "Fishing Contest tunnel east entrance" + }, + { + "id": "white-wolf-west-stairs-up", + "origin": { "x": 2820, "y": 9882, "plane": 0 }, + "destination": { "x": 2820, "y": 3486, "plane": 0 }, + "type": "TRANSPORT", + "duration": 1, + "displayInfo": "Fishing Contest tunnel west exit" + } + ], + "expectedReached": true, + "expectedTransportIds": [ + "white-wolf-east-stairs-down", + "white-wolf-west-stairs-up" + ] + }, + { + "id": "tempoross-cove-unreachable", + "category": "unreachable", + "start": { "x": 3222, "y": 3218, "plane": 0 }, + "target": { "x": 3044, "y": 2870, "plane": 0 }, + "policy": { + "transportMode": "STATIC_COLLISION_ONLY", + "avoidWilderness": true, + "cutoffMillis": 12000 + }, + "expectedReached": false + }, + { + "id": "wilderness-interior", + "category": "wilderness", + "start": { "x": 3094, "y": 3550, "plane": 0 }, + "target": { "x": 3094, "y": 3599, "plane": 0 }, + "policy": { + "transportMode": "STATIC_COLLISION_ONLY", + "avoidWilderness": true, + "cutoffMillis": 12000 + }, + "expectedReached": true + } + ] +} diff --git a/scripts/shortest-path-planner-harness/README.md b/scripts/shortest-path-planner-harness/README.md new file mode 100644 index 00000000000..93c7dd278b3 --- /dev/null +++ b/scripts/shortest-path-planner-harness/README.md @@ -0,0 +1,55 @@ +# Dual-engine planner harness + +This directory independently adapts the exact reviewed `Skretzo/shortest-path` commit for the comparison in +`../compare-shortest-path-planners.py`. It remains test infrastructure. Microbot separately packages a pinned, +non-UI core under `runelite-client/src/upstreamPlanner`; the harness proves that production adapter has not +drifted semantically from this independently compiled checkout. + +## Exact transport identity + +The reviewed upstream `PathStep` stores position and bank state but not the transport selected by the +search. Reconstructing an edge from its endpoints is ambiguous when multiple transports connect the same +points. The orchestrator therefore applies `upstream-exact-transport-identity.patch` to its temporary, +detached checkout before compiling the runner. The patch only carries the selected `Transport` reference +through `NodeGraph` into `PathStep`; it does not change neighbor generation, queue order, costs, visited +state, or termination. + +Both runners create transports from immutable corpus definitions and retain an identity map from the exact +object to its corpus ID. A selected object that is not in that map is an error. The comparison also checks +the complete ordered edge records, so endpoint-based rematching cannot pass the ambiguous-network case. + +## Policy scope + +Schema 3 supports: + +- `STATIC_COLLISION_ONLY`: no catalog transports; +- `EXPLICIT_CATALOG`: only transports marked `ALWAYS` are available; +- `BANK_AWARE_EXPLICIT_CATALOG`: transports marked `AFTER_BANK` become available when the declared start + tile is a bank or a declared bank is reached. + +The bank cases prove unavailable, start-at-bank and separate-bank-detour policy transitions and retain the +exact selected edge. Upstream represents the detour with bank state inside one search. Microbot's current +workflow compares a direct search with composed start-to-bank and bank-to-target searches. Reachability, +final path cost, selected edges and bank-visited state are correctness-comparable; node, elapsed-time and +peak-memory measurements for the composed workflow are not planner-core performance parity. + +Run the strict harness from the repository root: + +```bash +scripts/compare-shortest-path-planners.py --require-all +``` + +The report records the reviewed upstream revision, production-packaged upstream revision, RuneLite version, +corpus digest, instrumentation-patch digest, packaged-adapter failures, explicitly expected input-policy +divergences, and whether the local worktree was dirty. + +One run is a correctness gate and a performance diagnostic, not production-selection evidence. Collect at +least five clean, same-revision reports and evaluate them with: + +```bash +scripts/report-shortest-path-planner-performance.py \ + build/shortest-path-performance/sample-*/report.json +``` + +The rationale, comparability rules, thresholds and live evidence requirement are documented in +`docs/walker-planner-selection-gate.md`. diff --git a/scripts/shortest-path-planner-harness/UpstreamPlannerComparisonMain.java b/scripts/shortest-path-planner-harness/UpstreamPlannerComparisonMain.java new file mode 100644 index 00000000000..ec1a9edf686 --- /dev/null +++ b/scripts/shortest-path-planner-harness/UpstreamPlannerComparisonMain.java @@ -0,0 +1,632 @@ +package shortestpath.pathfinder; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import net.runelite.api.Client; +import net.runelite.api.GameState; +import net.runelite.api.Quest; +import net.runelite.api.QuestState; +import net.runelite.api.Skill; +import net.runelite.api.coords.WorldPoint; +import shortestpath.Destination; +import shortestpath.DestinationRequirements; +import shortestpath.ShortestPathConfig; +import shortestpath.TeleportationItem; +import shortestpath.WorldPointUtil; +import shortestpath.transport.Transport; +import shortestpath.transport.TransportType; +import shortestpath.transport.requirement.ItemRequirement; +import shortestpath.transport.requirement.TransportItems; + +import java.io.IOException; +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryPoolMXBean; +import java.lang.management.MemoryType; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** Emits reviewed-upstream planner results for Microbot's opt-in comparison harness. */ +public final class UpstreamPlannerComparisonMain +{ + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + private UpstreamPlannerComparisonMain() + { + } + + public static void main(String[] args) throws Exception + { + if (args.length != 2) + { + throw new IllegalArgumentException("expected "); + } + Path corpusPath = Path.of(args[0]).toAbsolutePath().normalize(); + Path outputPath = Path.of(args[1]).toAbsolutePath().normalize(); + PlannerCorpus corpus = readCorpus(corpusPath); + List results = new ArrayList<>(); + for (PlannerCase plannerCase : corpus.cases) + { + results.add(run(plannerCase)); + } + PlannerRun run = new PlannerRun( + corpus.schemaVersion, + "shortest-path-upstream", + System.getProperty("microbot.planner.revision", "unknown"), + results); + Files.createDirectories(outputPath.getParent()); + Files.writeString(outputPath, GSON.toJson(run) + System.lineSeparator(), + StandardCharsets.UTF_8); + } + + private static PlannerCorpus readCorpus(Path path) throws IOException + { + PlannerCorpus corpus = GSON.fromJson(Files.readString(path, StandardCharsets.UTF_8), + PlannerCorpus.class); + if (corpus == null || corpus.schemaVersion != 3 || corpus.cases == null) + { + throw new IllegalArgumentException("unsupported or incomplete planner corpus"); + } + return corpus; + } + + private static PlannerCaseResult run(PlannerCase plannerCase) + { + if (!"STATIC_COLLISION_ONLY".equals(plannerCase.policy.transportMode) + && !"EXPLICIT_CATALOG".equals(plannerCase.policy.transportMode) + && !"BANK_AWARE_EXPLICIT_CATALOG".equals(plannerCase.policy.transportMode)) + { + return PlannerCaseResult.unsupported(plannerCase.id, + "unsupported transport policy: " + plannerCase.policy.transportMode); + } + if (plannerCase.policy.cutoffMillis <= 0 || plannerCase.policy.cutoffMillis % 600L != 0) + { + throw new IllegalArgumentException( + "comparison cutoff must be a positive whole number of game ticks"); + } + + Client client = mock(Client.class); + ShortestPathConfig shortestPathConfig = mock(ShortestPathConfig.class); + when(client.getGameState()).thenReturn(GameState.LOGGED_IN); + when(client.getClientThread()).thenReturn(Thread.currentThread()); + when(client.getBoostedSkillLevel(any(Skill.class))).thenReturn(99); + when(client.getTotalLevel()).thenReturn(2277); + when(shortestPathConfig.calculationCutoff()).thenReturn( + (int) (plannerCase.policy.cutoffMillis / 600L)); + when(shortestPathConfig.avoidWilderness()).thenReturn(plannerCase.policy.avoidWilderness); + when(shortestPathConfig.useTeleportationItems()).thenReturn(TeleportationItem.NONE); + when(shortestPathConfig.useFairyRings()).thenReturn(true); + when(shortestPathConfig.useGnomeGliders()).thenReturn(true); + when(shortestPathConfig.useSpiritTrees()).thenReturn(true); + + Catalog catalog = Catalog.from(plannerCase); + PathfinderConfig config = new ComparisonPathfinderConfig( + client, shortestPathConfig, catalog, plannerCase); + config.refresh(); + int start = plannerCase.start.pack(); + int target = plannerCase.target.pack(); + resetHeapPeaks(); + long heapBefore = usedHeap(); + Pathfinder pathfinder = new Pathfinder(config, start, Set.of(target)); + pathfinder.run(); + long peakHeapDelta = Math.max(0L, peakHeap() - heapBefore); + PathfinderResult result = pathfinder.getResult(); + if (result == null) + { + throw new IllegalStateException("upstream result unavailable for " + plannerCase.id); + } + List path = result.getPathSteps(); + PathStep last = path == null || path.isEmpty() ? null : path.get(path.size() - 1); + int endpoint = last == null ? WorldPointUtil.UNDEFINED : last.getPackedPosition(); + long pathCost = pathCost(path); + List selected = selectedTransports(path, catalog); + boolean bankVisited = path != null && path.stream().anyMatch(PathStep::isBankVisited); + return PlannerCaseResult.supported( + plannerCase.id, + result.getTerminationReason().name(), + result.isReached(), + Point.fromPacked(endpoint), + path == null ? 0 : path.size(), + pathCost, + result.getNodesChecked(), + result.getTransportsChecked(), + result.getElapsedNanos(), + peakHeapDelta, + selected, + bankVisited); + } + + private static long pathCost(List path) + { + if (path == null) + { + return -1L; + } + long cost = 0L; + for (int i = 1; i < path.size(); i++) + { + Transport transport = path.get(i).getTransport(); + cost += transport == null + ? WorldPointUtil.distanceBetween( + path.get(i - 1).getPackedPosition(), path.get(i).getPackedPosition()) + : transport.getDuration(); + } + return cost; + } + + private static List selectedTransports(List path, Catalog catalog) + { + if (path == null) + { + return Collections.emptyList(); + } + List selected = new ArrayList<>(); + for (int i = 1; i < path.size(); i++) + { + Transport transport = path.get(i).getTransport(); + if (transport == null) + { + continue; + } + String id = catalog.ids.get(transport); + if (id == null) + { + throw new IllegalStateException("selected transport is not from the explicit corpus catalog: " + + transport); + } + selected.add(new SelectedTransport(id, + Point.fromPacked(path.get(i - 1).getPackedPosition()), + Point.fromPacked(path.get(i).getPackedPosition()), + transport.getType().name(), transport.getDuration())); + } + return Collections.unmodifiableList(selected); + } + + private static void resetHeapPeaks() + { + for (MemoryPoolMXBean pool : ManagementFactory.getMemoryPoolMXBeans()) + { + if (pool.getType() == MemoryType.HEAP) + { + pool.resetPeakUsage(); + } + } + } + + private static long usedHeap() + { + return ManagementFactory.getMemoryMXBean().getHeapMemoryUsage().getUsed(); + } + + private static long peakHeap() + { + long peak = 0L; + for (MemoryPoolMXBean pool : ManagementFactory.getMemoryPoolMXBeans()) + { + if (pool.getType() == MemoryType.HEAP && pool.getPeakUsage() != null) + { + peak += Math.max(0L, pool.getPeakUsage().getUsed()); + } + } + return peak; + } + + private static final class ComparisonPathfinderConfig extends PathfinderConfig + { + private final TransportAvailability withoutBank; + private final TransportAvailability withBank; + private final boolean bankPathEnabled; + private final Set bankLocations; + + private ComparisonPathfinderConfig(Client client, ShortestPathConfig config, + Catalog catalog, PlannerCase plannerCase) + { + this(client, config, catalog, plannerCase, Destination.loadAllFromResources()); + } + + private ComparisonPathfinderConfig(Client client, ShortestPathConfig config, + Catalog catalog, PlannerCase plannerCase, Map> destinations) + { + super(client, config, + SplitFlagMap.fromResources(), + catalog.withBankByOrigin, + destinations, + PathfinderConfig.filterDestinations(destinations), + Collections.emptyMap()); + withoutBank = availability(catalog.withoutBankByOrigin); + withBank = availability(catalog.withBankByOrigin); + bankPathEnabled = "BANK_AWARE_EXPLICIT_CATALOG".equals( + plannerCase.policy.transportMode); + Set packedBanks = new java.util.HashSet<>(); + if (plannerCase.bankLocations != null) + { + for (Point bank : plannerCase.bankLocations) + { + packedBanks.add(bank.pack()); + } + } + bankLocations = Collections.unmodifiableSet(packedBanks); + } + + private static TransportAvailability availability( + Map> transports) + { + TransportAvailability.Builder builder = new TransportAvailability.Builder( + Math.max(1, transports.size())); + for (Set values : transports.values()) + { + for (Transport transport : values) + { + builder.add(transport); + } + } + return builder.build(); + } + + @Override + public TransportAvailability getTransportAvailability(boolean bankVisited) + { + return bankVisited ? withBank : withoutBank; + } + + @Override + public boolean isBankPathEnabled() + { + return bankPathEnabled; + } + + @Override + public boolean bankAccessible(int packedPosition) + { + return bankLocations.contains(packedPosition); + } + + @Override + public QuestState getQuestState(Quest quest) + { + return QuestState.FINISHED; + } + } + + private static final class PlannerCorpus + { + private int schemaVersion; + private List cases; + } + + private static final class PlannerCase + { + private String id; + private Point start; + private Point target; + private PlannerPolicy policy; + private List transports = Collections.emptyList(); + private List bankLocations = Collections.emptyList(); + private List inventoryItems = Collections.emptyList(); + private List equipmentItems = Collections.emptyList(); + private List bankItems = Collections.emptyList(); + } + + private static final class PlannerPolicy + { + private String transportMode; + private boolean avoidWilderness; + private long cutoffMillis; + } + + private static final class PlannerTransport + { + private String id; + private Point origin; + private Point destination; + private String type; + private int duration; + private String displayInfo; + private String availability = "ALWAYS"; + private String items; + } + + private static final class PlannerItem + { + private int id; + private int quantity; + } + + private static final class Catalog + { + private final Map> withoutBankByOrigin = new HashMap<>(); + private final Map> withBankByOrigin = new HashMap<>(); + private final IdentityHashMap ids = new IdentityHashMap<>(); + + private static Catalog from(PlannerCase plannerCase) + { + Catalog catalog = new Catalog(); + List definitions = plannerCase.transports == null + ? Collections.emptyList() : plannerCase.transports; + if ("STATIC_COLLISION_ONLY".equals(plannerCase.policy.transportMode) + && !definitions.isEmpty()) + { + throw new IllegalArgumentException("static-only case has a transport catalog: " + + plannerCase.id); + } + Set seenIds = new java.util.HashSet<>(); + for (PlannerTransport definition : definitions) + { + if (definition.id == null || !seenIds.add(definition.id)) + { + throw new IllegalArgumentException("missing or duplicate transport id in " + + plannerCase.id + ": " + definition.id); + } + if (definition.origin == null || definition.destination == null + || definition.type == null || definition.duration < 0) + { + throw new IllegalArgumentException("incomplete transport " + definition.id + + " in " + plannerCase.id); + } + int origin = definition.origin.pack(); + Transport.TransportBuilder builder = new Transport.TransportBuilder() + .origin(origin) + .destination(definition.destination.pack()) + .type(TransportType.valueOf(definition.type)) + .duration(definition.duration) + .displayInfo(definition.displayInfo); + if (definition.items != null && !definition.items.isBlank()) + { + builder.itemRequirements(definition.items); + } + Transport transport = builder.build(); + if (!"ALWAYS".equals(definition.availability) + && !"AFTER_BANK".equals(definition.availability)) + { + throw new IllegalArgumentException("unsupported transport availability for " + + definition.id + ": " + definition.availability); + } + if (hasRequiredItems(transport, plannerCase, true)) + { + catalog.withBankByOrigin + .computeIfAbsent(origin, ignored -> new LinkedHashSet<>()) + .add(transport); + } + if ("ALWAYS".equals(definition.availability) + && hasRequiredItems(transport, plannerCase, false)) + { + catalog.withoutBankByOrigin + .computeIfAbsent(origin, ignored -> new LinkedHashSet<>()) + .add(transport); + } + catalog.ids.put(transport, definition.id); + } + return catalog; + } + + /** Mirrors the reviewed upstream provider-consumption contract over explicit headless state. */ + private static boolean hasRequiredItems( + Transport transport, PlannerCase plannerCase, boolean includeBank) + { + TransportItems requirements = transport.getItemRequirements(); + if (requirements == null) + { + return true; + } + Map available = availableItems(plannerCase, includeBank); + boolean usingStaff = false; + boolean usingOffhand = false; + for (ItemRequirement requirement : requirements.getRequirements()) + { + boolean missing = !hasQuantity( + requirement.getItemIds(), requirement.getQuantity(), available); + if (missing && !usingStaff && hasProvider( + requirement.getStaffIds(), requirement.getQuantity(), available)) + { + usingStaff = true; + missing = false; + } + if (missing && !usingOffhand && hasProvider( + requirement.getOffhandIds(), requirement.getQuantity(), available)) + { + usingOffhand = true; + missing = false; + } + if (missing) + { + return false; + } + } + return true; + } + + private static boolean hasQuantity( + int[] itemIds, int required, Map available) + { + if (itemIds == null) + { + return false; + } + for (int itemId : itemIds) + { + int quantity = available.getOrDefault(itemId, 0); + if (required > 0 && quantity >= required || required == 0 && quantity == 0) + { + return true; + } + } + return false; + } + + private static boolean hasProvider( + int[] itemIds, int required, Map available) + { + if (itemIds == null) + { + return false; + } + for (int itemId : itemIds) + { + int quantity = available.getOrDefault(itemId, 0); + if (required > 0 && quantity >= 1 || required == 0 && quantity == 0) + { + return true; + } + } + return false; + } + + private static Map availableItems( + PlannerCase plannerCase, boolean includeBank) + { + Map available = new HashMap<>(); + addItems(available, plannerCase.inventoryItems, "inventory"); + addItems(available, plannerCase.equipmentItems, "equipment"); + if (includeBank) + { + addItems(available, plannerCase.bankItems, "bank"); + } + return available; + } + + private static void addItems( + Map available, List items, String source) + { + if (items == null) + { + return; + } + for (PlannerItem item : items) + { + if (item == null || item.id <= 0 || item.quantity <= 0) + { + throw new IllegalArgumentException("invalid " + source + " item state"); + } + available.merge(item.id, item.quantity, Math::addExact); + } + } + } + + private static final class Point + { + private int x; + private int y; + private int plane; + + private int pack() + { + return WorldPointUtil.packWorldPoint(x, y, plane); + } + + private static Point fromPacked(int packed) + { + if (packed == WorldPointUtil.UNDEFINED) + { + return null; + } + WorldPoint point = WorldPointUtil.unpackWorldPoint(packed); + Point value = new Point(); + value.x = point.getX(); + value.y = point.getY(); + value.plane = point.getPlane(); + return value; + } + } + + private static final class PlannerRun + { + private final int schemaVersion; + private final String engine; + private final String revision; + private final List cases; + + private PlannerRun(int schemaVersion, String engine, String revision, + List cases) + { + this.schemaVersion = schemaVersion; + this.engine = engine; + this.revision = revision; + this.cases = cases; + } + } + + private static final class PlannerCaseResult + { + private final String id; + private final boolean supported; + private final String unsupportedReason; + private final String termination; + private final boolean reached; + private final Point endpoint; + private final int pathLength; + private final long pathCost; + private final long nodesChecked; + private final long transportsChecked; + private final long elapsedNanos; + private final long peakHeapDeltaBytes; + private final List selectedTransports; + private final boolean bankVisited; + + private PlannerCaseResult(String id, boolean supported, String unsupportedReason, + String termination, boolean reached, Point endpoint, int pathLength, long pathCost, + long nodesChecked, long transportsChecked, long elapsedNanos, long peakHeapDeltaBytes, + List selectedTransports, boolean bankVisited) + { + this.id = id; + this.supported = supported; + this.unsupportedReason = unsupportedReason; + this.termination = termination; + this.reached = reached; + this.endpoint = endpoint; + this.pathLength = pathLength; + this.pathCost = pathCost; + this.nodesChecked = nodesChecked; + this.transportsChecked = transportsChecked; + this.elapsedNanos = elapsedNanos; + this.peakHeapDeltaBytes = peakHeapDeltaBytes; + this.selectedTransports = selectedTransports; + this.bankVisited = bankVisited; + } + + private static PlannerCaseResult unsupported(String id, String reason) + { + return new PlannerCaseResult(id, false, reason, null, false, null, + 0, -1L, -1L, -1L, -1L, -1L, Collections.emptyList(), false); + } + + private static PlannerCaseResult supported(String id, String termination, + boolean reached, Point endpoint, int pathLength, long pathCost, long nodesChecked, + long transportsChecked, long elapsedNanos, long peakHeapDeltaBytes, + List selectedTransports, boolean bankVisited) + { + return new PlannerCaseResult(id, true, null, termination, reached, endpoint, + pathLength, pathCost, nodesChecked, transportsChecked, elapsedNanos, + peakHeapDeltaBytes, selectedTransports, bankVisited); + } + } + + private static final class SelectedTransport + { + private final String id; + private final Point from; + private final Point to; + private final String type; + private final int duration; + + private SelectedTransport(String id, Point from, Point to, String type, int duration) + { + this.id = id; + this.from = from; + this.to = to; + this.type = type; + this.duration = duration; + } + } +} diff --git a/scripts/shortest-path-planner-harness/upstream-exact-transport-identity.patch b/scripts/shortest-path-planner-harness/upstream-exact-transport-identity.patch new file mode 100644 index 00000000000..0369da5eadf --- /dev/null +++ b/scripts/shortest-path-planner-harness/upstream-exact-transport-identity.patch @@ -0,0 +1,232 @@ +diff --git a/src/main/java/shortestpath/pathfinder/CollisionMap.java b/src/main/java/shortestpath/pathfinder/CollisionMap.java +index c9a147d..f51c234 100644 +--- a/src/main/java/shortestpath/pathfinder/CollisionMap.java ++++ b/src/main/java/shortestpath/pathfinder/CollisionMap.java +@@ -144,7 +144,8 @@ public class CollisionMap + config.getAdditionalTransportCost(transport) + chainPenalty, + pathBankVisited, + delayedVisit, +- delayedVisit ? config.getDifferentialCost(transport) : 0)); ++ delayedVisit ? config.getDifferentialCost(transport) : 0, ++ transport)); + } + + // Global teleports are only considered from an abstract node, so each +@@ -258,7 +259,8 @@ public class CollisionMap + config.getAdditionalTransportCost(transport), + bankVisited, + delayedVisit, +- differentialCost)); ++ differentialCost, ++ transport)); + } + return neighbors; + } +diff --git a/src/main/java/shortestpath/pathfinder/NodeGraph.java b/src/main/java/shortestpath/pathfinder/NodeGraph.java +index 2ec3c07..013334c 100644 +--- a/src/main/java/shortestpath/pathfinder/NodeGraph.java ++++ b/src/main/java/shortestpath/pathfinder/NodeGraph.java +@@ -5,6 +5,7 @@ import java.util.Arrays; + import java.util.List; + + import shortestpath.WorldPointUtil; ++import shortestpath.transport.Transport; + + /** + * Structure-of-Arrays store for pathfinding nodes. +@@ -57,6 +58,7 @@ public class NodeGraph + private int[] differentialCost; + private byte[] flags; + private byte[] abstractKind; ++ private Transport[] transport; + private int size; + + public NodeGraph(int initialCapacity) +@@ -68,6 +70,7 @@ public class NodeGraph + differentialCost = new int[capacity]; + flags = new byte[capacity]; + abstractKind = new byte[capacity]; ++ transport = new Transport[capacity]; + } + + public int size() +@@ -91,9 +94,11 @@ public class NodeGraph + differentialCost = Arrays.copyOf(differentialCost, newCapacity); + flags = Arrays.copyOf(flags, newCapacity); + abstractKind = Arrays.copyOf(abstractKind, newCapacity); ++ transport = Arrays.copyOf(transport, newCapacity); + } + +- private int append(int packed, int prev, int nodeCost, int diffCost, byte flagBits, byte kind) ++ private int append(int packed, int prev, int nodeCost, int diffCost, byte flagBits, byte kind, ++ Transport selectedTransport) + { + ensureCapacity(); + final int id = size; +@@ -103,6 +108,7 @@ public class NodeGraph + differentialCost[id] = diffCost; + abstractKind[id] = kind; + flags[id] = flagBits; ++ transport[id] = selectedTransport; + size = id + 1; + return id; + } +@@ -117,7 +123,7 @@ public class NodeGraph + */ + public int createStart(int packedPosition) + { +- return append(packedPosition, NO_NODE, 0, 0, (byte) 0, (byte) 0); ++ return append(packedPosition, NO_NODE, 0, 0, (byte) 0, (byte) 0, null); + } + + /** +@@ -131,7 +137,7 @@ public class NodeGraph + ? WorldPointUtil.distanceBetween(this.packedPosition[previous], packedPosition) + : 0; + final byte flagBits = bankVisited ? FLAG_BANK_VISITED : 0; +- return append(packedPosition, previous, costOf(previous) + travelTime, 0, flagBits, (byte) 0); ++ return append(packedPosition, previous, costOf(previous) + travelTime, 0, flagBits, (byte) 0, null); + } + + /** +@@ -139,7 +145,7 @@ public class NodeGraph + * any additional cost; there is no walking-distance term (mirrors the old {@code TransportNode}). + */ + public int createTransport(int packedPosition, int previous, int travelTime, int additionalCost, +- boolean bankVisited, boolean delayedVisit, int differentialCost) ++ boolean bankVisited, boolean delayedVisit, int differentialCost, Transport selectedTransport) + { + byte flagBits = FLAG_TRANSPORT; + if (bankVisited) +@@ -151,7 +157,7 @@ public class NodeGraph + flagBits |= FLAG_DELAYED_VISIT; + } + return append(packedPosition, previous, costOf(previous) + travelTime + additionalCost, +- differentialCost, flagBits, (byte) 0); ++ differentialCost, flagBits, (byte) 0, selectedTransport); + } + + /** +@@ -166,7 +172,7 @@ public class NodeGraph + flagBits |= FLAG_BANK_VISITED; + } + return append(WorldPointUtil.UNDEFINED, previous, costOf(previous), 0, flagBits, +- (byte) abstractKind.ordinal()); ++ (byte) abstractKind.ordinal(), null); + } + + public int packedPosition(int id) +@@ -240,7 +246,8 @@ public class NodeGraph + final int[] prev = previous; + final int[] packed = packedPosition; + final byte[] flg = flags; +- if (prev == null || packed == null || flg == null || id == NO_NODE) ++ final Transport[] selectedTransports = transport; ++ if (prev == null || packed == null || flg == null || selectedTransports == null || id == NO_NODE) + { + return new ArrayList<>(); + } +@@ -269,7 +276,8 @@ public class NodeGraph + { + if ((flg[node] & FLAG_ABSTRACT) == 0) + { +- pathSteps.set(--i, new PathStep(packed[node], (flg[node] & FLAG_BANK_VISITED) != 0)); ++ pathSteps.set(--i, new PathStep(packed[node], ++ (flg[node] & FLAG_BANK_VISITED) != 0, selectedTransports[node])); + } + node = prev[node]; + } +@@ -315,6 +323,7 @@ public class NodeGraph + differentialCost = null; + flags = null; + abstractKind = null; ++ transport = null; + size = 0; + } + } +diff --git a/src/main/java/shortestpath/pathfinder/PathStep.java b/src/main/java/shortestpath/pathfinder/PathStep.java +index 896d67f..77d3af6 100644 +--- a/src/main/java/shortestpath/pathfinder/PathStep.java ++++ b/src/main/java/shortestpath/pathfinder/PathStep.java +@@ -1,16 +1,24 @@ + package shortestpath.pathfinder; + + import lombok.Getter; ++import shortestpath.transport.Transport; + + @Getter + public final class PathStep + { + private final int packedPosition; + private final boolean bankVisited; ++ private final Transport transport; + + public PathStep(int packedPosition, boolean bankVisited) ++ { ++ this(packedPosition, bankVisited, null); ++ } ++ ++ public PathStep(int packedPosition, boolean bankVisited, Transport transport) + { + this.packedPosition = packedPosition; + this.bankVisited = bankVisited; ++ this.transport = transport; + } + } +diff --git a/src/test/java/shortestpath/pathfinder/NodeGraphTest.java b/src/test/java/shortestpath/pathfinder/NodeGraphTest.java +index 1ac3580..1188251 100644 +--- a/src/test/java/shortestpath/pathfinder/NodeGraphTest.java ++++ b/src/test/java/shortestpath/pathfinder/NodeGraphTest.java +@@ -6,6 +6,8 @@ import static org.junit.Assert.assertTrue; + + import org.junit.Test; + import shortestpath.WorldPointUtil; ++import shortestpath.transport.Transport; ++import shortestpath.transport.TransportType; + + /** + * Pins down the cost formulas and flag bookkeeping of the structure-of-arrays node store so a +@@ -86,7 +88,7 @@ public class NodeGraphTest + int travelTime = 6; + int additionalCost = 50; + int differentialCost = 4; +- int transport = graph.createTransport(destination, prev, travelTime, additionalCost, false, true, differentialCost); ++ int transport = graph.createTransport(destination, prev, travelTime, additionalCost, false, true, differentialCost, null); + + // No walking-distance term for transports, unlike a walked tile. + assertEquals(graph.cost(prev) + travelTime + additionalCost, graph.cost(transport)); +@@ -101,14 +103,23 @@ public class NodeGraphTest + public void nonDelayedTransportHasNoDelayedFlagAndZeroDifferential() + { + NodeGraph graph = new NodeGraph(16); +- int start = graph.createStart(WorldPointUtil.packWorldPoint(3200, 3200, 0)); +- int transport = graph.createTransport(WorldPointUtil.packWorldPoint(2800, 3400, 0), start, 6, 0, true, false, 0); ++ int origin = WorldPointUtil.packWorldPoint(3200, 3200, 0); ++ int destination = WorldPointUtil.packWorldPoint(2800, 3400, 0); ++ int start = graph.createStart(origin); ++ Transport selected = new Transport.TransportBuilder() ++ .origin(origin) ++ .destination(destination) ++ .type(TransportType.TRANSPORT) ++ .duration(6) ++ .build(); ++ int transport = graph.createTransport(destination, start, 6, 0, true, false, 0, selected); + + assertTrue(graph.isTransport(transport)); + assertFalse(graph.isDelayedVisit(transport)); + assertEquals(0, graph.differentialCost(transport)); + assertEquals(graph.cost(transport), graph.compareCost(transport)); + assertTrue(graph.bankVisited(transport)); ++ assertTrue(selected == graph.getPathSteps(transport).get(1).getTransport()); + } + + @Test +@@ -121,7 +132,7 @@ public class NodeGraphTest + int start = graph.createStart(a); + int tile = graph.createTile(b, start, false); + int abstractNode = graph.createAbstract(AbstractNodeKind.GLOBAL_TELEPORTS_NORMAL, tile, false); +- int teleportDest = graph.createTransport(c, abstractNode, 6, 0, false, false, 0); ++ int teleportDest = graph.createTransport(c, abstractNode, 6, 0, false, false, 0, null); + + var steps = graph.getPathSteps(teleportDest); + assertEquals(3, steps.size()); // start, tile, teleportDest (abstract is skipped) diff --git a/scripts/shortest-path-planner-harness/upstream-planner-comparison.init.gradle b/scripts/shortest-path-planner-harness/upstream-planner-comparison.init.gradle new file mode 100644 index 00000000000..0ac954467f5 --- /dev/null +++ b/scripts/shortest-path-planner-harness/upstream-planner-comparison.init.gradle @@ -0,0 +1,45 @@ +allprojects { + configurations.configureEach { + resolutionStrategy.eachDependency { details -> + if (details.requested.group != null + && details.requested.group.startsWith('net.runelite') + && details.requested.version == 'latest.release') { + def pinned = gradle.startParameter.projectProperties['plannerRuneliteVersion'] + if (pinned != null && !pinned.isBlank()) { + details.useVersion(pinned) + details.because('Microbot planner comparison pins the RuneLite API revision') + } + } + } + } + + afterEvaluate { project -> + if (project == rootProject) { + tasks.register('exportPlannerComparison', JavaExec) { + group = 'verification' + description = 'Export upstream planner results for Microbot comparison' + dependsOn tasks.named('testClasses') + // The normal runtime classpath pulls every RuneLite LWJGL native classifier. The + // headless planner uses none of them, and some classifiers are intentionally absent + // from Maven local. Test compile classpath contains the Java API, Gson and Mockito. + classpath = sourceSets.test.output + sourceSets.main.output \ + + configurations.testCompileClasspath + mainClass = 'shortestpath.pathfinder.UpstreamPlannerComparisonMain' + + doFirst { + def corpus = project.findProperty('plannerCorpus') + def output = project.findProperty('plannerOutput') + if (corpus == null || output == null) { + throw new GradleException( + 'exportPlannerComparison requires -PplannerCorpus and -PplannerOutput') + } + args corpus, output + systemProperty 'microbot.planner.revision', + project.findProperty('plannerRevision') ?: 'unknown' + } + + outputs.upToDateWhen { false } + } + } + } +} diff --git a/scripts/shortest-path-transport-baseline.json b/scripts/shortest-path-transport-baseline.json new file mode 100644 index 00000000000..88b643686d7 --- /dev/null +++ b/scripts/shortest-path-transport-baseline.json @@ -0,0 +1,282 @@ +{ + "comparisons": { + "agility_shortcuts.tsv -> agility_shortcuts.tsv": { + "comparableFieldDrift": 52, + "comparableFieldDriftDigest": "90d61f52d96d88fa47d7bd93b35477cedc90d5bca5c322798195cb7bef80144c", + "localOnlyDigest": "ae5782932d655dd2a3abe0e724ce01b563c51ba31d3b979323329cda96fbab82", + "localOnlyRoutes": 68, + "sharedRoutes": 337, + "upstreamOnlyClassifications": { + "knownAgilityCourseTraversal": { + "digest": "ceca7807389ce19663ff3e8b9aac16b6457a366cf9ae52a83bce7b5d009e0b19", + "routes": 114 + }, + "representedAsLocalGenericTransport": { + "digest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "routes": 0 + } + }, + "upstreamOnlyDigest": "3c8a5b30c8d7e373f457f84910b1c7063a55641128e6fcffbedcf812fa89abd7", + "upstreamOnlyRoutes": 231 + }, + "boats.tsv -> boats.tsv": { + "comparableFieldDrift": 22, + "comparableFieldDriftDigest": "e90371125a3c10322e4ca63de501230d3336df88a598f0cc70c0dfa43be17a86", + "localOnlyDigest": "5425eb486666e0d2f08b31401a487beeb63543a5d7c38eb5ff619a008c4973bf", + "localOnlyRoutes": 11, + "sharedRoutes": 94, + "upstreamOnlyDigest": "8f42485632d6c536e22f6271978809572294d84e27c47a362ddadc0fe030566d", + "upstreamOnlyRoutes": 32 + }, + "canoes.tsv -> canoes.tsv": { + "comparableFieldDrift": 0, + "comparableFieldDriftDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "localOnlyDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "localOnlyRoutes": 0, + "sharedRoutes": 45, + "upstreamOnlyDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "upstreamOnlyRoutes": 0 + }, + "charter_ships.tsv -> charter_ships.tsv": { + "comparableFieldDrift": 197, + "comparableFieldDriftDigest": "e850ef6af502bae6e4a8dac03a53ac121f37ad643846b889be14da9eff9d0998", + "localOnlyDigest": "fffeac99d0e09a8c8fdec05ce5c888e815c00c1826d34af373fd11cabfae9875", + "localOnlyRoutes": 1, + "sharedRoutes": 228, + "upstreamOnlyDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "upstreamOnlyRoutes": 0 + }, + "fairy_rings.tsv -> fairy_rings.tsv": { + "comparableFieldDrift": 4, + "comparableFieldDriftDigest": "36a6000d838c2cb551704b431dc7a7dc3564bad6994d7fb50b6c44fe380ec1f8", + "localOnlyDigest": "bb89cd98d0af55cd4c8879834934c2b782445c3c78310aa6abc8276d8c0c266c", + "localOnlyRoutes": 56, + "sharedRoutes": 53, + "upstreamOnlyDigest": "34f09156d3dbf6389b3b3f82b0f1d647abe97e890aae4524ddec39b228f3c05c", + "upstreamOnlyRoutes": 58 + }, + "gnome_gliders.tsv -> gnome_gliders.tsv": { + "comparableFieldDrift": 22, + "comparableFieldDriftDigest": "51655fce0c1194a1ffcceb2dd900739315c40ef8ab23698d969ef620981f768e", + "localOnlyDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "localOnlyRoutes": 0, + "sharedRoutes": 22, + "upstreamOnlyDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "upstreamOnlyRoutes": 0 + }, + "hot_air_balloons.tsv -> hot_air_balloons.tsv": { + "comparableFieldDrift": 0, + "comparableFieldDriftDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "localOnlyDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "localOnlyRoutes": 0, + "sharedRoutes": 51, + "upstreamOnlyDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "upstreamOnlyRoutes": 0 + }, + "magic_carpets.tsv -> magic_carpets.tsv": { + "comparableFieldDrift": 12, + "comparableFieldDriftDigest": "20f45064eac242981c611c102b4fa3893cb2d54617b1c399acd4711bf3036126", + "localOnlyDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "localOnlyRoutes": 0, + "sharedRoutes": 12, + "upstreamOnlyDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "upstreamOnlyRoutes": 0 + }, + "magic_mushtrees.tsv -> magic_mushtrees.tsv": { + "comparableFieldDrift": 0, + "comparableFieldDriftDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "localOnlyDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "localOnlyRoutes": 0, + "sharedRoutes": 12, + "upstreamOnlyDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "upstreamOnlyRoutes": 0 + }, + "minecarts.tsv -> minecarts.tsv": { + "comparableFieldDrift": 0, + "comparableFieldDriftDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "localOnlyDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "localOnlyRoutes": 0, + "sharedRoutes": 47, + "upstreamOnlyDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "upstreamOnlyRoutes": 0 + }, + "quetzal_whistle.tsv -> teleportation_items.tsv": { + "comparableFieldDrift": 14, + "comparableFieldDriftDigest": "1762f599fcf2c6fa9e8107f4441d7501e83bdded0e6dd2bebec19d5bab5e481b", + "localOnlyDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "localOnlyRoutes": 0, + "sharedRoutes": 14, + "upstreamOnlyDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "upstreamOnlyRoutes": 0 + }, + "quetzals.tsv -> quetzals.tsv": { + "comparableFieldDrift": 0, + "comparableFieldDriftDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "localOnlyDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "localOnlyRoutes": 0, + "sharedRoutes": 28, + "upstreamOnlyDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "upstreamOnlyRoutes": 0 + }, + "seasonal_transports.tsv -> seasonal_transports.tsv": { + "comparableFieldDrift": 1, + "comparableFieldDriftDigest": "4742eb8ebd8d79fe34bf3635a7b3e2213a38d8a585bdd2b7141a89bd27ce16fa", + "localOnlyDigest": "0313733dd83dfa0504b70c1efa3e02d67ddbb24130a1be56953baf265246a555", + "localOnlyRoutes": 168, + "sharedRoutes": 1, + "upstreamOnlyDigest": "fa0d0b8dc82a01ce784dead7520999e70cc2aaf6b2ff042cbc2b7ad9cc365d4c", + "upstreamOnlyRoutes": 437 + }, + "ships.tsv -> ships.tsv": { + "comparableFieldDrift": 8, + "comparableFieldDriftDigest": "22a9f7526dd718745f42ceb768c44ec3917bcb8efb98befe4707f3d9ba174df4", + "localOnlyDigest": "9431d024c9b96543bff9d082705e5a98e39c10076dbbdf2dc499ad60ee5a3026", + "localOnlyRoutes": 6, + "sharedRoutes": 31, + "upstreamOnlyClassifications": { + "representedByCurrentArdougneShipDeck": { + "digest": "aadd83a77c79e19095c938fcaa2e1cc39e1a26d3db17c84efd4826cbd81ef765", + "routes": 2 + }, + "representedByCurrentCorsairCoveLandings": { + "digest": "246008a786a80af8944de801dae72e2f49711978e5433858c05ab89069669d16", + "routes": 2 + }, + "representedByCurrentVoidOutpostShipDeck": { + "digest": "46492e1951a77289e64da18bae8a2e80311ff5e8101f9af15c154eb3bed71189", + "routes": 2 + } + }, + "upstreamOnlyDigest": "d03b34f8cb697736e43f0eaf3fc57f07776b15d8ec6b0a11dcc45a7212ea289c", + "upstreamOnlyRoutes": 6 + }, + "spirit_trees.tsv -> spirit_trees.tsv": { + "comparableFieldDrift": 144, + "comparableFieldDriftDigest": "b0e03c967ea821c5a968fd7c0de6df9684441e78bf42c0fa679dd374f9a50cdc", + "localOnlyDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "localOnlyRoutes": 0, + "sharedRoutes": 154, + "upstreamOnlyDigest": "4b1bda05a6c12fc11ad724e8430b94491b5ce88d2e33f18c3eb0379f161caa69", + "upstreamOnlyRoutes": 2 + }, + "teleportation_items.tsv -> teleportation_items.tsv": { + "comparableFieldDrift": 122, + "comparableFieldDriftDigest": "1d9f007cac1a27e13f74f27806a98abc6416f807ab18b487db443d01be2e4da8", + "localOnlyDigest": "f97e3461b80e9332b47d4c0f8f099829f0096fbb2469bf7faa7425d63e62af7d", + "localOnlyRoutes": 134, + "sharedRoutes": 140, + "upstreamOnlyDigest": "8328f0a369ecf6d088bd9fe85963e45485b2694cd7f8c21e2ec49e7f3aadbb97", + "upstreamOnlyRoutes": 160 + }, + "teleportation_levers.tsv -> teleportation_levers.tsv": { + "comparableFieldDrift": 0, + "comparableFieldDriftDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "localOnlyDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "localOnlyRoutes": 0, + "sharedRoutes": 7, + "upstreamOnlyDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "upstreamOnlyRoutes": 0 + }, + "teleportation_minigames.tsv -> teleportation_minigames.tsv": { + "comparableFieldDrift": 2, + "comparableFieldDriftDigest": "1a25255e41b9947db091a6b052f90805f5073c1ac3f5f966609bc564f21d18db", + "localOnlyDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "localOnlyRoutes": 0, + "sharedRoutes": 21, + "upstreamOnlyDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "upstreamOnlyRoutes": 0 + }, + "teleportation_portals.tsv -> teleportation_portals.tsv": { + "comparableFieldDrift": 4, + "comparableFieldDriftDigest": "55c5ddd51d1a475ed787e9898549b6ae38367d49621072612cee256d2125c2b0", + "localOnlyDigest": "5fabd1c8b91067d56a02c3fd52be0e664ebfd35fac6f9d7ca6670f94167b2a84", + "localOnlyRoutes": 2, + "sharedRoutes": 96, + "upstreamOnlyDigest": "7e9bf88aed74fe65ac61dd8120210fa41e29c4a24616926a131e312a5524014b", + "upstreamOnlyRoutes": 33 + }, + "teleportation_spells.tsv -> teleportation_spells.tsv": { + "comparableFieldDrift": 19, + "comparableFieldDriftDigest": "fdfc11e78c094f17ad7f8f890158ac9afdac14cde8aac167a9c756d8874c7fbd", + "localOnlyDigest": "90298ac98b16ae54ea3bf3dedf8256405db52f0cf11eac6157a1798c7c564062", + "localOnlyRoutes": 28, + "sharedRoutes": 19, + "upstreamOnlyDigest": "fc92ea877a3d06c7dc5e75d32d5a8aef560a0c60d4959d4815d2bc554b9f56da", + "upstreamOnlyRoutes": 41 + }, + "teleportation_spells_home.tsv -> teleportation_spells.tsv": { + "comparableFieldDrift": 4, + "comparableFieldDriftDigest": "dc5176068d72228054c92ed2a189097855326c9e710f545ca8d859c5ff27074a", + "localOnlyDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "localOnlyRoutes": 0, + "sharedRoutes": 4, + "upstreamOnlyDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "upstreamOnlyRoutes": 0 + }, + "transports.tsv -> transports.tsv": { + "comparableFieldDrift": 698, + "comparableFieldDriftClassifications": { + "concreteElementalWorkshopWallWithConservativeKeyRequirement": { + "digest": "ad53dec2c1f4b08a4615f465401a2224cf92c498875a3f046deb8dc2794e02f2", + "routes": 2 + } + }, + "comparableFieldDriftDigest": "5339089c8f3bd65c67a0b78f522fc43163de8b5abaf358ecafcbd0b1a4cbf663", + "localOnlyDigest": "0ed6b24dbc45afb60f2fa8059c9c81c48f70f842a4e03ef0d7d8690c3059f14e", + "localOnlyRoutes": 478, + "sharedRoutes": 5125, + "upstreamOnlyClassifications": { + "intentionalDisabledVarrockPalaceTrellis": { + "digest": "2bf4e997b38c2d2cbeda9ec0b49cab290b090b948acc5e915b297169653ac3de", + "routes": 2 + }, + "supersededPiscatorisGateAnchors": { + "digest": "24e6795f3d3ffb701289070223a3476ef43c40dfb5006ddd439830c5ef4693b7", + "routes": 4 + }, + "unsupportedIdlessMarimStaircases": { + "digest": "ea1ebf506b4513b8d83b7e8079424018469cb3541a49548e57ef517061f791f7", + "routes": 8 + }, + "unsupportedInteractionlessDaeroTransition": { + "digest": "df3bb3be7ddc5d05e1dbea9985ba9f3d6916ae8b57f7e72724773ffc8153fd2b", + "routes": 1 + } + }, + "upstreamOnlyDigest": "d0ff9af20d234b43bf04e2116b042a37d132b20038849a74908b8558cb98e3fd", + "upstreamOnlyRoutes": 15 + }, + "wilderness_obelisks.tsv -> wilderness_obelisks.tsv": { + "comparableFieldDrift": 54, + "comparableFieldDriftDigest": "831d0acad9a28cdac093f6d34220336440f2de79d6c862312eb51d70cf4c5b4e", + "localOnlyDigest": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "localOnlyRoutes": 0, + "sharedRoutes": 60, + "upstreamOnlyDigest": "a719cd133d37193c509c9fc9a076652b477eab47a06f22947eccebfbf92426c9", + "upstreamOnlyRoutes": 1 + } + }, + "localOnlyFiles": [ + "blocked_edges.tsv", + "dangerous_tiles.tsv", + "npcs.tsv", + "purchasable_items.tsv", + "restrictions.tsv" + ], + "reviewedCommit": "ff8e961b32120175709df9630ece9468cc11347f", + "reviewedDifferences": { + "quetzal_whistle.tsv -> teleportation_items.tsv": { + "comparableFieldDriftDigest": "1762f599fcf2c6fa9e8107f4441d7501e83bdded0e6dd2bebec19d5bab5e481b", + "rationale": "Microbot splits charged whistles from perfected-infinite item 33120 so Inventory (perm) retains the non-consumable alternative; upstream applies Consumable=T to the combined family." + }, + "teleportation_minigames.tsv -> teleportation_minigames.tsv": { + "comparableFieldDriftDigest": "1a25255e41b9947db091a6b052f90805f5073c1ac3f5f966609bc564f21d18db", + "rationale": "Barbarian Assault tutorial access is enforced by shared varbit 3264=11 because it is not a RuneLite Quest enum; Microbot retains the Mage Training Arena unlock varbit because the in-game teleport requires first speaking to the Entrance Guardian." + } + }, + "schemaVersion": 2, + "upstreamOnlyFiles": [ + "teleportation_boxes.tsv", + "teleportation_portals_poh.tsv" + ] +} diff --git a/scripts/shortest-path-upstream-baseline.json b/scripts/shortest-path-upstream-baseline.json new file mode 100644 index 00000000000..9abbd601d74 --- /dev/null +++ b/scripts/shortest-path-upstream-baseline.json @@ -0,0 +1,88 @@ +{ + "schemaVersion": 1, + "repository": "Skretzo/shortest-path", + "branch": "master", + "reviewedCommit": "ff8e961b32120175709df9630ece9468cc11347f", + "reviewedAt": "2026-08-05", + "trackedScopes": [ + { + "upstreamPrefix": "src/main/resources/collision-map.zip", + "localPath": "runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/collision-map.zip", + "policy": "parity-test-before-import" + }, + { + "upstreamPrefix": "src/main/resources/transports/", + "localPath": "runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/", + "policy": "semantic-baseline-selective-import" + }, + { + "upstreamPrefix": "src/main/java/shortestpath/", + "localPath": "runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/", + "policy": "selective-planner-backport" + } + ], + "reviewedArtifacts": [ + { + "upstreamPath": "src/main/resources/collision-map.zip", + "gitBlob": "fbbfbcc5d2b3cdb7ad66cf12c7fc1cc93c51670c", + "sha256": "3a99d42fec10e12dbda96bbaae45b354d8e2270c4c1a453d033e95b7da2670d2", + "size": 1200360, + "decision": "imported-after-core-corpus-and-benchmark-parity" + }, + { + "upstreamPath": "src/main/resources/transports/teleportation_spells_home.tsv", + "gitBlob": "fd9c231a8fd33d513d0fa4ade027611547b770b6", + "size": 1684, + "decision": "selectively-imported-without-animation-setting-variants" + }, + { + "upstreamPath": "src/main/resources/transports/minecarts.tsv", + "gitBlob": "9fe4c3b4c417f8154492d4359bf19d27d283f24c", + "sha256": "1e0cc458a5797ee5407a82932cf8829a8d3fea8db8aed723f9a4a3e8f21bf870", + "size": 5970, + "decision": "semantic-parity-import-with-forsaken-tower-fare-gates" + }, + { + "upstreamPath": "src/main/resources/transports/canoes.tsv", + "gitBlob": "0b14ed48cff12b3ba5181754e0ab36838c992eac", + "sha256": "4c59620fdcf6ac513b3d03159685f2b25a75aa657ae6b7e0421cda4cf5355bb8", + "size": 4731, + "decision": "full-semantic-parity-with-chain-specific-canoe-map-executor-selection" + }, + { + "upstreamPath": "src/main/java/shortestpath/ItemVariations.java", + "gitBlob": "2e65770c2966d51186070645c13d1c5e33202824", + "sha256": "c0e5b683d000b42ac0ea64356a9060bab7afe76c73f5c44e9951ef0b25504ee5", + "size": 8675, + "decision": "selective-symbolic-collection-adapter-with-rune-substitutions-deferred" + }, + { + "upstreamPath": "src/main/resources/transports/teleportation_minigames.tsv", + "gitBlob": "e66e9f8ac0968d5fc83bbfde48ce405d8411db82", + "sha256": "353fa831a27b5bcc99b9c46e60bbfc03fd5206ac54ca55839f60960dc7ad5257", + "size": 2446, + "decision": "route-identity-parity-with-special-level-gates" + }, + { + "upstreamPath": "src/main/resources/transports/teleportation_items.tsv", + "gitBlob": "058f42c9585e441c0ec9d1c2168912d2dce864dd", + "sha256": "048dadce739c7b6e8b124d9a548c6150512afe9f1c5c90e02402b6f4f0d43455", + "size": 31266, + "decision": "direct-max-cape-and-quest-point-cape-family-import-with-behavior-compatible-item-requirements" + }, + { + "upstreamPath": "src/main/resources/transports/quetzals.tsv", + "gitBlob": "073606bb11afdfd4ecbf24825137fbcfeaa59039", + "sha256": "d4dad0c889e133437dac97af2b89b27ff58d1a29d51112293f0cdc2dd2833540", + "size": 1643, + "decision": "semantic-parity-import-with-upstream-interaction-and-varplayer-schema" + }, + { + "upstreamPath": "src/main/resources/transports/quetzal_whistle.tsv", + "gitBlob": "ef4cfddf2857cfc5a5227eb96e3a2d2562ef103e", + "sha256": "0024369a7cd4f11ed8a2fefb8e4c3c3dfd688b6e0f455faacdd572d90af8fcd9", + "size": 1912, + "decision": "behavior-compatible-inline-representation-with-charged-and-infinite-consumability-split" + } + ] +} diff --git a/scripts/shortest-path-vendored-core-baseline.json b/scripts/shortest-path-vendored-core-baseline.json new file mode 100644 index 00000000000..c06e39560b2 --- /dev/null +++ b/scripts/shortest-path-vendored-core-baseline.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": 1, + "repository": "Skretzo/shortest-path", + "revision": "ff8e961b32120175709df9630ece9468cc11347f", + "sourceFileCount": 50, + "sourceTreeSha256": "76c1a3949796ae2958fc9c22f7b7ec795a9334454974ecf23f22d8cc2686ddd8", + "patchSurfacePolicy": { + "maximumPatchedUpstreamFiles": 6, + "maximumAdapterAddedFiles": 1, + "growthRequiresAdrAmendment": true + }, + "metadataSha256": { + "ADAPTER_PATCHES.md": "712537eed33a1e30c3d4d629c92abb1092b3d3a4d8352b566e7c1f9561a32904", + "LICENSE": "7fd99cacafef8e453c0e43aa9c07d34cabbd0048d32e237340a5868c2c554ff7", + "README.md": "7a6b7aa82e01da232368d577732f294b2eda3ec01c0661b34820e020de04bafd", + "UPSTREAM_REVISION": "5a092139a91ada4ab7647f5a56e35e961c8a4e9893fe617c29427af8ae32e5e6" + }, + "patchedUpstreamFiles": [ + "shortestpath/ShortestPathPlugin.java", + "shortestpath/pathfinder/CollisionMap.java", + "shortestpath/pathfinder/NodeGraph.java", + "shortestpath/pathfinder/PathStep.java", + "shortestpath/pathfinder/PathfinderConfig.java", + "shortestpath/pathfinder/TransportAvailability.java" + ], + "adapterAddedFiles": [ + "shortestpath/pathfinder/EdgeOverride.java" + ] +} diff --git a/scripts/tests/test_check_shortest_path_boundary.py b/scripts/tests/test_check_shortest_path_boundary.py new file mode 100644 index 00000000000..28338d235cc --- /dev/null +++ b/scripts/tests/test_check_shortest_path_boundary.py @@ -0,0 +1,339 @@ +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).resolve().parents[1] / "check-shortest-path-boundary.py" + + +class ShortestPathBoundaryTest(unittest.TestCase): + def run_check(self, files: dict[str, str]) -> subprocess.CompletedProcess[str]: + with tempfile.TemporaryDirectory() as temporary: + source_root = Path(temporary) + for relative, contents in files.items(): + target = source_root / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(contents, encoding="utf-8") + return subprocess.run( + [sys.executable, str(SCRIPT), "--source-root", str(source_root)], + check=False, + capture_output=True, + text=True, + ) + + def test_allows_implementation_facade_and_plugin_identity(self): + result = self.run_check( + { + "shortestpath/ShortestPathPlugin.java": + "class ShortestPathPlugin { void x() { ShortestPathPlugin.exit(); } }\n", + "util/walker/Rs2PathApi.java": + "class Rs2PathApi { void x() { ShortestPathPlugin.exit(); } }\n", + "breakhandler/breakhandlerv2/MicrobotPluginChoice.java": ( + "import net.runelite.client.plugins.microbot.shortestpath.ShortestPathPlugin;\n" + "class MicrobotPluginChoice { Class type = ShortestPathPlugin.class; }\n" + ), + } + ) + + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + + def test_rejects_direct_plugin_state_access(self): + result = self.run_check( + { + "util/bank/Rs2Bank.java": + "class Rs2Bank { void x() { ShortestPathPlugin.getPathfinderConfig(); } }\n" + } + ) + + self.assertEqual(1, result.returncode) + self.assertIn("util/bank/Rs2Bank.java:1", result.stdout) + self.assertIn("use util/walker/Rs2PathApi", result.stdout) + + def test_rejects_plugin_import_outside_allowed_seam(self): + result = self.run_check( + { + "questhelper/QuestScript.java": + "import net.runelite.client.plugins.microbot.shortestpath.ShortestPathPlugin;\n" + } + ) + + self.assertEqual(1, result.returncode) + self.assertIn("questhelper/QuestScript.java:1", result.stdout) + + def test_plugin_identity_exception_does_not_hide_state_access(self): + result = self.run_check( + { + "breakhandler/breakhandlerv2/MicrobotPluginChoice.java": ( + "class MicrobotPluginChoice { Object[] x = { ShortestPathPlugin.class, " + "ShortestPathPlugin.getPathfinder() }; }\n" + ) + } + ) + + self.assertEqual(1, result.returncode) + self.assertIn("MicrobotPluginChoice.java:1", result.stdout) + + def test_rejects_direct_pathfinder_in_migrated_synchronous_scope(self): + result = self.run_check( + { + "util/bank/Rs2Bank.java": ( + "import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder;\n" + "class Rs2Bank { Object x(Object c, Object a, Object b) { " + "return new Pathfinder(c, a, b); } }\n" + ) + } + ) + + self.assertEqual(1, result.returncode) + self.assertIn("direct-pathfinder util/bank/Rs2Bank.java:1", result.stdout) + self.assertIn("direct-pathfinder util/bank/Rs2Bank.java:2", result.stdout) + + def test_rejects_new_pathfinder_construction_in_migrated_walker_utilities(self): + result = self.run_check( + { + "util/walker/Rs2Walker.java": + "class Rs2Walker { Object x(Object c, Object a, Object b) { " + "return new Pathfinder(c, a, b); } }\n" + } + ) + + self.assertEqual(1, result.returncode) + self.assertIn( + "direct-pathfinder-construction util/walker/Rs2Walker.java:1", + result.stdout, + ) + + def test_rejects_mutable_planner_config_in_walker(self): + result = self.run_check( + { + "util/walker/Rs2Walker.java": ( + "class Rs2Walker { void x() { " + "Rs2PathApi.getPathfinderConfig().refresh(); } }\n" + ) + } + ) + + self.assertEqual(1, result.returncode) + self.assertIn( + "direct-pathfinder-config util/walker/Rs2Walker.java:1", + result.stdout, + ) + + def test_rejects_active_pathfinder_consumption_outside_facade(self): + result = self.run_check( + { + "util/walker/Rs2Walker.java": ( + "import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder;\n" + "class Rs2Walker { Pathfinder x() { return Rs2PathApi.getPathfinder(); } }\n" + ) + } + ) + + self.assertEqual(1, result.returncode) + self.assertIn( + "direct-active-pathfinder util/walker/Rs2Walker.java:2", + result.stdout, + ) + + def test_allows_owned_active_route_status_consumption(self): + result = self.run_check( + { + "questhelper/QuestScript.java": ( + "class QuestScript { boolean x() { " + "return Rs2PathApi.getActiveRouteStatus().isReady(); } }\n" + ) + } + ) + + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + + def test_rejects_concrete_planner_lifecycle_outside_facade(self): + result = self.run_check( + { + "util/walker/lifecycle/Rs2WalkerLifecycleRuntime.java": ( + "import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder;\n" + "class Rs2WalkerLifecycleRuntime { Object x(Object c, Object a, Object b) { " + "return new Pathfinder(c, a, b); } }\n" + ) + } + ) + + self.assertEqual(1, result.returncode) + self.assertIn( + "direct-pathfinder util/walker/lifecycle/Rs2WalkerLifecycleRuntime.java:1", + result.stdout, + ) + self.assertIn( + "direct-pathfinder util/walker/lifecycle/Rs2WalkerLifecycleRuntime.java:2", + result.stdout, + ) + + def test_rejects_mutable_planner_config_in_migrated_scope(self): + result = self.run_check( + { + "util/npc/Rs2NpcManager.java": ( + "import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathfinderConfig;\n" + "class Rs2NpcManager { Object x() { " + "return Rs2PathApi.getPathfinderConfig(); } }\n" + ) + } + ) + + self.assertEqual(1, result.returncode) + self.assertIn( + "direct-pathfinder-config util/npc/Rs2NpcManager.java:1", + result.stdout, + ) + self.assertIn( + "direct-pathfinder-config util/npc/Rs2NpcManager.java:2", + result.stdout, + ) + + def test_rejects_mutable_planner_config_in_slayer_scope(self): + result = self.run_check( + { + "util/skills/slayer/Rs2Slayer.java": ( + "class Rs2Slayer { void x() { " + "Rs2PathApi.getPathfinderConfig().setUseBankItems(true); } }\n" + ) + } + ) + + self.assertEqual(1, result.returncode) + self.assertIn( + "direct-pathfinder-config util/skills/slayer/Rs2Slayer.java:1", + result.stdout, + ) + + def test_rejects_mutable_planner_config_in_leagues_scope(self): + result = self.run_check( + { + "util/leaguetransport/LeaguesTransportInjection.java": ( + "import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathfinderConfig;\n" + "class LeaguesTransportInjection { PathfinderConfig config; }\n" + ) + } + ) + + self.assertEqual(1, result.returncode) + self.assertIn( + "direct-pathfinder-config util/leaguetransport/LeaguesTransportInjection.java:1", + result.stdout, + ) + + def test_rejects_shortest_path_types_in_owned_route_values(self): + result = self.run_check( + { + "util/walker/Rs2RouteStep.java": ( + "import net.runelite.client.plugins.microbot.shortestpath.Transport;\n" + "class Rs2RouteStep { Transport selected; }\n" + ) + } + ) + + self.assertEqual(1, result.returncode) + self.assertIn( + "owned-value-dependency util/walker/Rs2RouteStep.java:1", + result.stdout, + ) + + def test_rejects_microbot_executor_registry_inside_planner_core(self): + result = self.run_check( + { + "shortestpath/pathfinder/PathfinderConfig.java": ( + "class PathfinderConfig { boolean x(Transport t) { " + "return TransportExecutionRegistry.canExecute(t); } }\n" + ) + } + ) + + self.assertEqual(1, result.returncode) + self.assertIn( + "planner-executor-coupling shortestpath/pathfinder/PathfinderConfig.java:1", + result.stdout, + ) + + def test_rejects_concrete_transport_in_migrated_recovery_scope(self): + result = self.run_check( + { + "util/walker/recovery/RouteRecovery.java": ( + "import net.runelite.client.plugins.microbot.shortestpath.Transport;\n" + "class RouteRecovery { Transport selected; }\n" + ) + } + ) + + self.assertEqual(1, result.returncode) + self.assertIn( + "concrete-transport util/walker/recovery/RouteRecovery.java:1", + result.stdout, + ) + + def test_rejects_concrete_transport_in_migrated_execution_handler(self): + result = self.run_check( + { + "util/walker/Rs2HotAirBalloon.java": ( + "import net.runelite.client.plugins.microbot.shortestpath.Transport;\n" + "class Rs2HotAirBalloon { Transport selected; }\n" + ) + } + ) + + self.assertEqual(1, result.returncode) + self.assertIn( + "concrete-transport util/walker/Rs2HotAirBalloon.java:1", + result.stdout, + ) + + def test_allows_java_and_runelite_api_types_in_owned_route_values(self): + result = self.run_check( + { + "util/walker/Rs2RouteResult.java": ( + "import java.util.List;\n" + "import net.runelite.api.coords.WorldPoint;\n" + "class Rs2RouteResult { List path; }\n" + ) + } + ) + + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + + def test_rejects_removed_legacy_route_handoff_even_in_facade(self): + result = self.run_check( + { + "util/walker/Rs2PathApi.java": ( + "class Rs2PathApi { LegacyRoutePlan planLegacy(Object request) { " + "return null; } }\n" + ) + } + ) + + self.assertEqual(1, result.returncode) + self.assertIn( + "legacy-route-handoff util/walker/Rs2PathApi.java:1", + result.stdout, + ) + + def test_rejects_replanning_transport_requirements_after_bank_comparison(self): + result = self.run_check( + { + "util/walker/Rs2Walker.java": ( + "class Rs2Walker { void x(Object target) {\n" + " getMissingTransportEdges(\n" + " getTransportEdgesForDestination(target, true));\n" + "} }\n" + ) + } + ) + + self.assertEqual(1, result.returncode) + self.assertIn( + "bank-route-replan util/walker/Rs2Walker.java:2", + result.stdout, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_check_shortest_path_vendored_core.py b/scripts/tests/test_check_shortest_path_vendored_core.py new file mode 100644 index 00000000000..4c024f135f2 --- /dev/null +++ b/scripts/tests/test_check_shortest_path_vendored_core.py @@ -0,0 +1,107 @@ +import importlib.util +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +SCRIPT = Path(__file__).resolve().parents[1] / "check-shortest-path-vendored-core.py" +SPEC = importlib.util.spec_from_file_location("vendored_core_check", SCRIPT) +MODULE = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(MODULE) + + +class VendoredCoreCheckTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + base = Path(self.temporary.name) + self.vendored = base / "vendored" + self.upstream = base / "upstream" + for root in (self.vendored, self.upstream): + (root / "src/main/java/shortestpath").mkdir(parents=True) + (self.vendored / "src/main/java/shortestpath/Exact.java").write_text( + "class Exact {}\n", encoding="utf-8" + ) + (self.upstream / "src/main/java/shortestpath/Exact.java").write_text( + "class Exact {}\n", encoding="utf-8" + ) + (self.vendored / "src/main/java/shortestpath/Patched.java").write_text( + "class Patched { int adapter; }\n", encoding="utf-8" + ) + (self.upstream / "src/main/java/shortestpath/Patched.java").write_text( + "class Patched {}\n", encoding="utf-8" + ) + (self.vendored / "src/main/java/shortestpath/Added.java").write_text( + "class Added {}\n", encoding="utf-8" + ) + for name in MODULE.METADATA_FILES: + value = "revision\n" if name == "UPSTREAM_REVISION" else f"{name}\n" + (self.vendored / name).write_text(value, encoding="utf-8") + (self.upstream / "LICENSE").write_bytes((self.vendored / "LICENSE").read_bytes()) + current = MODULE.current_baseline_values(self.vendored) + self.baseline = { + "revision": "revision", + **current, + "patchedUpstreamFiles": ["shortestpath/Patched.java"], + "adapterAddedFiles": ["shortestpath/Added.java"], + "patchSurfacePolicy": { + "maximumPatchedUpstreamFiles": 1, + "maximumAdapterAddedFiles": 1, + "growthRequiresAdrAmendment": True, + }, + } + + def tearDown(self): + self.temporary.cleanup() + + def test_offline_pin_and_declared_checkout_surface_pass(self): + self.assertEqual([], MODULE.verify_offline(self.vendored, self.baseline)) + with mock.patch.object(MODULE, "git_head", return_value="revision"): + self.assertEqual( + [], + MODULE.verify_against_checkout( + self.vendored, self.baseline, self.upstream + ), + ) + + def test_offline_source_mutation_changes_tree_digest(self): + (self.vendored / "src/main/java/shortestpath/Exact.java").write_text( + "class Exact { int drift; }\n", encoding="utf-8" + ) + + failures = MODULE.verify_offline(self.vendored, self.baseline) + + self.assertTrue(any("source tree digest changed" in item for item in failures)) + + def test_checkout_rejects_undeclared_patch(self): + (self.vendored / "src/main/java/shortestpath/Exact.java").write_text( + "class Exact { int drift; }\n", encoding="utf-8" + ) + + with mock.patch.object(MODULE, "git_head", return_value="revision"): + failures = MODULE.verify_against_checkout( + self.vendored, self.baseline, self.upstream + ) + + self.assertTrue( + any("undeclared upstream source patch" in item for item in failures) + ) + + def test_offline_check_rejects_patch_surface_growth_beyond_budget(self): + self.baseline["patchedUpstreamFiles"].append("shortestpath/Exact.java") + + failures = MODULE.verify_offline(self.vendored, self.baseline) + + self.assertTrue(any("exceeds reviewed budget" in item for item in failures)) + + def test_offline_check_requires_adr_amendment_policy(self): + self.baseline["patchSurfacePolicy"]["growthRequiresAdrAmendment"] = False + + failures = MODULE.verify_offline(self.vendored, self.baseline) + + self.assertTrue(any("growthRequiresAdrAmendment" in item for item in failures)) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_compare_shortest_path_planners.py b/scripts/tests/test_compare_shortest_path_planners.py new file mode 100644 index 00000000000..f6d5e328758 --- /dev/null +++ b/scripts/tests/test_compare_shortest_path_planners.py @@ -0,0 +1,270 @@ +import importlib.util +import json +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).resolve().parents[1] / "compare-shortest-path-planners.py" +SPEC = importlib.util.spec_from_file_location("planner_compare", SCRIPT) +MODULE = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(MODULE) + + +def result(case, **overrides): + value = { + "id": case, + "supported": True, + "termination": "TARGET_REACHED", + "reached": True, + "endpoint": {"x": 2, "y": 2, "plane": 0}, + "pathCost": 2, + "nodesChecked": 3, + "transportsChecked": 0, + "selectedTransports": [], + "bankVisited": False, + "elapsedNanos": 10, + } + value.update(overrides) + return value + + +class PlannerComparisonTest(unittest.TestCase): + def setUp(self): + self.corpus = { + "schemaVersion": 3, + "cases": [ + { + "id": "route", + "category": "overland", + "target": {"x": 2, "y": 2, "plane": 0}, + "policy": {"transportMode": "STATIC_COLLISION_ONLY"}, + "expectedReached": True, + } + ] + } + + def compare(self, local_case, upstream_case, require_all=False): + return MODULE.compare_results( + self.corpus, + {"schemaVersion": 3, "revision": "local", "cases": [local_case]}, + {"schemaVersion": 3, "revision": "upstream", "cases": [upstream_case]}, + require_all, + ) + + def test_equivalent_reached_route_passes(self): + report, exit_code = self.compare(result("route"), result("route")) + + self.assertEqual(0, exit_code) + self.assertEqual("PASS", report["comparisons"][0]["status"]) + + def test_reached_cost_difference_fails(self): + report, exit_code = self.compare( + result("route"), result("route", pathCost=3) + ) + + self.assertEqual(1, exit_code) + self.assertIn("reached-path cost differs", report["failures"][0]) + + def test_transport_use_in_static_policy_fails(self): + report, exit_code = self.compare( + result("route", transportsChecked=1), result("route") + ) + + self.assertEqual(1, exit_code) + self.assertTrue( + any("checked transports" in failure for failure in report["failures"]) + ) + + def test_exact_selected_transport_difference_fails(self): + self.corpus["cases"][0]["policy"]["transportMode"] = "EXPLICIT_CATALOG" + self.corpus["cases"][0]["expectedTransportIds"] = ["fast"] + fast = { + "id": "fast", + "from": {"x": 1, "y": 1, "plane": 0}, + "to": {"x": 2, "y": 2, "plane": 0}, + "type": "SPIRIT_TREE", + "duration": 5, + } + wrong = dict(fast, id="slow") + + report, exit_code = self.compare( + result("route", selectedTransports=[fast]), + result("route", selectedTransports=[wrong]), + ) + + self.assertEqual(1, exit_code) + self.assertTrue( + any("selected transports" in failure for failure in report["failures"]) + ) + + def test_expected_bank_visit_is_gated(self): + self.corpus["cases"][0]["expectedBankVisited"] = True + + report, exit_code = self.compare( + result("route", bankVisited=True), result("route") + ) + + self.assertEqual(1, exit_code) + self.assertTrue( + any("bankVisited" in failure for failure in report["failures"]) + ) + + def test_documented_engine_specific_divergence_passes(self): + self.corpus["cases"][0]["policy"]["transportMode"] = "EXPLICIT_CATALOG" + self.corpus["cases"][0]["expectedParity"] = False + self.corpus["cases"][0]["expectedDivergenceReason"] = "reviewed provider difference" + self.corpus["cases"][0]["expectedLocalTransportIds"] = ["spell"] + self.corpus["cases"][0]["expectedUpstreamTransportIds"] = [] + spell = { + "id": "spell", + "from": {"x": 1, "y": 1, "plane": 0}, + "to": {"x": 2, "y": 2, "plane": 0}, + "type": "TELEPORTATION_SPELL", + "duration": 4, + } + + report, exit_code = self.compare( + result("route", pathCost=4, selectedTransports=[spell]), + result("route"), + ) + + self.assertEqual(0, exit_code) + self.assertEqual("EXPECTED_DIVERGENCE", report["comparisons"][0]["status"]) + + def test_undocumented_expected_divergence_fails(self): + self.corpus["cases"][0]["expectedParity"] = False + + report, exit_code = self.compare( + result("route"), result("route", pathCost=3) + ) + + self.assertEqual(1, exit_code) + self.assertTrue( + any("no documented reason" in failure for failure in report["failures"]) + ) + + def test_expected_divergence_must_remain_observable(self): + self.corpus["cases"][0]["expectedParity"] = False + self.corpus["cases"][0]["expectedDivergenceReason"] = "reviewed provider difference" + + report, exit_code = self.compare(result("route"), result("route")) + + self.assertEqual(1, exit_code) + self.assertTrue( + any("was not observed" in failure for failure in report["failures"]) + ) + + def test_unsupported_capability_is_fail_closed_when_required(self): + unsupported = result( + "route", + supported=False, + unsupportedReason="no exact transport identity", + ) + + report, exit_code = self.compare(result("route"), unsupported, True) + + self.assertEqual(2, exit_code) + self.assertEqual("UNSUPPORTED", report["comparisons"][0]["status"]) + + def test_packaged_upstream_adapter_exact_match_passes(self): + packaged = {"cases": [result("route")]} + independent = {"cases": [result("route")]} + + failures, expected = MODULE.compare_upstream_adapters( + self.corpus, packaged, independent + ) + + self.assertEqual([], failures) + self.assertEqual([], expected) + + def test_packaged_upstream_adapter_semantic_mismatch_fails(self): + packaged = {"cases": [result("route", pathCost=3)]} + independent = {"cases": [result("route", pathCost=2)]} + + failures, expected = MODULE.compare_upstream_adapters( + self.corpus, packaged, independent + ) + + self.assertEqual([], expected) + self.assertEqual(1, len(failures)) + self.assertIn("packaged upstream pathCost=3", failures[0]) + + def test_packaged_upstream_expected_input_policy_difference_is_explicit(self): + self.corpus["cases"][0]["expectedParity"] = False + self.corpus["cases"][0]["expectedDivergenceReason"] = ( + "reviewed executable-catalog policy difference" + ) + packaged = {"cases": [result("route", pathCost=3)]} + independent = {"cases": [result("route", pathCost=2)]} + + failures, expected = MODULE.compare_upstream_adapters( + self.corpus, packaged, independent + ) + + self.assertEqual([], failures) + self.assertEqual( + ["route: reviewed executable-catalog policy difference"], expected + ) + + def test_checked_in_corpus_covers_static_and_exact_network_slices(self): + corpus_path = SCRIPT.with_name("shortest-path-planner-corpus.json") + corpus = json.loads(corpus_path.read_text(encoding="utf-8")) + categories = {case["category"] for case in corpus["cases"]} + + self.assertTrue( + { + "overland", + "underground", + "surface-underground-surface", + "unreachable", + "wilderness", + "network", + }.issubset( + categories + ) + ) + network_cases = [case for case in corpus["cases"] if case["category"] == "network"] + self.assertTrue(network_cases) + self.assertTrue(all(case["expectedTransportIds"] for case in network_cases)) + bank_cases = [case for case in corpus["cases"] if case["category"] == "bank"] + self.assertEqual(3, len(bank_cases)) + self.assertEqual( + {"EXPLICIT_CATALOG", "BANK_AWARE_EXPLICIT_CATALOG"}, + {case["policy"]["transportMode"] for case in bank_cases}, + ) + bank_aware_cases = [ + case + for case in bank_cases + if case["policy"]["transportMode"] == "BANK_AWARE_EXPLICIT_CATALOG" + ] + self.assertTrue( + any(case["start"] not in case["bankLocations"] for case in bank_aware_cases) + ) + for case in corpus["cases"]: + self.assertIn( + case["policy"]["transportMode"], + { + "STATIC_COLLISION_ONLY", + "EXPLICIT_CATALOG", + "BANK_AWARE_EXPLICIT_CATALOG", + }, + ) + self.assertGreater(case["policy"]["cutoffMillis"], 0) + self.assertEqual(0, case["policy"]["cutoffMillis"] % 600) + transport_ids = {transport["id"] for transport in case.get("transports", [])} + for field in ( + "expectedTransportIds", + "expectedLocalTransportIds", + "expectedUpstreamTransportIds", + ): + self.assertTrue(set(case.get(field, [])).issubset(transport_ids)) + if not case.get("expectedParity", True): + self.assertTrue(case.get("expectedDivergenceReason")) + if case["policy"]["transportMode"] == "BANK_AWARE_EXPLICIT_CATALOG": + self.assertTrue(case.get("bankLocations")) + self.assertTrue(case.get("expectedBankVisited")) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_compare_shortest_path_transports.py b/scripts/tests/test_compare_shortest_path_transports.py new file mode 100644 index 00000000000..4b8a8652108 --- /dev/null +++ b/scripts/tests/test_compare_shortest_path_transports.py @@ -0,0 +1,563 @@ +import importlib.util +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).resolve().parents[1] / "compare-shortest-path-transports.py" +SPEC = importlib.util.spec_from_file_location("compare_shortest_path_transports", SCRIPT) +MODULE = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +sys.modules[SPEC.name] = MODULE +SPEC.loader.exec_module(MODULE) + + +class CompareShortestPathTransportsTest(unittest.TestCase): + def write_tsv(self, root: Path, name: str, contents: str) -> Path: + path = root / name + path.write_text(contents, encoding="utf-8") + return path + + def test_parser_normalizes_headers_and_trailing_empty_columns(self): + with tempfile.TemporaryDirectory() as temp_dir: + path = self.write_tsv( + Path(temp_dir), + "boats.tsv", + "# Origin\tDestination\tVarPlayers\tDisplay info\n" + "1 2 0\t3 4 0\t892@30\tExample Boat\n", + ) + row = MODULE.read_transport_tsv(path)[0] + + self.assertEqual(("1 2 0", "3 4 0", ""), row.route_key) + self.assertEqual("example boat", row.display_info) + self.assertEqual("892@30", row.fields["varplayers"]) + + def test_course_object_catalog_comes_from_runelite_obstacles(self): + course_ids = MODULE.load_agility_course_object_ids() + + self.assertIn(23134, course_ids) # Gnome course obstacle net + self.assertNotIn(3921, course_ids) # Regicide forest tripwire + self.assertNotIn(16518, course_ids) # Lumbridge-farm world shortcut + + def test_agility_course_classification_does_not_hide_world_shortcuts(self): + with tempfile.TemporaryDirectory() as upstream_dir, tempfile.TemporaryDirectory() as local_dir: + header = "# Origin\tDestination\tmenuOption menuTarget objectID\tSkills\n" + self.write_tsv( + Path(upstream_dir), + "agility_shortcuts.tsv", + header + + "1 2 0\t3 4 0\tClimb-over Obstacle net 23134\t1 Agility\n" + + "5 6 0\t7 8 0\tJump-over Fence 16518\t13 Agility\n", + ) + self.write_tsv(Path(local_dir), "agility_shortcuts.tsv", header) + + result = MODULE.compare_spec( + Path(upstream_dir), + Path(local_dir), + MODULE.ComparisonSpec("agility_shortcuts.tsv", "agility_shortcuts.tsv"), + ) + + self.assertEqual(2, len(result["upstreamOnlyRoutes"])) + self.assertEqual( + ["1 2 0 -> 3 4 0"], + result["upstreamOnlyClassifications"]["knownAgilityCourseTraversal"], + ) + self.assertIn("5 6 0 -> 7 8 0", result["upstreamOnlyRoutes"]) + + def test_agility_shortcut_represented_as_generic_transport_stays_visible(self): + with tempfile.TemporaryDirectory() as upstream_dir, tempfile.TemporaryDirectory() as local_dir: + agility_header = "# Origin\tDestination\tmenuOption menuTarget objectID\tSkills\n" + transport_header = "# Origin\tDestination\tmenuOption menuTarget objectID\tSkills\n" + self.write_tsv( + Path(upstream_dir), + "agility_shortcuts.tsv", + agility_header + + "5 6 0\t7 8 0\tJump-over Fence 16518\t13 Agility\n", + ) + self.write_tsv(Path(local_dir), "agility_shortcuts.tsv", agility_header) + self.write_tsv( + Path(local_dir), + "transports.tsv", + transport_header + + "5 6 0\t7 8 0\tJump-over;Fence;16518\t\n", + ) + + result = MODULE.compare_spec( + Path(upstream_dir), + Path(local_dir), + MODULE.ComparisonSpec("agility_shortcuts.tsv", "agility_shortcuts.tsv"), + ) + + self.assertEqual(["5 6 0 -> 7 8 0"], result["upstreamOnlyRoutes"]) + self.assertEqual( + ["5 6 0 -> 7 8 0"], + result["upstreamOnlyClassifications"]["representedAsLocalGenericTransport"], + ) + + def test_route_identity_ignores_display_and_format_specific_interaction(self): + upstream = MODULE.TransportRow( + "boats.tsv", + 2, + {"origin": "1 2 0", "destination": "3 4 0", "displayinfo": "Old name"}, + ) + local = MODULE.TransportRow( + "boats.tsv", + 2, + {"origin": " 1 2 0 ", "destination": "3 4 0", "displayinfo": "New name"}, + ) + + self.assertEqual(upstream.route_key, local.route_key) + + def test_comparison_separates_missing_routes_from_requirement_drift(self): + with tempfile.TemporaryDirectory() as upstream_dir, tempfile.TemporaryDirectory() as local_dir: + header = "# Origin\tDestination\tmenuOption menuTarget objectID\tQuests\tDuration\tDisplay info\n" + self.write_tsv( + Path(upstream_dir), + "boats.tsv", + header + + "1 2 0\t3 4 0\tTravel Boatman 10\tQuest A\t5\tShared\n" + + "5 6 0\t7 8 0\tTravel Boatman 11\t\t5\tMissing\n", + ) + self.write_tsv( + Path(local_dir), + "boats.tsv", + header + + "1 2 0\t3 4 0\tTravel;Boatman;10\tQuest B\t5\tShared\n", + ) + + result = MODULE.compare_spec( + Path(upstream_dir), + Path(local_dir), + MODULE.ComparisonSpec("boats.tsv", "boats.tsv"), + ) + + self.assertEqual(["5 6 0 -> 7 8 0"], result["upstreamOnlyRoutes"]) + self.assertEqual(1, len(result["comparableFieldDrift"])) + + def test_ordinary_transport_exclusions_are_exact_and_leave_unknown_routes_unclassified(self): + with tempfile.TemporaryDirectory() as upstream_dir, tempfile.TemporaryDirectory() as local_dir: + header = "# Origin\tDestination\tmenuOption menuTarget objectID\tItems\n" + self.write_tsv( + Path(upstream_dir), + "transports.tsv", + header + + "2343 3662 0\t2343 3663 0\tOpen Colony gate\t\n" + + "2724 2747 0\t2770 2793 0\t\t\n" + + "2795 2793 0\t2795 2797 1\tClimb-up Staircase\t\n" + + "3228 3470 0\t3228 3472 0\tClimb Trellis 2149\t\n" + + "1 2 0\t3 4 0\tOpen Mystery 999\t\n", + ) + self.write_tsv(Path(local_dir), "transports.tsv", header) + + result = MODULE.compare_spec( + Path(upstream_dir), + Path(local_dir), + MODULE.ComparisonSpec("transports.tsv", "transports.tsv"), + ) + + classifications = result["upstreamOnlyClassifications"] + self.assertEqual( + ["2343 3662 0 -> 2343 3663 0"], + classifications["supersededPiscatorisGateAnchors"], + ) + self.assertEqual( + ["2724 2747 0 -> 2770 2793 0"], + classifications["unsupportedInteractionlessDaeroTransition"], + ) + self.assertEqual( + ["2795 2793 0 -> 2795 2797 1"], + classifications["unsupportedIdlessMarimStaircases"], + ) + self.assertEqual( + ["3228 3470 0 -> 3228 3472 0"], + classifications["intentionalDisabledVarrockPalaceTrellis"], + ) + classified = {route for routes in classifications.values() for route in routes} + self.assertNotIn("1 2 0 -> 3 4 0", classified) + + def test_elemental_wall_conservative_requirement_drift_is_classified(self): + with tempfile.TemporaryDirectory() as upstream_dir, tempfile.TemporaryDirectory() as local_dir: + self.write_tsv( + Path(upstream_dir), + "transports.tsv", + "# Origin\tDestination\tmenuOption menuTarget objectID\tItems\n" + "2709 3495 0\t2709 3496 0\tOpen Odd-looking wall\t2887=1|4446=1\n", + ) + self.write_tsv( + Path(local_dir), + "transports.tsv", + "# Origin\tDestination\tmenuOption menuTarget objectID\tItem IDs\n" + "2709 3495 0\t2709 3496 0\tOpen;Odd-looking wall;26115\t2887=1\n", + ) + + result = MODULE.compare_spec( + Path(upstream_dir), + Path(local_dir), + MODULE.ComparisonSpec("transports.tsv", "transports.tsv"), + ) + + self.assertEqual(1, len(result["comparableFieldDrift"])) + self.assertEqual( + ["2709 3495 0 -> 2709 3496 0"], + result["comparableFieldDriftClassifications"][ + "concreteElementalWorkshopWallWithConservativeKeyRequirement" + ], + ) + + def test_ship_landing_representations_are_exact_and_leave_unknown_routes_unclassified(self): + with tempfile.TemporaryDirectory() as upstream_dir, tempfile.TemporaryDirectory() as local_dir: + header = "# Origin\tDestination\tmenuOption menuTarget objectID\tDisplay info\n" + self.write_tsv( + Path(upstream_dir), + "ships.tsv", + header + + "2578 2840 0\t2956 3146 0\tTravel Captain 1\tRimmington\n" + + "1 2 0\t3 4 0\tTravel Captain 2\tMystery Port\n", + ) + self.write_tsv(Path(local_dir), "ships.tsv", header) + + result = MODULE.compare_spec( + Path(upstream_dir), + Path(local_dir), + MODULE.ComparisonSpec("ships.tsv", "ships.tsv", network_identity=True), + ) + + classifications = result["upstreamOnlyClassifications"] + self.assertEqual( + ["2578 2840 0 -> rimmington"], + classifications["representedByCurrentCorsairCoveLandings"], + ) + classified = {route for routes in classifications.values() for route in routes} + self.assertNotIn("1 2 0 -> mystery port", classified) + + def test_home_teleport_mapping_filters_regular_spells(self): + home = MODULE.TransportRow( + "teleportation_spells.tsv", + 2, + {"destination": "1 2 0", "displayinfo": "Lumbridge Home Teleport"}, + ) + regular = MODULE.TransportRow( + "teleportation_spells.tsv", + 3, + {"destination": "1 2 0", "displayinfo": "Lumbridge Teleport"}, + ) + + self.assertTrue(MODULE.is_home_teleport(home)) + self.assertFalse(MODULE.is_home_teleport(regular)) + + def test_inline_quetzal_whistles_are_compared_with_the_upstream_family(self): + with tempfile.TemporaryDirectory() as upstream_dir, tempfile.TemporaryDirectory() as local_dir: + upstream_root = Path(upstream_dir) + local_root = Path(local_dir) + self.write_tsv( + upstream_root, + "teleportation_items.tsv", + "# Destination\tItems\tDuration\tDisplay info\n" + "3 4 0\t200=1\t4\tRegular teleport\n", + ) + self.write_tsv( + upstream_root, + "quetzal_whistle.tsv", + "# Destination\tItems\tDuration\tDisplay info\n" + "1 2 0\t100=1\t4\tQuetzal whistle: Aldarin\n", + ) + self.write_tsv( + local_root, + "teleportation_items.tsv", + "# Destination\tItem IDs\tDuration\tDisplay info\n" + "3 4 0\t200=1\t4\tRegular teleport\n" + "1 2 0\t100=1\t4\tQuetzal whistle: Aldarin\n", + ) + + specs, upstream_only, local_only = MODULE.comparison_specs(upstream_root, local_root) + report = MODULE.build_report(upstream_root, local_root) + + self.assertEqual([], upstream_only) + self.assertEqual([], local_only) + self.assertEqual(2, len(specs)) + comparisons = { + MODULE.comparison_id(comparison): comparison + for comparison in report["comparisons"] + } + self.assertEqual( + 1, + comparisons["teleportation_items.tsv -> teleportation_items.tsv"]["sharedRoutes"], + ) + self.assertEqual( + 1, + comparisons["quetzal_whistle.tsv -> teleportation_items.tsv"]["sharedRoutes"], + ) + + def test_split_infinite_whistle_variant_is_visible_as_reviewable_drift(self): + with tempfile.TemporaryDirectory() as upstream_dir, tempfile.TemporaryDirectory() as local_dir: + upstream_root = Path(upstream_dir) + local_root = Path(local_dir) + self.write_tsv( + upstream_root, + "quetzal_whistle.tsv", + "# Destination\tItems\tDisplay info\tConsumable\n" + "1 2 0\t10=1||11=1\tQuetzal whistle: Aldarin\tT\n", + ) + self.write_tsv( + local_root, + "teleportation_items.tsv", + "# Destination\tItem IDs\tDisplay info\tConsumable\n" + "1 2 0\t10=1\tQuetzal whistle: Aldarin\tT\n" + "1 2 0\t11=1\tQuetzal whistle: Aldarin\tF\n", + ) + + result = MODULE.compare_spec( + upstream_root, + local_root, + MODULE.ComparisonSpec( + "quetzal_whistle.tsv", + "teleportation_items.tsv", + MODULE.is_quetzal_whistle, + ), + ) + + self.assertEqual(1, result["sharedRoutes"]) + self.assertEqual([], result["upstreamOnlyRoutes"]) + self.assertEqual([], result["localOnlyRoutes"]) + self.assertEqual(1, len(result["comparableFieldDrift"])) + self.assertEqual( + ["items", "consumable"], + result["comparableFieldDrift"][0]["differingFields"], + ) + self.assertEqual(1, result["comparableFieldDrift"][0]["upstreamVariants"]) + self.assertEqual(2, result["comparableFieldDrift"][0]["localVariants"]) + + def test_originless_routes_include_normalized_display_identity(self): + ge_long = MODULE.TransportRow( + "teleportation_spells.tsv", + 2, + {"destination": "1 2 0", "displayinfo": "Varrock Teleport: Grand Exchange"}, + ) + ge_short = MODULE.TransportRow( + "teleportation_spells.tsv", + 3, + {"destination": "1 2 0", "displayinfo": "Varrock Teleport: GE"}, + ) + other = MODULE.TransportRow( + "teleportation_spells.tsv", + 4, + {"destination": "1 2 0", "displayinfo": "Different spell"}, + ) + + self.assertEqual(ge_long.route_key, ge_short.route_key) + self.assertNotEqual(ge_long.route_key, other.route_key) + + def test_origin_only_routes_do_not_require_a_display_label(self): + upstream = MODULE.TransportRow( + "wilderness_obelisks.tsv", + 2, + {"origin": "1 2 0", "destination": "", "displayinfo": ""}, + ) + local = MODULE.TransportRow( + "wilderness_obelisks.tsv", + 2, + {"origin": "1 2 0", "destination": "", "displayinfo": "Level 13 Wilderness"}, + ) + + self.assertEqual(upstream.route_key, local.route_key) + + def test_display_normalization_removes_selector_and_minigame_suffix(self): + self.assertEqual( + "barbarian assault", + MODULE.normalize_display_info("1: Barbarian Assault Minigame Teleport"), + ) + self.assertEqual( + "rat pits: ardougne", + MODULE.normalize_display_info("Rat Pits Minigame Teleport: 1. Ardougne"), + ) + + def test_coin_costs_compare_across_upstream_and_local_formats(self): + upstream = MODULE.TransportRow( + "minecarts.tsv", + 2, + {"items": "COINS=20", "quests": "Quest A"}, + ) + local = MODULE.TransportRow( + "minecarts.tsv", + 2, + {"currency": "20 Coins", "quests": "Quest A"}, + ) + + self.assertEqual(upstream.comparable_fingerprint, local.comparable_fingerprint) + + def test_network_identity_matches_different_execution_tiles_by_location(self): + upstream = [ + MODULE.TransportRow( + "ships.tsv", + 2, + {"origin": "10 10 0", "destination": "20 20 0", "displayinfo": "Musa Point"}, + ), + MODULE.TransportRow( + "ships.tsv", + 3, + {"origin": "20 20 0", "destination": "10 10 0", "displayinfo": "Port Sarim"}, + ), + ] + local = [ + MODULE.TransportRow( + "ships.tsv", + 2, + {"origin": "12 11 0", "destination": "22 21 1", "displayinfo": "Musa Point"}, + ), + MODULE.TransportRow( + "ships.tsv", + 3, + {"origin": "22 21 0", "destination": "12 11 1", "displayinfo": "Port Sarim"}, + ), + ] + + self.assertEqual( + set(MODULE.index_rows(upstream, network_identity=True)), + set(MODULE.index_rows(local, network_identity=True)), + ) + + def test_requirement_drift_identifies_the_changed_dimensions(self): + with tempfile.TemporaryDirectory() as upstream_dir, tempfile.TemporaryDirectory() as local_dir: + header = "# Origin\tDestination\tItems\tQuests\tDuration\tDisplay info\n" + self.write_tsv( + Path(upstream_dir), + "boats.tsv", + header + "1 2 0\t3 4 0\tCOINS=20\tQuest A\t5\tShared\n", + ) + self.write_tsv( + Path(local_dir), + "boats.tsv", + "# Origin\tDestination\tCurrency\tQuests\tDuration\tDisplay info\n" + "1 2 0\t3 4 0\t20 Coins\tQuest B\t6\tShared\n", + ) + + result = MODULE.compare_spec( + Path(upstream_dir), + Path(local_dir), + MODULE.ComparisonSpec("boats.tsv", "boats.tsv"), + ) + + drift = result["comparableFieldDrift"][0] + self.assertEqual(["quests", "duration"], drift["differingFields"]) + + def test_absent_schema_field_is_unknown_instead_of_empty(self): + with tempfile.TemporaryDirectory() as upstream_dir, tempfile.TemporaryDirectory() as local_dir: + self.write_tsv( + Path(upstream_dir), + "teleportation_items.tsv", + "# Destination\tItems\tDuration\tDisplay info\n" + "1 2 0\t100=1\t4\tExample teleport\n", + ) + self.write_tsv( + Path(local_dir), + "teleportation_items.tsv", + "# Destination\tItem IDs\tisMembers\tDuration\tDisplay info\n" + "1 2 0\t100=1\tY\t4\tExample teleport\n", + ) + + result = MODULE.compare_spec( + Path(upstream_dir), + Path(local_dir), + MODULE.ComparisonSpec("teleportation_items.tsv", "teleportation_items.tsv"), + ) + + self.assertEqual([], result["comparableFieldDrift"]) + + def test_transport_baseline_detects_identity_changes_not_just_counts(self): + report = { + "upstreamOnlyFiles": [], + "localOnlyFiles": ["restrictions.tsv"], + "comparisons": [ + { + "upstreamFile": "boats.tsv", + "localFile": "boats.tsv", + "sharedRoutes": 1, + "upstreamOnlyRoutes": ["1 2 0 -> 3 4 0"], + "localOnlyRoutes": [], + "comparableFieldDrift": [], + } + ], + } + expected = MODULE.build_transport_baseline(report, "abc123") + changed_report = { + **report, + "comparisons": [ + { + **report["comparisons"][0], + "upstreamOnlyRoutes": ["5 6 0 -> 7 8 0"], + } + ], + } + actual = MODULE.build_transport_baseline(changed_report, "abc123") + + differences = MODULE.baseline_differences(expected, actual) + + self.assertTrue(any("upstreamOnlyDigest" in difference for difference in differences)) + self.assertFalse(any("upstreamOnlyRoutes:" in difference for difference in differences)) + + def test_reviewed_difference_rationale_is_bound_to_exact_drift_digest(self): + report = { + "upstreamOnlyFiles": [], + "localOnlyFiles": [], + "comparisons": [ + { + "upstreamFile": "boats.tsv", + "localFile": "boats.tsv", + "sharedRoutes": 1, + "upstreamOnlyRoutes": [], + "localOnlyRoutes": [], + "comparableFieldDrift": [ + { + "route": "1 2 0 -> 3 4 0", + "upstreamVariants": 1, + "localVariants": 1, + "differingFields": ["duration"], + } + ], + } + ], + } + actual = MODULE.build_transport_baseline(report, "abc123") + comparison_id = "boats.tsv -> boats.tsv" + expected = { + **actual, + "reviewedDifferences": { + comparison_id: { + "comparableFieldDriftDigest": actual["comparisons"][comparison_id][ + "comparableFieldDriftDigest" + ], + "rationale": "Local duration reflects observed execution time.", + } + }, + } + + self.assertEqual([], MODULE.baseline_differences(expected, actual)) + + expected["reviewedDifferences"][comparison_id][ + "comparableFieldDriftDigest" + ] = "stale" + differences = MODULE.baseline_differences(expected, actual) + self.assertTrue(any("reviewed difference digest" in item for item in differences)) + + expected["reviewedDifferences"][comparison_id][ + "comparableFieldDriftDigest" + ] = actual["comparisons"][comparison_id]["comparableFieldDriftDigest"] + expected["reviewedDifferences"][comparison_id]["rationale"] = "" + differences = MODULE.baseline_differences(expected, actual) + self.assertTrue(any("missing rationale" in item for item in differences)) + + def test_transport_baseline_is_stable_for_identical_reports(self): + report = { + "upstreamOnlyFiles": [], + "localOnlyFiles": [], + "comparisons": [], + } + baseline = MODULE.build_transport_baseline(report, "abc123") + + self.assertEqual(2, baseline["schemaVersion"]) + self.assertEqual([], MODULE.baseline_differences(baseline, baseline)) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_evaluate_walker_rollout_evidence.py b/scripts/tests/test_evaluate_walker_rollout_evidence.py new file mode 100644 index 00000000000..e84aee84bce --- /dev/null +++ b/scripts/tests/test_evaluate_walker_rollout_evidence.py @@ -0,0 +1,213 @@ +import importlib.util +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).resolve().parents[1] / "evaluate-walker-rollout-evidence.py" +SPEC = importlib.util.spec_from_file_location("walker_rollout_evidence", SCRIPT) +MODULE = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(MODULE) + +REVISION = "f" * 40 +ENGINE = f"shortest-path-upstream@{REVISION}" + + +def result(*, rollback=False, started=123, completed=10, arrivals=10): + matches = 0 if rollback else completed + failures = completed if rollback else 0 + outcome = { + "completed": completed, + "matches": matches, + "divergences": 0, + "failures": failures, + } + snapshot = { + "schemaVersion": 2, + "enabled": True, + "plannerMode": "UPSTREAM_F2P_CANARY", + "candidateEngineId": ENGINE, + "startedAtEpochMillis": started, + "totals": { + "submitted": completed, + "completed": completed, + "matches": matches, + "divergences": 0, + "failures": failures, + "staleResults": 0, + "discarded": 0, + "pending": 0, + "routeShapeDifferences": 0, + "upstreamCanarySelections": 0 if rollback else completed, + "localFallbackDivergences": 0, + "localFallbackFailures": completed if rollback else 0, + }, + "execution": { + "terminal": arrivals, + "arrived": arrivals, + "unreachable": 0, + "exited": 0, + "recoveryTerminal": 0, + "recoveryArrived": 0, + "recoveryUnreachable": 0, + "recoveryExited": 0, + }, + "canaryPerformance": { + "planningSamples": completed, + "planningNanosTotal": completed * (60_000_000 if rollback else 100_000_000), + "planningNanosMax": 60_000_000 if rollback else 100_000_000, + "localSearchNanosTotal": completed * 40_000_000, + "localSearchNanosMax": 40_000_000, + "upstreamSearchSamples": 0 if rollback else completed, + "upstreamSearchNanosTotal": 0 if rollback else completed * 30_000_000, + "upstreamSearchNanosMax": 0 if rollback else 30_000_000, + }, + "coverage": { + "ACTIVE_ROUTE": dict(outcome), + "UNDERGROUND_COORDINATES": dict(outcome), + }, + "transportExecutors": {"OBJECT": dict(outcome)}, + "transportTypes": {}, + } + if rollback: + snapshot["latestFailure"] = { + "status": "FAILED", + "shadowEngineId": ENGINE, + "failureType": "IllegalStateException", + } + return { + "script": "F2P Web Walker Harness", + "exitCode": 0, + "exitReason": "completed", + "errors": [], + "plannerMode": "UPSTREAM_F2P_CANARY", + "expectLocalFallback": rollback, + "shadowSettled": True, + "checks": [ + {"name": "F2P-17 route", "passed": True}, + {"name": "planner comparison and selection evidence", "passed": True}, + ], + "selectedRoutes": ["F2P-17"], + "routes": [ + { + "id": "F2P-17", + "passed": True, + "repetitions": 5, + "successfulAttempts": 5, + "walkerState": "ARRIVED", + } + ], + "shadowEvidence": snapshot, + } + + +class WalkerRolloutEvidenceTest(unittest.TestCase): + def test_representative_pair_is_accepted(self): + report = MODULE.evaluate( + result(started=123), result(rollback=True, started=456), REVISION + ) + + self.assertEqual("ACCEPTED", report["verdict"]) + self.assertEqual(10, report["normal"]["totals"]["upstreamCanarySelections"]) + self.assertEqual(10, report["rollback"]["totals"]["localFallbackFailures"]) + + def test_normal_canary_must_select_every_match(self): + normal = result() + normal["shadowEvidence"]["totals"]["upstreamCanarySelections"] = 9 + + report = MODULE.evaluate(normal, result(rollback=True, started=456), REVISION) + + self.assertEqual("REJECTED", report["verdict"]) + self.assertTrue(any("select upstream" in value for value in report["failures"])) + + def test_rollback_must_fall_back_for_every_failure(self): + rollback = result(rollback=True, started=456) + rollback["shadowEvidence"]["totals"]["localFallbackFailures"] = 9 + + report = MODULE.evaluate(result(), rollback, REVISION) + + self.assertEqual("REJECTED", report["verdict"]) + self.assertTrue(any("locally fall back" in value for value in report["failures"])) + + def test_pair_must_come_from_distinct_client_sessions(self): + report = MODULE.evaluate(result(), result(rollback=True), REVISION) + + self.assertEqual("REJECTED", report["verdict"]) + self.assertTrue(any("same client session" in value for value in report["failures"])) + + def test_low_comparison_and_arrival_counts_are_insufficient(self): + report = MODULE.evaluate( + result(completed=9, arrivals=9), + result(rollback=True, started=456, completed=9, arrivals=9), + REVISION, + ) + + self.assertEqual("INSUFFICIENT_EVIDENCE", report["verdict"]) + self.assertTrue(any("completed comparison" in value for value in report["evidenceShortfalls"])) + self.assertTrue(any("terminal arrival" in value for value in report["evidenceShortfalls"])) + + def test_incomplete_live_route_rejects(self): + normal = result() + normal["routes"][0]["successfulAttempts"] = 4 + + report = MODULE.evaluate(normal, result(rollback=True, started=456), REVISION) + + self.assertEqual("REJECTED", report["verdict"]) + self.assertTrue(any("every repetition" in value for value in report["failures"])) + + def test_canary_planning_samples_must_cover_every_comparison(self): + normal = result() + normal["shadowEvidence"]["canaryPerformance"]["planningSamples"] = 9 + + report = MODULE.evaluate(normal, result(rollback=True, started=456), REVISION) + + self.assertEqual("REJECTED", report["verdict"]) + self.assertTrue(any("planningSamples" in value for value in report["failures"])) + + def test_canary_readiness_maximum_is_a_release_gate(self): + normal = result() + performance = normal["shadowEvidence"]["canaryPerformance"] + performance["planningNanosMax"] = 2_000_000_001 + performance["planningNanosTotal"] = 2_900_000_001 + + report = MODULE.evaluate(normal, result(rollback=True, started=456), REVISION) + + self.assertEqual("REJECTED", report["verdict"]) + self.assertTrue(any("planning maximum" in value for value in report["failures"])) + + def test_combined_canary_non_search_overhead_is_a_release_gate(self): + normal = result() + performance = normal["shadowEvidence"]["canaryPerformance"] + performance["planningNanosTotal"] = 3_300_000_000 + + report = MODULE.evaluate(normal, result(rollback=True, started=456), REVISION) + + self.assertEqual("REJECTED", report["verdict"]) + self.assertTrue(any("non-search overhead" in value for value in report["failures"])) + + def test_wrong_engine_and_failure_message_reject(self): + rollback = result(rollback=True, started=456) + rollback["shadowEvidence"]["candidateEngineId"] = "shortest-path-upstream@" + "0" * 40 + rollback["shadowEvidence"]["latestFailure"]["message"] = "sensitive details" + + report = MODULE.evaluate(result(), rollback, REVISION) + + self.assertEqual("REJECTED", report["verdict"]) + self.assertTrue(any("reviewed engine" in value for value in report["failures"])) + self.assertTrue(any("must not expose" in value for value in report["failures"])) + + def test_markdown_contains_both_release_phases(self): + report = MODULE.evaluate( + result(started=123), result(rollback=True, started=456), REVISION + ) + text = MODULE.markdown(report) + + self.assertIn("`ACCEPTED`", text) + self.assertIn("Normal canary", text) + self.assertIn("Forced rollback", text) + self.assertIn("Ready/local", text) + self.assertIn("Non-search avg ms", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_evaluate_walker_shadow_evidence.py b/scripts/tests/test_evaluate_walker_shadow_evidence.py new file mode 100644 index 00000000000..9df388e3798 --- /dev/null +++ b/scripts/tests/test_evaluate_walker_shadow_evidence.py @@ -0,0 +1,438 @@ +import importlib.util +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).resolve().parents[1] / "evaluate-walker-shadow-evidence.py" +SPEC = importlib.util.spec_from_file_location("walker_shadow_evidence", SCRIPT) +MODULE = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(MODULE) + +REVISION = "f" * 40 + + +def snapshot(*, completed=100, enabled=True, divergences=0, failures=0, started=123): + matches = completed - divergences - failures + coverage = {} + for tag, required in MODULE.DEFAULT_REQUIRED_COVERAGE.items(): + coverage[tag] = { + "completed": required, + "matches": required, + "divergences": 0, + "failures": 0, + } + transport_executors = {} + for requirement in MODULE.DEFAULT_REQUIRED_EXECUTOR_GROUPS.values(): + executor = requirement["executors"][0] + minimum = requirement["minimum"] + transport_executors[executor] = { + "completed": minimum, + "matches": minimum, + "divergences": 0, + "failures": 0, + } + return { + "schemaVersion": 2, + "enabled": enabled, + "candidateEngineId": f"shortest-path-upstream@{REVISION}", + "startedAtEpochMillis": started, + "totals": { + "submitted": completed, + "completed": completed, + "matches": matches, + "divergences": divergences, + "failures": failures, + "staleResults": 0, + "discarded": 0, + "pending": 0, + "routeShapeDifferences": 0, + }, + "coverage": coverage, + "transportExecutors": transport_executors, + "transportTypes": {}, + "execution": { + "terminal": 50, + "arrived": 50, + "unreachable": 0, + "exited": 0, + "recoveryTerminal": 5, + "recoveryArrived": 5, + "recoveryUnreachable": 0, + "recoveryExited": 0, + }, + "latest": None, + "latestRouteShapeDifference": None, + "latestDivergence": None, + "latestFailure": None, + } + + +def members_snapshot(*, started=123): + value = snapshot(completed=30, started=started) + value["plannerMode"] = "SHADOW" + for tag, required in MODULE.MEMBERS_REQUIRED_COVERAGE.items(): + value["coverage"][tag] = { + "completed": required, + "matches": required, + "divergences": 0, + "failures": 0, + } + value["transportExecutors"].update( + { + "FAIRY_RING": { + "completed": 3, + "matches": 3, + "divergences": 0, + "failures": 0, + }, + "TERMINAL_TRAVEL": { + "completed": 2, + "matches": 2, + "divergences": 0, + "failures": 0, + }, + } + ) + value["execution"] = { + "terminal": 10, + "arrived": 10, + "unreachable": 0, + "exited": 0, + "recoveryTerminal": 1, + "recoveryArrived": 1, + "recoveryUnreachable": 0, + "recoveryExited": 0, + } + return value + + +def evaluate_members(value): + return MODULE.evaluate( + value, + REVISION, + minimum_completed=MODULE.MEMBERS_MINIMUM_COMPLETED, + required_coverage=MODULE.MEMBERS_REQUIRED_COVERAGE, + minimum_distinct_transport_executors=( + MODULE.MEMBERS_MINIMUM_DISTINCT_TRANSPORT_EXECUTORS + ), + required_executor_groups=MODULE.MEMBERS_REQUIRED_EXECUTOR_GROUPS, + minimum_walker_arrivals=MODULE.MEMBERS_MINIMUM_WALKER_ARRIVALS, + minimum_recovery_arrivals=MODULE.MEMBERS_MINIMUM_RECOVERY_ARRIVALS, + minimum_sessions=1, + expected_planner_mode="SHADOW", + evidence_profile="members", + ) + + +class WalkerShadowEvidenceTest(unittest.TestCase): + def test_members_profile_accepts_members_requirement_and_network_evidence(self): + result = evaluate_members(members_snapshot()) + + self.assertEqual("ACCEPTED", result["verdict"]) + self.assertEqual("members", result["evidenceProfile"]) + self.assertEqual("SHADOW", result["plannerMode"]) + + def test_members_profile_rejects_canary_mode(self): + value = members_snapshot() + value["plannerMode"] = "UPSTREAM_F2P_CANARY" + + result = evaluate_members(value) + + self.assertEqual("REJECTED", result["verdict"]) + self.assertTrue(any("plannerMode" in item for item in result["failures"])) + + def test_members_profile_requires_non_item_requirement_evidence(self): + value = members_snapshot() + value["coverage"]["SELECTS_NON_ITEM_REQUIREMENT_GATED_TRANSPORT"] = { + "completed": 0, + "matches": 0, + "divergences": 0, + "failures": 0, + } + + result = evaluate_members(value) + + self.assertEqual("INSUFFICIENT_EVIDENCE", result["verdict"]) + self.assertTrue( + any("NON_ITEM_REQUIREMENT" in item for item in result["evidenceShortfalls"]) + ) + + def test_members_profile_requires_multiple_fresh_sessions(self): + merged = MODULE.merge_snapshots( + [members_snapshot(started=123), members_snapshot(started=456)], + REVISION, + expected_planner_mode="SHADOW", + ) + result = MODULE.evaluate( + merged, + REVISION, + minimum_completed=MODULE.MEMBERS_MINIMUM_COMPLETED, + required_coverage=MODULE.MEMBERS_REQUIRED_COVERAGE, + minimum_distinct_transport_executors=( + MODULE.MEMBERS_MINIMUM_DISTINCT_TRANSPORT_EXECUTORS + ), + required_executor_groups=MODULE.MEMBERS_REQUIRED_EXECUTOR_GROUPS, + minimum_walker_arrivals=MODULE.MEMBERS_MINIMUM_WALKER_ARRIVALS, + minimum_recovery_arrivals=MODULE.MEMBERS_MINIMUM_RECOVERY_ARRIVALS, + minimum_sessions=MODULE.MEMBERS_MINIMUM_SESSIONS, + expected_planner_mode="SHADOW", + evidence_profile="members", + ) + + self.assertEqual("INSUFFICIENT_EVIDENCE", result["verdict"]) + self.assertTrue(any("fresh client session" in item for item in result["evidenceShortfalls"])) + + def test_multiple_fresh_sessions_are_aggregated(self): + merged = MODULE.merge_snapshots( + [snapshot(started=123), snapshot(started=456)], REVISION + ) + + report = MODULE.evaluate(merged, REVISION) + + self.assertEqual("ACCEPTED", report["verdict"]) + self.assertEqual(2, report["sessionCount"]) + self.assertEqual(200, report["totals"]["completed"]) + self.assertEqual(100, report["execution"]["arrived"]) + + def test_duplicate_client_session_cannot_be_counted_twice(self): + with self.assertRaisesRegex(ValueError, "same client session twice"): + MODULE.merge_snapshots([snapshot(), snapshot()], REVISION) + + def test_invalid_member_session_cannot_hide_in_aggregate(self): + invalid = snapshot(started=456) + invalid["totals"]["completed"] += 1 + + with self.assertRaisesRegex(ValueError, "internally invalid"): + MODULE.merge_snapshots([snapshot(), invalid], REVISION) + + def test_representative_clean_snapshot_is_accepted(self): + report = MODULE.evaluate(snapshot(), REVISION) + + self.assertEqual("ACCEPTED", report["verdict"]) + self.assertEqual([], report["failures"]) + self.assertEqual([], report["evidenceShortfalls"]) + + def test_missing_routes_and_disabled_shadow_are_insufficient(self): + value = snapshot(completed=20, enabled=False) + for coverage in value["coverage"].values(): + coverage["completed"] = 0 + coverage["matches"] = 0 + + report = MODULE.evaluate(value, REVISION) + + self.assertEqual("INSUFFICIENT_EVIDENCE", report["verdict"]) + self.assertTrue(any("not enabled" in item for item in report["evidenceShortfalls"])) + self.assertTrue(any("ACTIVE_ROUTE" in item for item in report["evidenceShortfalls"])) + + def test_any_divergence_rejects(self): + value = snapshot(divergences=1) + value["coverage"]["ACTIVE_ROUTE"] = { + "completed": 75, + "matches": 74, + "divergences": 1, + "failures": 0, + } + value["latestDivergence"] = { + "status": "DIVERGENCE", + "shadowEngineId": f"shortest-path-upstream@{REVISION}", + "invocation": "ACTIVE_ROUTE", + "terminationMatches": True, + "endpointMatches": True, + "costComparable": True, + "costMatches": False, + "selectedTransportsMatch": True, + "pathMatches": False, + "failureType": None, + } + + report = MODULE.evaluate(value, REVISION) + + self.assertEqual("REJECTED", report["verdict"]) + self.assertTrue(any("divergence" in item for item in report["failures"])) + + def test_divergence_without_semantic_mismatch_rejects_diagnostic(self): + value = snapshot(divergences=1) + value["latestDivergence"] = { + "status": "DIVERGENCE", + "shadowEngineId": f"shortest-path-upstream@{REVISION}", + "invocation": "ACTIVE_ROUTE", + "terminationMatches": True, + "endpointMatches": True, + "costComparable": True, + "costMatches": True, + "selectedTransportsMatch": True, + "pathMatches": False, + "failureType": None, + } + + report = MODULE.evaluate(value, REVISION) + + self.assertEqual("REJECTED", report["verdict"]) + self.assertTrue( + any("does not contain" in item for item in report["failures"]) + ) + + def test_planner_failure_requires_preserved_exception_class(self): + value = snapshot(failures=1) + value["latestFailure"] = { + "status": "FAILED", + "shadowEngineId": f"shortest-path-upstream@{REVISION}", + "invocation": "ACTIVE_ROUTE", + "terminationMatches": False, + "endpointMatches": False, + "costComparable": False, + "costMatches": False, + "selectedTransportsMatch": False, + "pathMatches": False, + "failureType": "IllegalStateException", + } + + report = MODULE.evaluate(value, REVISION) + + self.assertEqual("REJECTED", report["verdict"]) + self.assertEqual("FAILED", report["latestFailure"]["status"]) + + def test_wrong_pinned_engine_rejects(self): + value = snapshot() + value["candidateEngineId"] = "shortest-path-upstream@" + "0" * 40 + + report = MODULE.evaluate(value, REVISION) + + self.assertEqual("REJECTED", report["verdict"]) + self.assertTrue(any("candidateEngineId" in item for item in report["failures"])) + + def test_pending_work_prevents_acceptance(self): + value = snapshot() + value["totals"]["submitted"] += 1 + value["totals"]["pending"] = 1 + + report = MODULE.evaluate(value, REVISION) + + self.assertEqual("INSUFFICIENT_EVIDENCE", report["verdict"]) + self.assertTrue(any("still pending" in item for item in report["evidenceShortfalls"])) + + def test_markdown_contains_coverage_table(self): + text = MODULE.markdown(MODULE.evaluate(snapshot(), REVISION)) + + self.assertIn("`ACCEPTED`", text) + self.assertIn("| ACTIVE_REPLAN |", text) + self.assertIn("Transport executor diversity", text) + + def test_transport_executor_diversity_is_required(self): + value = snapshot() + value["transportExecutors"] = { + "OBJECT": { + "completed": 20, + "matches": 20, + "divergences": 0, + "failures": 0, + } + } + + report = MODULE.evaluate(value, REVISION) + + self.assertEqual("INSUFFICIENT_EVIDENCE", report["verdict"]) + self.assertTrue( + any("distinct transport executor" in item for item in report["evidenceShortfalls"]) + ) + + def test_route_shape_difference_is_reported_as_diagnostic(self): + value = snapshot() + value["totals"]["routeShapeDifferences"] = 3 + value["latestRouteShapeDifference"] = { + "status": "MATCH", + "shadowEngineId": f"shortest-path-upstream@{REVISION}", + "invocation": "ACTIVE_ROUTE", + "terminationMatches": True, + "endpointMatches": True, + "costComparable": True, + "costMatches": True, + "selectedTransportsMatch": True, + "pathMatches": False, + } + + report = MODULE.evaluate(value, REVISION) + + self.assertEqual("ACCEPTED", report["verdict"]) + self.assertTrue(any("route shape" in item for item in report["warnings"])) + self.assertEqual("MATCH", report["latestRouteShapeDifference"]["status"]) + + def test_inconsistent_route_shape_diagnostic_rejects(self): + value = snapshot() + value["latestRouteShapeDifference"] = { + "status": "MATCH", + "shadowEngineId": f"shortest-path-upstream@{REVISION}", + "terminationMatches": True, + "endpointMatches": True, + "costComparable": True, + "costMatches": True, + "selectedTransportsMatch": True, + "pathMatches": False, + } + + report = MODULE.evaluate(value, REVISION) + + self.assertEqual("REJECTED", report["verdict"]) + self.assertTrue( + any("latestRouteShapeDifference" in item for item in report["failures"]) + ) + + def test_executor_outcome_cannot_hide_divergence(self): + value = snapshot() + value["transportExecutors"]["OBJECT"] = { + "completed": 5, + "matches": 4, + "divergences": 1, + "failures": 0, + } + + report = MODULE.evaluate(value, REVISION) + + self.assertEqual("REJECTED", report["verdict"]) + self.assertTrue( + any("transportExecutors.OBJECT" in item for item in report["failures"]) + ) + + def test_old_snapshot_schema_is_rejected(self): + value = snapshot() + value["schemaVersion"] = 1 + + with self.assertRaisesRegex(ValueError, "schemaVersion"): + MODULE.evaluate(value, REVISION) + + def test_failed_recovery_rejects_live_evidence(self): + value = snapshot() + value["execution"].update( + { + "terminal": 51, + "unreachable": 1, + "recoveryTerminal": 6, + "recoveryUnreachable": 1, + } + ) + + report = MODULE.evaluate(value, REVISION) + + self.assertEqual("REJECTED", report["verdict"]) + self.assertTrue(any("recovery-triggered" in item for item in report["failures"])) + + def test_execution_arrivals_are_required(self): + value = snapshot() + value["execution"].update( + { + "terminal": 0, + "arrived": 0, + "recoveryTerminal": 0, + "recoveryArrived": 0, + } + ) + + report = MODULE.evaluate(value, REVISION) + + self.assertEqual("INSUFFICIENT_EVIDENCE", report["verdict"]) + self.assertTrue(any("blocking walk arrival" in item for item in report["evidenceShortfalls"])) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_report_shortest_path_planner_performance.py b/scripts/tests/test_report_shortest_path_planner_performance.py new file mode 100644 index 00000000000..694a415626c --- /dev/null +++ b/scripts/tests/test_report_shortest_path_planner_performance.py @@ -0,0 +1,148 @@ +import importlib.util +import unittest +from pathlib import Path + + +SCRIPT = ( + Path(__file__).resolve().parents[1] + / "report-shortest-path-planner-performance.py" +) +SPEC = importlib.util.spec_from_file_location("planner_performance", SCRIPT) +MODULE = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(MODULE) + + +def corpus(): + return { + "schemaVersion": 3, + "cases": [ + { + "id": "core", + "category": "overland", + "policy": {"transportMode": "STATIC_COLLISION_ONLY"}, + }, + { + "id": "bank", + "category": "bank", + "policy": {"transportMode": "BANK_AWARE_EXPLICIT_CATALOG"}, + }, + { + "id": "policy-divergence", + "category": "spell-provider-divergence", + "policy": {"transportMode": "EXPLICIT_CATALOG"}, + "expectedParity": False, + }, + ], + } + + +def engine_result(elapsed_nanos, nodes=10, heap=1024): + return { + "elapsedNanos": elapsed_nanos, + "nodesChecked": nodes, + "peakHeapDeltaBytes": heap, + } + + +def comparison(case_id, local_nanos=10_000_000, upstream_nanos=12_000_000): + return { + "id": case_id, + "status": "PASS", + "local": engine_result(local_nanos), + "upstream": engine_result(upstream_nanos, nodes=20, heap=2048), + } + + +def sample(*, dirty=False, upstream_nanos=12_000_000): + return { + "schemaVersion": 3, + "localRevision": "local", + "upstreamRevision": "upstream", + "embeddedUpstreamRevision": "upstream", + "runeliteVersion": "runelite", + "corpusSha256": "corpus", + "upstreamIdentityPatchSha256": "patch", + "localWorkingTreeDirty": dirty, + "failures": [], + "unsupported": [], + "embeddedUpstreamFailures": [], + "comparisons": [ + comparison("core", upstream_nanos=upstream_nanos), + comparison("bank"), + { + **comparison("policy-divergence"), + "status": "EXPECTED_DIVERGENCE", + }, + ], + } + + +class PlannerPerformanceReportTest(unittest.TestCase): + def test_one_dirty_sample_is_insufficient_and_excludes_non_core_workflows(self): + report = MODULE.evaluate_reports([sample(dirty=True)], corpus()) + + self.assertEqual("INSUFFICIENT_EVIDENCE", report["verdict"]) + self.assertEqual(["core"], report["comparability"]["includedCaseIds"]) + self.assertEqual(2, len(report["comparability"]["excludedCases"])) + self.assertTrue( + any("localWorkingTreeDirty" in value for value in report["evidenceShortfalls"]) + ) + self.assertTrue( + any("at least 5" in value for value in report["evidenceShortfalls"]) + ) + + def test_five_clean_samples_accept_within_thresholds(self): + report = MODULE.evaluate_reports([sample() for _ in range(5)], corpus()) + + self.assertEqual("ACCEPTED", report["verdict"]) + self.assertAlmostEqual(1.2, report["suite"]["upstreamToLocalMedianRatio"]) + self.assertEqual("PASS", report["cases"][0]["status"]) + + def test_repeated_material_regression_is_rejected(self): + report = MODULE.evaluate_reports( + [sample(upstream_nanos=150_000_000) for _ in range(5)], corpus() + ) + + self.assertEqual("REJECTED", report["verdict"]) + self.assertEqual("FAIL", report["cases"][0]["status"]) + self.assertTrue(report["performanceFailures"]) + + def test_correctness_failure_rejects_even_before_minimum_samples(self): + failing = sample() + failing["failures"] = ["route differs"] + + report = MODULE.evaluate_reports([failing], corpus()) + + self.assertEqual("REJECTED", report["verdict"]) + self.assertTrue(any("route differs" in value for value in report["failures"])) + + def test_mixed_revisions_are_rejected(self): + other = sample() + other["localRevision"] = "other" + + report = MODULE.evaluate_reports([sample(), other], corpus()) + + self.assertEqual("REJECTED", report["verdict"]) + self.assertTrue(any("localRevision" in value for value in report["failures"])) + + def test_supplied_corpus_digest_must_match_report(self): + report = MODULE.evaluate_reports( + [sample()], corpus(), corpus_sha256="different" + ) + + self.assertEqual("REJECTED", report["verdict"]) + self.assertTrue(any("supplied corpus" in value for value in report["failures"])) + + def test_markdown_explains_verdict_and_exclusions(self): + report = MODULE.evaluate_reports([sample()], corpus()) + + value = MODULE.markdown(report) + + self.assertIn("`INSUFFICIENT_EVIDENCE`", value) + self.assertIn("Excluded from core timing", value) + self.assertIn("bank-aware workflow shapes differ", value) + + +if __name__ == "__main__": + unittest.main()