Skip to content

route-oracle: measure a plan's stored route against the navmesh - #274

Merged
pjerra merged 6 commits into
devfrom
feat/route-oracle
Oct 20, 2026
Merged

pjerra merged 6 commits into
devfrom
feat/route-oracle

Conversation

@pjerra

@pjerra pjerra commented Oct 20, 2026 •

Copy link
Copy Markdown
Owner

Summary

turtle-agent route-oracle --plans dungeons --mmaps DIR --out DIR [--plan NAME]... compares a dungeon plan's stored route with what the server's navmesh plans for the same dungeon, leg by leg, and writes <out>/<plan>.txt (human table) and <out>/<plan>.json (machine report, Stage 1's fixture). Without --plan it reports the five mapped plans: wailing_caverns, ragefire_chasm, uldaman, zulfarrak, deadmines.

Per plan:

  • the objective chain: entrance, then per leg its boss position (else its to, else its last step's point); per leg the walk from the previous objective is planned twice — plain, and on a Scratch() planner with SetAvoid of the plan's avoid boxes. The avoid-aware path is the one the metrics use, since it is what a run plans with.
  • the stored polyline: the plan's own points in the order planexport writes them (via, to, pull groups' approach points and pull centroid / hold spot, boss approach and position).

Per leg: status, end gap, live length (both runs), stored length, ratio, max and mean distance of the stored points to the live path, Z jumps over 8 yd (corners and ground samples), breaks touched (via with exact/direct/gm_cross/check_skip within 6 yd of the path, or within 10 yd of a partial path's end), pull groups' corridor membership (group radius + 18 yd, dz ≤ 8) and redundant approach/via points (within 5 yd of the path), and the plan time in ms; the report is written under the plan's name, which is validated with the same strict basename regex as --plan and must equal the requested plan name (the destination stays under --out). The report names the SHA-256 of every .mmap/.mmtile file the planning used.

What changed

  • new package internal/routeoracle (report, metrics, thresholds); tests plan over a synthetic mesh (internal/navmesh/navfixture) — no real mmaps needed.
  • new command cmd/turtle-agent/route_oracle_cmd.go, wired into main.go (exit 12 when a plan could not be reported).
  • README: CLI line and a "Route oracle" section.

No plan or exporter file is touched.

Tests

  • ~/TortoiseBots-audit/tools/vmtest.sh ~/wt-ta-route-oracle 'make test' → MAKE-TEST-OK (gofmt check, vet, no-cgo build, go test -race ./...; internal/routeoracle ok, cgo tests included on the VM).
  • make build (CGO_ENABLED=1) on the laptop, then one run over the five plans against the server's mmaps; the five summary lines:
wailing_caverns: map 43, 12 legs: 12 complete, 0 failed, 0 indeterminate; mean ratio 0.93, packs on path 86/88 (98%); plan 70 ms
ragefire_chasm: map 389, 9 legs: 9 complete, 0 failed, 0 indeterminate; mean ratio 0.95, packs on path 50/51 (98%); plan 32 ms
uldaman: map 70, 14 legs: 14 complete, 0 failed, 0 indeterminate; mean ratio 0.86, packs on path 66/66 (100%); plan 53 ms
zulfarrak: map 209, 18 legs: 18 complete, 0 failed, 0 indeterminate; mean ratio 0.95, packs on path 52/53 (98%); plan 236 ms
deadmines: map 36, 16 legs: 16 complete, 0 failed, 0 indeterminate; mean ratio 0.91, packs on path 135/136 (99%); plan 105 ms

Every leg of the five plans paths complete on the exported sequence (69/69 legs; the WC serpentis ramp's direct,check_skip hop is the plan's own exemption and is not measured on the mesh). The plan's route is described in one walk now — the legs' live hops and stored hops pair per row.

Live check

not run (the oracle is static: plans and mmaps only).

Bosses mode and output safety (added)

  • --mode bosses plans each objective once on the live navmesh with no hand point and no break hop skipped; --objectives plan (default) uses each leg's own objective (boss, else its stored to), --objectives bosses plans only the chain entrance -> boss 1 -> boss 2 ... Failed objectives score nothing and name the stored break they hit. Both modes use one pack-on-path definition (a position within radius + 18 yd, 8 yd of height). See the README ("Route oracle") for the flags.
  • Output: --out is refused when it is or contains a symlink (a host where /tmp or /home is a symlink must name the real folder); a report file that exists as a symlink/folder is refused; files are written to a temp file and renamed.
  • Documented limit (Codex round 3): the output folder is validated by path, so a concurrent rename of a writable ancestor between the validation and OpenRoot could redirect the writes. This is a local, owner-run tool; the limit is accepted.

The audit's Stage 1 needs a fixture per dungeon plan that says how far the
plan's stored route and what the server's navmesh plan are apart, leg by
leg. The command builds the objective chain (entrance, then per leg its
boss, else its to/last step point) and the stored polyline (the plan's own
points in the order planexport writes them), plans every leg twice (plain,
and on a scratch planner with SetAvoid of the plan's avoid boxes, whose
path the metrics use), and reports status, end gap, live and stored length,
ratio, stored points' distance to the live path, Z jumps over 8 yd, the
breaks touched (exact/direct/gm_cross/check_skip vias within 6 yd of the
path, or within 10 yd of a partial path's end), pull groups' corridor
membership (radius + 18 yd, dz 8) and redundant approach/via points
(within 5 yd), with the SHA-256 of every mmap file used.

internal/routeoracle holds the report; its tests plan over a synthetic
navfixture mesh, so no real mmaps are needed.
@pjerra
pjerra marked this pull request as draft October 20, 2026 18:27
The review's three highs:

- The live route and the stored route were two different walks: boss legs
  planned previous objective to boss position directly, while the stored
  length counted the via points, the to, every pull group's approach
  points and centre, and the boss approaches. The oracle now plans the
  driver's own ordered sequence (via, to, pull approaches and pull
  centres or holds, step stands, boss approaches, boss, after-boss
  stands), hop by hop, and scores each stored hop against its own
  navmesh hop: per-segment rows in the JSON, per-leg sums in the table.
- A partial path poisoned the numbers behind it: a hop that does not
  path complete now fails its leg; the leg's remaining hops are planned
  on from the point the failed path reached and marked non_comparable,
  the legs after a failed leg are indeterminate, and every aggregate
  (ratio, packs, breaks, redundancy) is over the complete legs only.
- The report is written under the plan's name, which the command read
  from the plan JSON: a name like ../escape would write outside --out.
  The plan's own name is validated with the same strict basename regex
  as --plan, it must equal the requested plan name, and the resolved
  destination must stay under --out.

Tests: a boss leg with to and pull groups (the walk and its ratio), a
partial leg followed by another leg (indeterminate), a traversal name in
the plan JSON (refused, nothing written outside --out), a name mismatch,
and the end-to-end fixture run; each fails if its check is removed.
… points, no break hops

The walk mode (still the default) proves the stored hops are walkable; it
cannot say whether a route could be planned from a boss list. The bosses
mode plans each leg once, avoid-aware, from the previous objective to the
leg's boss (else its to or last point), skipping nothing.

- A leg the mesh does not reach is FAILED, adds nothing to any aggregate,
  and names the stored break it hit: a break hop whose foot the mesh reaches
  from the start and whose landing reaches the objective, or one within 15 yd
  of the live end or of the line to the objective.
- The leg after a failed one is planned from the last point reached and is
  labelled non-comparable and excluded; it is also planned from the previous
  objective (bridged), reported beside the primary numbers, never in them.
- Complete legs report packs whose centre the path passes, hand points
  within 10 yd of it, floor jumps and live length. No stored/live ratio.
- Files are <plan>-bosses.txt and .json; an unknown --mode is a usage error.

Tests: a fixture mesh with a gap fails the leg and names the break; a
failed leg and the leg after it stay out of the totals; a break far from
the end is named because it bridges; the mode defaults to walk.
The containment check was lexical: a <plan>.txt or .json that already
existed as a symlink to a file outside --out passed it, and os.WriteFile
then truncated the target.

- --out that is, or has a component that is, a symlink is a usage error;
  checked before and after the folder is made.
- A destination that exists as anything but a regular file (a symlink, even
  a dangling one, or a folder) is refused, both checked before either file
  is written.
- Each file is written to a fresh O_EXCL temp file through an os.Root on the
  folder and renamed over the target, so a link planted after the check is
  replaced, not followed.

Tests: symlinked report (walk and bosses, .txt and .json, live and dangling
target), folder as report, symlinked --out and --out component, and the
rename not following a link.
…ack definition

- --objectives plan (default) keeps each leg's own objective; --objectives
  bosses plans only entrance -> boss 1 -> boss 2 ..., folding a leg without
  a boss into the next boss, counting every stored to point as a hand point,
  and not planning legs after the last boss. Files: <plan>-bosses-only.*.
  Needs --mode bosses.
- A pack is on the path by one definition in both modes (GroupOnRoute: a
  position within radius + 18 yd, 8 yd of height); the bosses mode no longer
  uses the centroid within the bare radius.
- A pull group with no position and no hold is skipped (the walk no longer
  plans to the NaN centroid of nothing) and counted as empty_groups.
- Break hits carry the stored leg; README documents the modes, the symlink
  refusal of --out and the accepted ancestor-rename limit.

Tests: pack boundaries at radius + 18 and 8 yd of height, the same pack
verdict in both modes, the boss-only chain (folding, to as hand point,
unplanned legs, continuation), empty groups, the flag checks.
…rows always say the stored leg

- GroupOnRoute reported the last position's distance while a group was off
  the path; it now tracks the minimum over all positions. No on/off verdict
  changes; only near/dz figures of off-path groups.
- PullOnPath, HandPoint and BreakTouched carry the stored leg as a pointer,
  so leg 0 is written in a boss-only row too.

Tests: a nearest position that is not the last, and a two-leg folded row
whose items name stored leg 0 and 1 (a non-zero index), in the JSON too.
@pjerra
pjerra marked this pull request as ready for review October 20, 2026 20:59
@pjerra
pjerra merged commit e1cdd16 into dev Oct 20, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant