Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -845,9 +845,15 @@ What it checks:
before them (the last `via`, `to`, step stand or approach point; part `run leader pull group 3 approach 0`). A separate list, `run_leader` (reason `run leader hop`; never a failure, not a warning of the agent's own run), names every pack (the centre its `pull` row
gets) and boss that stands more than 50 yd from the run leader's last move row: the run leader sees 60 yd from its lead bot and walks toward anything farther in navmesh hops of its own
(`kApproachYd`/`kApproachHopYd`), which is where its Uldaman test of 2026-10-16 stopped (a hop point under a low ceiling refused `off-mesh`, and long paths with no path at all). Give such a
pack `approach` points. The check does **not** model the server's own refusals: `cmd move` snaps a point to the vmap floor found from 8 yd above it (a ceiling 4-6 yd above the floor wins
and the point is refused `off-mesh`, more than 5 yd from where the path ends), and the server's path finder keeps 2048 search nodes and 256 polygons (here 65535 and 2048) and builds
a smoothed path of at most 256 points; neither the vmaps nor that path are here, so an approach point is checked against the vmaps offline (`lostool` on the test VM).
pack `approach` points. Every offline-complete `walk` hop above also runs the server's own path query (2048 search nodes, 256 polygons, ground|water included, steep included, a smoothed path of at most
256 points) and the run leader's `CheckMove` verdict on it: a hop the offline query takes but the server refuses fails as `server unreachable`, and the walk goes on from where the
server leaves the party (an incomplete path's end, or where it stood). An offline-partial hop keeps the established offline reason and never asks the server query. A `direct` hop within the arrival radius (`ArriveYd`, 3 yd: `DungeonRunPolicy.h kArriveYd`) of the point before it
fails as `hop inside the arrival radius`: the run leader counts it reached without sending an order, so the party stays behind. Replay one hop of a live refusal from the logged lead
position with `turtle-agent path-check --mmaps DIR --map N x y z x y z`: two lines, the offline `Plan` answer and the server's type, verdict, end and distances to each end's walk poly.
Limits (what the check does not model): the bot's real z and the vmap floor snap of the goal (a ledge whose mesh floats 2-3 yd above the game floor still passes); swimming (underwater
shortcut paths, which need liquid data); tiles unloaded on the server (the core loads mmtiles per loaded grid; the check covers only the start and end); the module's 25 yd stand-in
search (`NearestReachablePoint`, `MoveFallbackYd`) and the `leadSpotGather` / `kMoveHereYd` branch, and a too-far refusal, are NOT modelled either: a `server unreachable` can still be
saved live when the run leader's ring point around the goal (up to 25 yd out) takes a path the direct hop's query refuses.

A run that starts inside the plan is checked from where the driver puts the party (`Plan.LegStart` for `from_leg`, `Plan.GatherSpot` for `from_pull_group`; the part before is not walked).

Expand Down
3 changes: 3 additions & 0 deletions cmd/turtle-agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
return planExportCmd(args[1:], stdout, stderr)
case "plan-check":
return planCheckCmd(args[1:], stdout, stderr)
case "path-check":
return pathCheckCmd(args[1:], stdout, stderr)
case "route-oracle":
return routeOracleCmd(args[1:], stdout, stderr)
case "route-edits":
Expand Down Expand Up @@ -119,6 +121,7 @@ func usage(w io.Writer) {
turtle-agent minimap-extract --client DIR --out DIR [--maps 70,209|all-instances] [--mmaps DIR] [--list]
turtle-agent plan-export [--dir dungeons] [--allow-draft] [--drop-companions-loot] [--carrier-chests] [--skip-legs NAME,...] [--mmaps DIR] [--rev SHA] [--stamp YYYYMMDDHHMMSS] [--force] --out FILE|DIR (<plan>... | --all-live)
turtle-agent plan-check [--dir dungeons] --mmaps DIR [--from-leg N [--from-pull-group N]] [--json] <plan>...
turtle-agent path-check --mmaps DIR --map N x y z x y z [--json]
turtle-agent route-oracle --plans DIR --mmaps DIR --out DIR [--plan NAME]...
turtle-agent route-edits list [--edit-dir DIR] [--json] [<plan>] | export [--edit-dir DIR] [--config-dir DIR] --to DIR <plan> [vN|latest] | promote [--edit-dir DIR] --worktree PATH <plan> vN
turtle-agent version`)
Expand Down
92 changes: 92 additions & 0 deletions cmd/turtle-agent/path_check_cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package main

import (
"flag"
"fmt"
"io"
"math"
"strconv"

"github.com/pjerra/turtle-agent/internal/navmesh"
)

const pathCheckUsage = "usage: turtle-agent path-check --mmaps DIR --map N [--json] -- x y z x y z"

const pathCheckCoords = 6

// exitCheckFailed is the exit code of path-check when a query itself fails (no
// tiles, no engine): usage errors stay exitUsage (2).
const exitCheckFailed = 1

// pathCheckCmd replays one hop with both queries: the offline Planner.Plan and the
// server's exact query (ServerPath plus ServerMoveVerdict). This is how a live refusal
// is replayed from the logged lead position. Exit 0 on a replayed hop, 1 when a query
// fails, 2 on a usage error.
func pathCheckCmd(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("path-check", flag.ContinueOnError)
fs.SetOutput(stderr)
mmaps := fs.String("mmaps", "", "folder with the server's mmaps (<map>.mmap and <map>*.mmtile); required")
mapID := fs.String("map", "", "map id (numeric); required")
asJSON := fs.Bool("json", false, "print the results as JSON")
if err := fs.Parse(args); err != nil {
return exitUsage
}
if len(fs.Args()) != pathCheckCoords || *mmaps == "" || *mapID == "" {
fmt.Fprintln(stderr, pathCheckUsage)
return exitUsage
}
var v [pathCheckCoords]float64
for i, s := range fs.Args() {
f, err := strconv.ParseFloat(s, 32)
if err != nil {
fmt.Fprintln(stderr, pathCheckUsage)
return exitUsage
}
v[i] = f
}
n, err := strconv.Atoi(*mapID)
if err != nil || n < 0 {
fmt.Fprintln(stderr, pathCheckUsage)
return exitUsage
}
from := navmesh.Vec3{float32(v[0]), float32(v[1]), float32(v[2])}
to := navmesh.Vec3{float32(v[3]), float32(v[4]), float32(v[5])}
pl := navmesh.NewPlanner(*mmaps)
defer pl.Close()
path, err := pl.Plan(uint32(n), from, to)
if err != nil {
fmt.Fprintf(stderr, "path-check: %v\n", err)
return exitCheckFailed
}
sp, err := pl.ServerPath(uint32(n), from, to)
if err != nil {
fmt.Fprintf(stderr, "path-check: %v\n", err)
return exitCheckFailed
}
verdict := navmesh.ServerMoveVerdict(sp, from, to, 0)
if *asJSON {
fmt.Fprintf(stdout, "{\"offline\":{\"status\":%q,\"length\":%.1f,\"reason\":%q},\"server\":{\"type\":%d,\"verdict\":%q,\"actual_end\":[%.1f,%.1f,%.1f],\"short\":%.1f,\"start_dist\":%.1f,\"end_dist\":%.1f}}\n",
path.Status, path.Length, path.Reason, sp.Type, verdict,
sp.ActualEnd[0], sp.ActualEnd[1], sp.ActualEnd[2], dist3f(sp.ActualEnd, to), sp.StartDist, sp.EndDist)
return exitOK
}
off := string(path.Status)
if path.Reason != "" {
off += " " + path.Reason
}
fmt.Fprintf(stdout, "offline: %s %.1f yd\n", off, path.Length)
if sp.Type&navmesh.PathNoPath != 0 {
fmt.Fprintf(stdout, "server: NOPATH %s start poly %.1f yd, end poly %.1f yd\n",
verdict, sp.StartDist, sp.EndDist)
return exitOK
}
short := dist3f(sp.ActualEnd, to)
fmt.Fprintf(stdout, "server: type 0x%x %s end %.1f %.1f %.1f %.1f yd short, start poly %.1f yd, end poly %.1f yd\n",
sp.Type, verdict, sp.ActualEnd[0], sp.ActualEnd[1], sp.ActualEnd[2], short, sp.StartDist, sp.EndDist)
return exitOK
}

func dist3f(a, b navmesh.Vec3) float32 {
dx, dy, dz := float64(a[0]-b[0]), float64(a[1]-b[1]), float64(a[2]-b[2])
return float32(math.Sqrt(dx*dx + dy*dy + dz*dz))
}
105 changes: 105 additions & 0 deletions cmd/turtle-agent/path_check_cmd_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package main

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)

// Path-check usage is anything but flags plus exactly 6 positional numbers:
// exit 2, on flags, counts and non-numbers alike.
func TestPathCheckCmdUsage(t *testing.T) {
for _, args := range [][]string{
{},
{"--mmaps", "x"},
{"--mmaps", "x", "--map", "1"},
{"--mmaps", "x", "--map", "1", "--", "0", "0", "0", "1", "1"}, // 5 numbers
{"--mmaps", "x", "--map", "1", "--", "0", "0", "0", "1", "1", "1", "2"}, // 7 numbers
{"--mmaps", "x", "--map", "1", "--", "0", "0", "0", "1", "1", "far"}, // not a number
{"--mmaps", "x", "--map", "1", "-1", "0", "0", "0", "1", "1"}, // no --: -1 parses as a flag
{"--nope", "1"},
} {
var out, errOut bytes.Buffer
if code := pathCheckCmd(args, &out, &errOut); code != exitUsage {
t.Errorf("%v: exit %d, want %d (%s)", args, code, exitUsage, errOut.String())
}
if out.String() != "" {
t.Errorf("%v: stdout %q on a usage error", args, out.String())
}
}
}

// A query the navmesh cannot answer (here: a folder with no tiles) is exit 1,
// not a usage error.
func TestPathCheckCmdQueryError(t *testing.T) {
var out, errOut bytes.Buffer
args := []string{"--mmaps", t.TempDir(), "--map", "1", "--", "0", "0", "0", "1", "1", "1"}
if code := pathCheckCmd(args, &out, &errOut); code != exitCheckFailed {
t.Fatalf("exit %d, want %d (%s)", code, exitCheckFailed, errOut.String())
}
if !strings.Contains(errOut.String(), "path-check: ") {
t.Errorf("no query error on stderr: %q", errOut.String())
}
}

// The server's NOPATH prints NOPATH and no "yd short" line (the pocket-to-boss
// hop on map 808: the server's query has no path from the steep pocket).
func TestPathCheckCmdNopath(t *testing.T) {
home, _ := os.UserHomeDir()
dir := ""
for _, d := range []string{os.Getenv("TURTLE_AGENT_MMAPS"), filepath.Join(home, "tortoise-wow-server", "data", "mmaps")} {
if d == "" {
continue
}
if _, err := os.Stat(filepath.Join(d, "808.mmap")); err == nil {
dir = d
break
}
}
if dir == "" {
t.Skip("no map 808 navmesh files: set TURTLE_AGENT_MMAPS to a folder holding 808.mmap and the tiles")
}
var out, errOut bytes.Buffer
args := []string{"--mmaps", dir, "--map", "808", "--",
"-8312.5", "-3474.8", "176.1", "-8309.45", "-3460.42", "194.43"}
if code := pathCheckCmd(args, &out, &errOut); code != exitOK {
t.Fatalf("exit %d (%s)", code, errOut.String())
}
if !strings.Contains(out.String(), "NOPATH") {
t.Fatalf("no NOPATH in %q", out.String())
}
if strings.Contains(out.String(), "yd short") {
t.Fatalf("a NOPATH prints no yd-short line: %q", out.String())
}
}

// A hop the server takes prints the type, the verdict and the shortfall.
func TestPathCheckCmdOk(t *testing.T) {
home, _ := os.UserHomeDir()
dir := ""
for _, d := range []string{os.Getenv("TURTLE_AGENT_MMAPS"), filepath.Join(home, "tortoise-wow-server", "data", "mmaps")} {
if d == "" {
continue
}
if _, err := os.Stat(filepath.Join(d, "808.mmap")); err == nil {
dir = d
break
}
}
if dir == "" {
t.Skip("no map 808 navmesh files: set TURTLE_AGENT_MMAPS to a folder holding 808.mmap and the tiles")
}
var out, errOut bytes.Buffer
args := []string{"--mmaps", dir, "--map", "808", "--",
"-8321.5", "-3447.5", "181.0", "-8309.45", "-3460.42", "194.43"}
if code := pathCheckCmd(args, &out, &errOut); code != exitOK {
t.Fatalf("exit %d (%s)", code, errOut.String())
}
for _, want := range []string{"offline: complete", "server: type 0x1 ok", "yd short"} {
if !strings.Contains(out.String(), want) {
t.Errorf("no %q in %q", want, out.String())
}
}
}
Loading
Loading