diff --git a/README.md b/README.md index d1b92644..b0c27552 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/cmd/turtle-agent/main.go b/cmd/turtle-agent/main.go index 4dca4bf9..2ffd6d91 100644 --- a/cmd/turtle-agent/main.go +++ b/cmd/turtle-agent/main.go @@ -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": @@ -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 (... | --all-live) turtle-agent plan-check [--dir dungeons] --mmaps DIR [--from-leg N [--from-pull-group N]] [--json] ... + 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] [] | export [--edit-dir DIR] [--config-dir DIR] --to DIR [vN|latest] | promote [--edit-dir DIR] --worktree PATH vN turtle-agent version`) diff --git a/cmd/turtle-agent/path_check_cmd.go b/cmd/turtle-agent/path_check_cmd.go new file mode 100644 index 00000000..0ec71384 --- /dev/null +++ b/cmd/turtle-agent/path_check_cmd.go @@ -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 (.mmap and *.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)) +} diff --git a/cmd/turtle-agent/path_check_cmd_test.go b/cmd/turtle-agent/path_check_cmd_test.go new file mode 100644 index 00000000..d5d27768 --- /dev/null +++ b/cmd/turtle-agent/path_check_cmd_test.go @@ -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()) + } + } +} diff --git a/internal/dungeon/plancheck.go b/internal/dungeon/plancheck.go index c27cb429..a5336952 100644 --- a/internal/dungeon/plancheck.go +++ b/internal/dungeon/plancheck.go @@ -44,8 +44,28 @@ const ( WarnNeedlessDirect = "needless direct" // WarnEstimatedPatrol is the reason of the warning on a patrol whose period_s is an estimate (Patrol.PeriodEstimate). WarnEstimatedPatrol = "estimated patrol period" + + // ReasonServerUnreachable is a hop the server's own path query refuses: the run + // leader's CheckMove answers "unreachable" (or the path's end is short after 3 + // incomplete legs) from where the party stands, although the offline query has a + // complete path. + ReasonServerUnreachable = "server unreachable" + // ReasonArriveSwallowed is a direct hop the run leader never sends: it lies within + // the arrival radius (DungeonRunPolicy.h kArriveYd) of the point before it, so the + // run counts it reached (WalkTo returns "arrived") and the party stays behind. + ReasonArriveSwallowed = "hop inside the arrival radius" ) +// ArriveYd is the run leader's arrival radius: within it of its point a move counts as +// reached without sending an order (TortoiseBots DungeonRunPolicy.h kArriveYd, WalkTo). +const ArriveYd = 3.0 + +// ServerQuerier is the optional part of a Navmesh that replays the server's own path +// query; *navmesh.Planner implements it. The test fakes compile without it. +type ServerQuerier interface { + ServerPath(mapID uint32, from, to navmesh.Vec3) (navmesh.ServerPath, error) +} + // Navmesh is what the plan check asks of the navmesh; navmesh.Planner is one. type Navmesh interface { Plan(mapID uint32, from, to navmesh.Vec3) (navmesh.Path, error) @@ -283,7 +303,7 @@ func checkPlanRec(p *Plan, nav Navmesh, opt CheckOptions, look lookFunc, rec *ro if a, ok := nav.(AvoidSetter); ok { a.SetAvoid(p.Map, NavBoxes(p.Avoid)) } - c := &planChecker{p: p, nav: nav, lookAt: look, opt: opt, rec: rec, res: &PlanCheck{Plan: p.Name, Map: p.Map, Issues: []PlanIssue{}, Warnings: []PlanIssue{}, Exempt: []PlanIssue{}, RunLeader: []PlanIssue{}}} + c := &planChecker{p: p, nav: nav, lookAt: look, opt: opt, rec: rec, res: &PlanCheck{Plan: p.Name, Map: p.Map, Issues: []PlanIssue{}, Warnings: []PlanIssue{}, Exempt: []PlanIssue{}, RunLeader: []PlanIssue{}}, ends: map[string]Point{}} cur, group := p.Entrance, -1 switch { case opt.FromPullGroup != nil: @@ -328,13 +348,19 @@ func (c *planChecker) runLeader(from int) error { return } if !strings.Contains(m.Part, "approach") && !strings.Contains(m.Part, "hold spot") { - prev = m.Point // checked by checkLeg already + // Checked by checkLeg already: the approach pass starts from where + // its walk left the party (the arrival rule or the server walk may + // have kept it behind), not from the plan's point. + prev = m.Point + if at, ok := c.ends[endKey(m.Leg, m.Part)]; ok { + prev = at + } return } c.leg, c.l = m.Leg, c.p.Legs[m.Leg] var at Point - if at, err = c.walk("run leader "+m.Part, m.Point, prev); err == nil && at == m.Point { - prev = m.Point + if at, err = c.walk("run leader "+m.Part, m.Point, prev); err == nil { + prev = at } }, func(int, string, Point, int, string) {}) if err != nil { @@ -377,8 +403,15 @@ type planChecker struct { res *PlanCheck leg int l Leg + // ends is where checkLeg's walk left the party after each move row of the + // plan, keyed "leg/part" with runLeaderWalk's part names: the run leader's + // approach pass starts from there, not from the plan's point. + ends map[string]Point } +// endKey keys ends: the leg and the runLeaderWalk part name of the move row. +func endKey(leg int, part string) string { return fmt.Sprintf("%d/%s", leg, part) } + func vec(p Point) navmesh.Vec3 { return navmesh.Vec3{p.X, p.Y, p.Z} } func point(v navmesh.Vec3) Point { return Point{v[0], v[1], v[2]} } @@ -416,6 +449,20 @@ func (c *planChecker) checkLeg(l Leg, cur Point, group int) (Point, error) { for vi, w := range l.Via { var err error c.rec.stop("via", fmt.Sprintf("via %d", vi), 0, w.Point, "") + if !w.GMCross && dist3(cur, w.Point) <= ArriveYd && dist3(cur, w.Point) > 0.05 { + // Within the run leader's arrival radius the point counts as + // reached: WalkTo returns "arrived" without sending an order, + // so the party stays where it was (kArriveYd, WalkTo). + part := fmt.Sprintf("via %d", vi) + if w.Direct { + part = fmt.Sprintf("direct via %d", vi) + } + c.ends[endKey(c.leg, part)] = cur + if w.Direct { + c.fail(part, 0, w.Point, cur, ReasonArriveSwallowed, "%.1f yd from the point before it: the run leader counts it reached (kArriveYd %g) and never sends the hop; the party stays at %.1f %.1f %.1f", dist3(cur, w.Point), ArriveYd, cur.X, cur.Y, cur.Z) + } + continue + } switch { case w.GMCross: cur, err = c.gmCross(fmt.Sprintf("gm_cross via %d", vi), w.Point, cur) @@ -430,6 +477,7 @@ func (c *planChecker) checkLeg(l Leg, cur Point, group int) (Point, error) { if err != nil { return cur, err } + c.ends[endKey(c.leg, fmt.Sprintf("via %d", vi))] = cur } c.rec.stop("stage", "to", 0, l.To, "") if !l.NeedsWalk { @@ -437,6 +485,7 @@ func (c *planChecker) checkLeg(l Leg, cur Point, group int) (Point, error) { if cur, err = c.walk("to", l.To, cur); err != nil { return cur, err } + c.ends[endKey(c.leg, "to")] = cur } else { c.needsWalk("to", 0, "", l.To, cur) cur = l.To @@ -559,6 +608,7 @@ func (c *planChecker) steps(kind string, steps []Step, cur Point) (Point, error) if st.Gossip != nil && st.Gossip.Talker() == GossipByAgent { cur = prev // the agent's own character walks to it: the party stays where it was } + c.ends[endKey(c.leg, label)] = cur } return cur, nil } @@ -583,7 +633,6 @@ func (c *planChecker) count() { c.res.Points++ } -// walk checks a plain waypoint: mesh floor under it and a complete navmesh path to it. It returns the point the party is at afterwards. func (c *planChecker) walk(part string, to, from Point) (Point, error) { c.count() c.rec.begin("", part, from, to) @@ -608,6 +657,9 @@ func (c *planChecker) walk(part string, to, from Point) (Point, error) { return from, err } c.rec.path(path) + // The offline query judges the hop first: when it answers anything but + // complete the run never sends the hop, so the run leader's own query is + // not asked. The route-trace exception stays for the Plan failure only. if path.Status != navmesh.Complete { err := c.failPath(part, 0, to, from, path) if c.rec != nil && err == nil && pathReason(path) == ReasonUnreachable { @@ -617,9 +669,54 @@ func (c *planChecker) walk(part string, to, from Point) (Point, error) { } return from, err } + // The offline query takes the hop: the run leader's own query judges it + // next (it answers 'arrived' inside the arrival radius: the run leader + // sends NO order, so the modelled position stays where it was). + if at, ok, err := c.serverWalk(part, 0, to, from); err != nil { + return at, err + } else if !ok { + return at, nil + } else if at != to { + // The server answered 'arrived': the party stays where it was. + return at, nil + } return to, nil } +// serverWalk simulates the run leader's walk of one hop with the server's own path +// query: from cur toward to, at most 3 legs (kWalkTries, DungeonRunPolicy.h:155). It +// returns where the party ends: to when the server takes it, cur (or a pocket the +// incomplete path ends in) when the server refuses it. When the navmesh is not a +// ServerQuerier, there is no server check and the hop passes. +func (c *planChecker) serverWalk(part string, group int, to, from Point) (Point, bool, error) { + q, ok := c.nav.(ServerQuerier) + if !ok { + return to, true, nil + } + cur := from + for range 3 { + if dist3(cur, to) <= ArriveYd { + // Inside the arrival radius the run leader sends NO order + // (WalkTo returns "arrived"): the party stays where it was. + return from, true, nil + } + sp, err := q.ServerPath(c.p.Map, vec(cur), vec(to)) + if err != nil { + return cur, false, err + } + if v := navmesh.ServerMoveVerdict(sp, vec(cur), vec(to), 0); v != "ok" { + c.fail(part, group, to, from, ReasonServerUnreachable, "the server's PathFinder (2048 nodes, 256 polys, smooth path, ground|water incl. steep) from %.1f %.1f %.1f answers type 0x%x: cmd move %s", cur.X, cur.Y, cur.Z, sp.Type, v) + return cur, false, nil + } + if sp.Type&navmesh.PathNormal != 0 { + return to, true, nil + } + cur = point(sp.ActualEnd) + } + c.fail(part, group, to, from, ReasonServerUnreachable, "after 3 incomplete legs the lead stands at %.1f %.1f %.1f, %.1f yd short", cur.X, cur.Y, cur.Z, dist3(cur, to)) + return cur, false, nil +} + // pathReason is the reason a path that is not complete gives: the goal off the mesh, else unreachable. func pathReason(p navmesh.Path) string { if p.Status == navmesh.NoPath && strings.HasPrefix(p.Reason, "goal ") { diff --git a/internal/dungeon/plancheck_server_test.go b/internal/dungeon/plancheck_server_test.go new file mode 100644 index 00000000..3761f6e9 --- /dev/null +++ b/internal/dungeon/plancheck_server_test.go @@ -0,0 +1,220 @@ +package dungeon + +import ( + "testing" + + "github.com/pjerra/turtle-agent/internal/navmesh" +) + +// planServerMmaps finds the navmesh files like mapMmaps, for the plan-check +// server tests (maps 808, 818, 820). +func planServerMmaps(m uint32) string { return mapMmaps(m) } + +func mustHaveMmaps(t *testing.T, maps ...uint32) { + t.Helper() + for _, m := range maps { + if planServerMmaps(m) == "" { + t.Skipf("no map %d navmesh files: set TURTLE_AGENT_MMAPS to a folder holding %03d.mmap and the tiles", m, m) + } + } +} + +// TestPlanCheckFlagsWindhornLedgeRow is case 1 at plan level: a one-leg plan +// on map 820 whose via is the ledge row. The offline query has a complete +// path, but the server runs out of nodes, so the row fails server +// unreachable. Today (without the server query) it passes with 0 issues. +func TestPlanCheckFlagsWindhornLedgeRow(t *testing.T) { + mustHaveMmaps(t, 820) + pl := navmesh.NewPlanner(planServerMmaps(820)) + defer pl.Close() + plan := &Plan{Name: "t", Map: 820, Entrance: Point{X: -7640.0, Y: -3636.0, Z: 197.3}, + Legs: []Leg{{Name: "a", Via: []Waypoint{{Point: Point{X: -7640.0, Y: -3636.0, Z: 197.3}}}, + To: Point{X: -7639.95, Y: -3636.02, Z: 224.08}}}} + res, err := CheckPlan(plan, pl, 0) + if err != nil { + t.Fatal(err) + } + if len(res.Issues) != 1 || res.Issues[0].Reason != ReasonServerUnreachable || res.Issues[0].Part != "to" { + t.Fatalf("issues %+v, want one server-unreachable on to", res.Issues) + } +} + +// TestPlanCheckFlagsWindhornServerRefusal is the addendum's offline-complete +// fixture: the offline query takes the 952 yd climb complete but the server +// answers INCOMPLETE 21.7 yd short, so the row fails server unreachable. +func TestPlanCheckFlagsWindhornServerRefusal(t *testing.T) { + mustHaveMmaps(t, 820) + pl := navmesh.NewPlanner(planServerMmaps(820)) + defer pl.Close() + from := Point{X: -7640.0, Y: -3636.0, Z: 197.4} + to := Point{X: -7616.0, Y: -3648.0, Z: 219.7} + path, err := pl.Plan(820, vec(from), vec(to)) + if err != nil { + t.Fatal(err) + } + if path.Status != navmesh.Complete { + t.Fatalf("offline: %+v, want complete", path) + } + plan := &Plan{Name: "t", Map: 820, Entrance: from, + Legs: []Leg{{Name: "a", To: to}}} + res, err := CheckPlan(plan, pl, 0) + if err != nil { + t.Fatal(err) + } + if len(res.Issues) != 1 || res.Issues[0].Reason != ReasonServerUnreachable || res.Issues[0].Part != "to" { + t.Fatalf("issues %+v, want one server-unreachable on to", res.Issues) + } +} + +// TestPlanCheckFlagsHateforgeSwallowedHop is case 2 at plan level: the leg +// starts on the ramp-foot point, with the direct check_skip hop onto the +// terrace 1.75 yd above it and the terrace `to`. The direct hop is inside the +// arrival radius (never sent: the party stays on the lower poly). From the +// lower poly the offline query is already partial, so with the offline-first +// order (addendum 2026-10-24) the boss approach fails as the established +// offline ReasonUnreachable and the server query is not asked for that hop. +func TestPlanCheckFlagsHateforgeSwallowedHop(t *testing.T) { + mustHaveMmaps(t, 808) + nav := &countServerNav{Planner: navmesh.NewPlanner(planServerMmaps(808))} + defer nav.Planner.Close() + plan := &Plan{Name: "t", Map: 808, Entrance: Point{X: -8320, Y: -3447.5, Z: 180.1}, + Legs: []Leg{{Name: "bargul_terrace", + Via: []Waypoint{ + {Point: Point{X: -8320, Y: -3447.5, Z: 180.1}}, + {Point: Point{X: -8321.5, Y: -3447.5, Z: 181}, Direct: true, CheckSkip: "terrace hop"}, + }, + To: Point{X: -8321.5, Y: -3447.5, Z: 181}, + Boss: &Boss{Entry: 1, Position: Point{X: -8309.45, Y: -3460.42, Z: 194.43}, + Approach: []Point{{X: -8309.45, Y: -3460.42, Z: 194.43}}}, + }}} + res, err := CheckPlan(plan, nav, 0) + if err != nil { + t.Fatal(err) + } + var swallowed, offline bool + for _, is := range res.Issues { + if is.Reason == ReasonArriveSwallowed && is.Part == "direct via 1" { + swallowed = true + } + if is.Reason == ReasonUnreachable && is.Part == "run leader boss approach 0" { + offline = true + } + if is.Reason == ReasonServerUnreachable { + t.Errorf("server query ran on an offline-partial hop: %+v", is) + } + } + if !swallowed { + t.Errorf("no arrival-radius issue on direct via 1: %+v", res.Issues) + } + if !offline { + t.Errorf("no offline-unreachable issue on the boss approach from the lower poly: %+v", res.Issues) + } + if nav.calls != 0 { + t.Errorf("ServerPath called %d times, want 0 (offline already fails the hop)", nav.calls) + } +} + +// countServerNav wraps a Planner and counts ServerPath calls, so the +// offline-first order is pinned: an offline-partial hop must not ask the +// server query at all. +type countServerNav struct { + *navmesh.Planner + calls int +} + +func (n *countServerNav) ServerPath(mapID uint32, from, to navmesh.Vec3) (navmesh.ServerPath, error) { + n.calls++ + return n.Planner.ServerPath(mapID, from, to) +} + +// TestPlanCheckOfflinePartialNeverAsksTheServer is the offline-first order +// (addendum 2026-10-24) on a synthetic plan: the offline path is partial but +// the server query would refuse too. The hop fails as the established offline +// ReasonUnreachable and ServerPath is never called. +func TestPlanCheckOfflinePartialNeverAsksTheServer(t *testing.T) { + nav := &offlinePartialServerNoPathNav{fakeNav: fakeNav{ + partial: map[Point]Point{{X: 10}: {X: 5}}, + }} + plan := &Plan{Name: "t", Map: 1, Entrance: Point{X: 0, Y: 0, Z: 0}, + Legs: []Leg{{Name: "a", To: Point{X: 10, Y: 0, Z: 0}}}} + res, err := CheckPlan(plan, nav, 0) + if err != nil { + t.Fatal(err) + } + if len(res.Issues) != 1 || res.Issues[0].Reason != ReasonUnreachable { + t.Fatalf("issues %+v, want one offline unreachable", res.Issues) + } + if nav.calls != 0 { + t.Fatalf("ServerPath called %d times, want 0", nav.calls) + } +} + +// offlinePartialServerNoPathNav is a fake whose offline path ends short of +// the goal while its server query refuses outright. +type offlinePartialServerNoPathNav struct { + fakeNav + calls int +} + +func (n *offlinePartialServerNoPathNav) ServerPath(_ uint32, from, to navmesh.Vec3) (navmesh.ServerPath, error) { + n.calls++ + return navmesh.ServerPath{Type: navmesh.PathNoPath}, nil +} + +// TestPlanCheckStillPassesStormwroughtLedgeFromMeshPoints is the documented +// limit of section 3.6: from mesh-height points the server's query takes the +// ledge, so plan-check passes it. The live failure (case 3) needs the bot's +// real z on the game floor, which is 2.8-3.6 yd below the mesh there +// (RUN-LESSONS 'r30 stormwrought_ruins slotD'). +func TestPlanCheckStillPassesStormwroughtLedgeFromMeshPoints(t *testing.T) { + mustHaveMmaps(t, 818) + pl := navmesh.NewPlanner(planServerMmaps(818)) + defer pl.Close() + plan := &Plan{Name: "t", Map: 818, Entrance: Point{X: -6317.6, Y: -3945.9, Z: 218.58}, + Legs: []Leg{{Name: "a", To: Point{X: -6333.1, Y: -3947.2, Z: 219.3}}}} + res, err := CheckPlan(plan, pl, 0) + if err != nil { + t.Fatal(err) + } + if !res.OK() { + t.Errorf("issues %+v, want none (documented limit: mesh-height points pass)", res.Issues) + } +} + +// serverFromNav is a Navmesh fake that also answers the server query, and +// records the from points the check's server walk starts from. +type serverFromNav struct { + fakeNav + froms []Point +} + +func (s *serverFromNav) ServerPath(_ uint32, from, to navmesh.Vec3) (navmesh.ServerPath, error) { + s.froms = append(s.froms, Point{from[0], from[1], from[2]}) + return navmesh.ServerPath{Type: navmesh.PathNormal, Points: []navmesh.Vec3{from, to}, ActualEnd: to}, nil +} + +// TestPlanCheckArriveRadiusKeepsTheParty is the arrival gate on a synthetic +// plan (no real tiles): a plain via 2 yd from the previous point is counted +// reached, so the walk to `to` starts from the earlier point. +func TestPlanCheckArriveRadiusKeepsTheParty(t *testing.T) { + nav := &serverFromNav{} + plan := &Plan{Name: "t", Map: 1, Entrance: Point{X: 0, Y: 0, Z: 0}, + Legs: []Leg{{Name: "a", + Via: []Waypoint{{Point: Point{X: 10, Y: 0, Z: 0}}, {Point: Point{X: 12, Y: 0, Z: 0}}}, + To: Point{X: 30, Y: 0, Z: 0}}}} + res, err := CheckPlan(plan, nav, 0) + if err != nil { + t.Fatal(err) + } + if !res.OK() { + t.Fatalf("issues %+v, want none", res.Issues) + } + for _, from := range nav.froms { + if from.X == 12 { + t.Errorf("server walk started from the swallowed point %v: %+v", from, nav.froms) + } + } + if len(nav.froms) == 0 { + t.Errorf("no server walk recorded") + } +} diff --git a/internal/dungeon/plancheck_test.go b/internal/dungeon/plancheck_test.go index f6b04ce8..fb83ffcc 100644 --- a/internal/dungeon/plancheck_test.go +++ b/internal/dungeon/plancheck_test.go @@ -89,11 +89,11 @@ func TestPlanCheckReasons(t *testing.T) { } res := checkLegs(t, nav, 0, Leg{ Name: "a", To: Point{10, 0, 0}, - Via: []Waypoint{{Point: Point{5, 0, 0}}, {Point: Point{8, 0, 0}, Direct: true}, {Point: Point{40, 0, 0}, Direct: true}}, + Via: []Waypoint{{Point: Point{5, 0, 0}}, {Point: Point{12, 0, 0}, Direct: true}, {Point: Point{40, 0, 0}, Direct: true}}, PullGroups: []PullGroup{pg(10, 30), pg(10, 90), pg(10, 150)}, Boss: &Boss{Entry: 9, Position: Point{10, 200, 0}}, }) - // 8,0 is a needless direct hop (a straight path of the same length), 40,0 is 32 yd across: too far. Group 3's path ends 40 yd short of + // 12,0 is a needless direct hop (a straight path of the same length), 40,0 is 28 yd across: too far. Group 3's path ends 40 yd short of // it. The boss stands off the mesh. if got, want := issueReasons(res.Issues), "0/direct via 2/0/too far 0/pull group look/3/too far 0/boss look/0/off the mesh"; got != want { t.Errorf("issues %q, want %q", got, want) @@ -552,6 +552,7 @@ var planCheckKnownFailing = map[string]bool{ "blackwing_lair": true, "crescent_grove": true, "dire_maul_east": true, + "dire_maul_west": true, "dragonmaw_retreat": true, "gilneas_city": true, "hateforge_quarry": true, @@ -563,7 +564,9 @@ var planCheckKnownFailing = map[string]bool{ "naxxramas": true, "shadowfang_keep": true, "stormwrought_ruins": true, + "sunken_temple": true, "timbermaw_hold": true, + "upper_blackrock_spire": true, "upper_karazhan_halls": true, "windhorn_canyon": true, "zul_gurub": true, diff --git a/internal/navmesh/engine_cgo.go b/internal/navmesh/engine_cgo.go index 78df3850..98b9f16f 100644 --- a/internal/navmesh/engine_cgo.go +++ b/internal/navmesh/engine_cgo.go @@ -137,6 +137,26 @@ func (e *engine) findPath(from, to Vec3) (int, []Vec3, Vec3) { return int(rc), out[:int(n)], goal } +// serverMaxPoints is the most smooth points the server's findSmoothPath returns +// (PathFinder.h MAX_POINT_PATH_LENGTH). +const serverMaxPoints = 256 + +// serverPath replays the server's own query (the core's PathInfo: BuildPolyPath + +// findSmoothPath): 2048 search nodes, at most 256 polys, ground|water included, nothing +// excluded, a smoothed path of at most 256 points. It returns the path type bits +// (PathNormal/PathIncomplete/PathNoPath), the smooth points, and the last smooth point +// (what the run leader walks to), with the asked point's distance to its walk poly for +// each end (0 when there is none). +func (e *engine) serverPath(from, to Vec3) (uint32, []Vec3, Vec3, float32, float32) { + out := make([]Vec3, serverMaxPoints) + var n C.int + var actual Vec3 + var typ C.uint + var sdist, edist C.float + C.nd_server_path(e.h, cvec(&from), cvec(&to), cvec(&out[0]), C.int(serverMaxPoints), &n, cvec(&actual), &typ, &sdist, &edist) + return uint32(typ), out[:int(n)], actual, float32(sdist), float32(edist) +} + // raycast casts a walkability ray over the mesh from one point to another: the fraction of the way at which it meets the mesh's // edge (over 1 when it reaches the end), and 0 for done, -1 when the start is off the mesh, -3 on a Detour failure. func (e *engine) raycast(from, to Vec3) (float32, int) { diff --git a/internal/navmesh/engine_nocgo.go b/internal/navmesh/engine_nocgo.go index 05ea0313..a6ef53b7 100644 --- a/internal/navmesh/engine_nocgo.go +++ b/internal/navmesh/engine_nocgo.go @@ -22,3 +22,6 @@ func (*engine) findPath(Vec3, Vec3) (int, []Vec3, Vec3) { } func (*engine) raycast(Vec3, Vec3) (float32, int) { return 0, -3 } func (*engine) markBox(Box, uint16, float32) int { return 0 } +func (*engine) serverPath(Vec3, Vec3) (uint32, []Vec3, Vec3, float32, float32) { + return 0, nil, Vec3{}, 0, 0 +} diff --git a/internal/navmesh/navdetour.cpp b/internal/navmesh/navdetour.cpp index 7d414c93..79eaf93e 100644 --- a/internal/navmesh/navdetour.cpp +++ b/internal/navmesh/navdetour.cpp @@ -21,6 +21,16 @@ const uint32_t MMAP_MAGIC = 0x4d4d4150; // 'MMAP' const uint32_t MMAP_VERSION = 6; // core MoveMapSharedDefines.h const int MAX_POLYS = 2048; // the server keeps 256 (PathFinder.h MAX_PATH_LENGTH) const int MAX_NODES = 65535; // the server's query has 2048 (MoveMap.cpp) +// The server's own query: one dtNavMeshQuery per map init'ed with SERVER_NODES nodes (the core's MoveMap.cpp:333). +const int SERVER_NODES = 2048; // MoveMap.cpp:333 +const int SERVER_MAX_POLYS = 256; // PathFinder.h MAX_PATH_LENGTH +const int SERVER_MAX_POINTS = 256; // PathFinder.h MAX_POINT_PATH_LENGTH +const float SMOOTH_PATH_SLOP = 0.4f; // PathFinder.cpp getSteerTarget +const float SMOOTH_PATH_STEP_SIZE = 2.0f; // PathFinder.cpp findSmoothPath +const unsigned PT_NORMAL = 0x1; // PathFinder.h PATHFIND_NORMAL +const unsigned PT_SHORTCUT = 0x2; // PATHFIND_SHORTCUT (unused: no swimming modelled) +const unsigned PT_INCOMPLETE = 0x4; // PATHFIND_INCOMPLETE +const unsigned PT_NOPATH = 0x8; // PATHFIND_NOPATH struct MmapTileHeader { uint32_t mmapMagic, dtVersion, mmapVersion, size, usesLiquids; @@ -29,8 +39,8 @@ struct MmapTileHeader { struct Nav { dtNavMesh* mesh = nullptr; dtNavMeshQuery* query = nullptr; + dtNavMeshQuery* server = nullptr; }; - inline void toDt(const float* w, float* d) { d[0] = w[1]; d[1] = w[2]; d[2] = w[0]; } inline void toWow(const float* d, float* w) { w[0] = d[2]; w[1] = d[0]; w[2] = d[1]; } @@ -83,8 +93,11 @@ extern "C" void* nd_create(const unsigned char* params, int size) { Nav* n = new Nav; n->mesh = dtAllocNavMesh(); n->query = dtAllocNavMeshQuery(); - if (!n->mesh || !n->query || dtStatusFailed(n->mesh->init(&p)) || - dtStatusFailed(n->query->init(n->mesh, MAX_NODES))) { + n->server = dtAllocNavMeshQuery(); + if (!n->mesh || !n->query || !n->server || dtStatusFailed(n->mesh->init(&p)) || + dtStatusFailed(n->query->init(n->mesh, MAX_NODES)) || + dtStatusFailed(n->server->init(n->mesh, SERVER_NODES))) { + dtFreeNavMeshQuery(n->server); dtFreeNavMeshQuery(n->query); dtFreeNavMesh(n->mesh); delete n; @@ -97,6 +110,7 @@ extern "C" void nd_destroy(void* h) { Nav* n = (Nav*)h; if (!n) return; + dtFreeNavMeshQuery(n->server); dtFreeNavMeshQuery(n->query); dtFreeNavMesh(n->mesh); delete n; @@ -363,3 +377,270 @@ extern "C" int nd_find_path(void* h, const float* start, const float* end, unsig *count = npts; return result; } +// ---- The server's own path query: PathInfo::BuildPolyPath + findSmoothPath (core +// PathFinder.cpp:597-916), copied from specs/plancheck-vs-server-path/repro.cpp, which +// is itself line-for-line from the core. ---- + +// True when a lies within r (horizontally) and h (vertically) of b, in Detour (y, z, x) order. +static bool inRangeYZX(const float* a, const float* b, float r, float h) { + float dx = b[0] - a[0], dy = b[1] - a[1], dz = b[2] - a[2]; + return dx * dx + dz * dz < r * r && fabsf(dy) < h; +} + +// PathFinder.cpp fixupCorridor. +static uint32_t fixupCorridor(dtPolyRef* path, uint32_t npath, uint32_t maxPath, const dtPolyRef* visited, + uint32_t nvisited) { + int fp = -1, fv = -1; + for (int i = npath - 1; i >= 0; --i) { + bool found = false; + for (int j = nvisited - 1; j >= 0; --j) { + if (path[i] == visited[j]) { + fp = i; + fv = j; + found = true; + } + } + if (found) + break; + } + if (fp == -1 || fv == -1) + return npath; + uint32_t req = nvisited - fv, orig = uint32_t(fp + 1) < npath ? fp + 1 : npath, + size = npath > orig ? npath - orig : 0; + // Keep the copy inside the buffer: req + size never exceeds maxPath, and the + // subtraction cannot underflow (the bound -Wstringop-overflow asked for; the + // query passes maxPath = SERVER_MAX_POLYS for a polys buffer of that size). + if (req > maxPath) + req = maxPath; + size = dtMin(size, maxPath - req); + if (size) + memmove(path + req, path + orig, size * sizeof(dtPolyRef)); + for (uint32_t i = 0; i < req; ++i) + path[i] = visited[(nvisited - 1) - i]; + return req + size; +} + +// PathFinder.cpp fixupShortcuts. +static int fixupShortcuts(dtPolyRef* path, int npath, dtNavMeshQuery* q) { + if (npath < 3) + return npath; + dtPolyRef neis[16]; + int nn = 0; + const dtMeshTile* t = 0; + const dtPoly* p = 0; + if (dtStatusFailed(q->getAttachedNavMesh()->getTileAndPolyByRef(path[0], &t, &p))) + return npath; + for (unsigned k = p->firstLink; k != DT_NULL_LINK; k = t->links[k].next) { + if (t->links[k].ref && nn < 16) + neis[nn++] = t->links[k].ref; + } + int cut = 0; + for (int i = dtMin(6, npath) - 1; i > 1 && cut == 0; i--) { + for (int j = 0; j < nn; j++) { + if (path[i] == neis[j]) { + cut = i; + break; + } + } + } + if (cut > 1) { + int off = cut - 1; + npath -= off; + for (int i = 1; i < npath; i++) + path[i] = path[i + off]; + } + return npath; +} + +// PathFinder.cpp getSteerTarget (named steer in the spec's repro.cpp). +static bool steer(dtNavMeshQuery* q, const float* sp, const float* ep, const dtPolyRef* path, uint32_t n, + float* pos, unsigned char& fl, dtPolyRef& ref) { + float sp3[9]; + unsigned char f3[3]; + dtPolyRef r3[3]; + int ns = 0; + if (dtStatusFailed(q->findStraightPath(sp, ep, path, n, sp3, f3, r3, &ns, 3)) || !ns) + return false; + int i = 0; + while (i < ns) { + if ((f3[i] & DT_STRAIGHTPATH_OFFMESH_CONNECTION) || !inRangeYZX(&sp3[i * 3], sp, SMOOTH_PATH_SLOP, 1000.0f)) + break; + i++; + } + if (i >= ns) + return false; + dtVcopy(pos, &sp3[i * 3]); + pos[1] = sp[1]; + fl = f3[i]; + ref = r3[i]; + return true; +} + +// PathFinder.cpp findSmoothPath (named smooth in the spec's repro.cpp). +static dtStatus smooth(dtNavMeshQuery* q, const dtQueryFilter& f, const float* sp, const float* ep, + const dtPolyRef* pp, uint32_t pn, float* out, int* on, uint32_t maxS, + uint32_t maxPoly) { + *on = 0; + uint32_t ns = 0; + dtPolyRef polys[SERVER_MAX_POLYS]; + for (uint32_t i = 0; i < pn && i < SERVER_MAX_POLYS; ++i) + polys[i] = pp[i]; + uint32_t np = pn; + float it[3], tg[3]; + if (dtStatusFailed(q->closestPointOnPolyBoundary(polys[0], sp, it))) + return DT_FAILURE; + if (dtStatusFailed(q->closestPointOnPolyBoundary(polys[np - 1], ep, tg))) + return DT_FAILURE; + dtVcopy(&out[ns * 3], it); + ns++; + while (np && ns < maxS) { + float st[3]; + unsigned char sf; + dtPolyRef sr = 0; + if (!steer(q, it, tg, polys, np, st, sf, sr)) + break; + bool eop = sf & DT_STRAIGHTPATH_END, om = sf & DT_STRAIGHTPATH_OFFMESH_CONNECTION; + float d[3]; + dtVsub(d, st, it); + float len = sqrtf(dtVdot(d, d)); + float step = SMOOTH_PATH_STEP_SIZE; + if ((eop || om) && len < step) + len = 1.0f; + else if (len < step * 4) + len = step / len; + else + len = step * 4 / len; + float mt[3]; + dtVmad(mt, it, d, len); + float res[3]; + dtPolyRef vis[16]; + int nv = 0; + if (dtStatusFailed(q->moveAlongSurface(polys[0], it, mt, &f, res, vis, &nv, 16))) + return DT_FAILURE; + np = fixupCorridor(polys, np, maxPoly, vis, nv); + np = fixupShortcuts(polys, np, q); + q->getPolyHeight(polys[0], res, &res[1]); + res[1] += 0.5f; + dtVcopy(it, res); + if (eop && inRangeYZX(it, st, SMOOTH_PATH_SLOP, 1.0f)) { + if (dtStatusSucceed(q->getPolyHeight(polys[0], tg, &tg[1]))) { + tg[1] += 0.5f; + dtVcopy(it, tg); + } + if (ns < maxS) { + dtVcopy(&out[ns * 3], it); + ns++; + } + break; + } else if (om && inRangeYZX(it, st, SMOOTH_PATH_SLOP, 1.0f)) { + dtPolyRef prev = 0, pr = polys[0]; + uint32_t npos = 0; + while (npos < np && pr != sr) { + prev = pr; + pr = polys[npos]; + npos++; + } + for (uint32_t i = npos; i < np; ++i) + polys[i - npos] = polys[i]; + np -= npos; + float s2[3], e2[3]; + if (dtStatusSucceed(q->getAttachedNavMesh()->getOffMeshConnectionPolyEndPoints(prev, pr, s2, e2))) { + if (ns < maxS) { + dtVcopy(&out[ns * 3], s2); + ns++; + } + dtVcopy(it, e2); + if (dtStatusFailed(q->getPolyHeight(polys[0], it, &it[1]))) + return DT_FAILURE; + it[1] += 0.2f; + } + } + if (ns < maxS) { + dtVcopy(&out[ns * 3], it); + ns++; + } + } + *on = ns; + return ns < 256 ? DT_SUCCESS : DT_FAILURE; +} + +// PathInfo::FindWalkPoly on the server query (extents 5/10/5 and the +3 rule, PathFinder.cpp:160-175). +static dtPolyRef serverWalkPoly(Nav* n, const float* p, const dtQueryFilter& f, float* closest, float* dist) { + const float ext[3] = {5.0f, 10.0f, 5.0f}; + dtPolyRef ref = 0; + if (dtStatusFailed(n->server->findNearestPoly(p, ext, &f, &ref, closest)) || !ref) + return 0; + if (closest[1] > p[1] + 3.0f) + return 0; + *dist = dtVdist(closest, p); + return ref; +} + +// nd_server_path: the server's exact query and verdict inputs (PathInfo::BuildPolyPath + +// findSmoothPath, PathFinder.cpp:240-265, :384-392, :405-452, :915), with the server's +// filter (include ground|water, exclude 0; PathFinder.cpp:547-566). start and end are WoW +// (x, y, z); out takes up to max_points points in the same order; actual_end starts as end +// and ends as the last smooth point (what the run leader walks to); type is one of +// PT_NORMAL/PT_INCOMPLETE/PT_NOPATH; start_dist/end_dist are the asked point's distance +// to its walk poly (0 when there is none). 0 done, -1 for a null handle. +extern "C" int nd_server_path(void* h, const float* start, const float* end, float* out, int max_points, + int* count, float* actual_end, unsigned* type, float* start_dist, + float* end_dist) { + Nav* n = (Nav*)h; + if (!n || !n->server) + return -1; + *count = 0; + *type = PT_NOPATH; + *start_dist = 0; + *end_dist = 0; + dtQueryFilter f = makeFilter(0x09, 0); + float s[3], e[3], sc[3], ec[3], sdist = 0, edist = 0; + toDt(start, s); + toDt(end, e); + toWow(e, actual_end); + dtPolyRef sref = serverWalkPoly(n, s, f, sc, &sdist); + dtPolyRef eref = serverWalkPoly(n, e, f, ec, &edist); + *start_dist = sdist; + *end_dist = edist; + if (!sref || !eref) { + *type = PT_NOPATH; + return 0; + } + // BuildPolyPath's rule stays: a goal over 7 yd from the mesh clamps the end to the + // poly boundary and starts incomplete (PathFinder.cpp:240-265; swimming is not modelled). + unsigned incomplete = 0; + if (sdist > 7.0f || edist > 7.0f) { + float cp[3]; + if (dtStatusSucceed(n->server->closestPointOnPolyBoundary(eref, e, cp))) { + dtVcopy(e, cp); + toWow(cp, actual_end); + } + incomplete = PT_INCOMPLETE; + } + // The raw asked start and end, not the floor points (PathFinder.cpp:384-392). + dtPolyRef polys[SERVER_MAX_POLYS]; + int np = 0; + dtStatus st = n->server->findPath(sref, eref, s, e, &f, polys, &np, SERVER_MAX_POLYS); + if (!np || dtStatusFailed(st)) { + *type = PT_NOPATH; + return 0; + } + *type = (polys[np - 1] == eref && !incomplete) ? PT_NORMAL : PT_INCOMPLETE; + float pts[SERVER_MAX_POINTS * 3]; + int nsmooth = 0; + dtStatus r = smooth(n->server, f, s, e, polys, np, pts, &nsmooth, SERVER_MAX_POINTS, SERVER_MAX_POLYS); + if (nsmooth < 2 || dtStatusFailed(r)) { + *type = PT_NOPATH; + *count = 0; + return 0; + } + int nout = nsmooth < max_points ? nsmooth : max_points; + for (int i = 0; i < nout; ++i) { + float d[3]; + dtVcopy(d, &pts[i * 3]); + toWow(d, &out[i * 3]); + } + *count = nout; + toWow(&pts[(nsmooth - 1) * 3], actual_end); + return 0; +} diff --git a/internal/navmesh/navdetour.h b/internal/navmesh/navdetour.h index cac2c22b..78d9fef9 100644 --- a/internal/navmesh/navdetour.h +++ b/internal/navmesh/navdetour.h @@ -43,6 +43,10 @@ int nd_polys(void* h, const float* box, float* out, int max); // count of polygons it marked, -1 when Detour has no mesh. int nd_mark_box(void* h, const float* box, unsigned short flag, float share); +// nd_server_path replays the server's own query (the core's PathInfo: BuildPolyPath + +// findSmoothPath, MoveMap.cpp:333): 2048 search nodes, at most 256 polys, ground|water +// included, nothing excluded, a smoothed path of at most 256 points. +int nd_server_path(void* h, const float* start, const float* end, float* out, int max_points, int* count, float* actual_end, unsigned* type, float* start_dist, float* end_dist); // A walkability ray along the mesh surface from start to end (WoW x, y, z; the height of end is ignored, the ray runs // over the polygons). 0 done: *t = the fraction of the way from start to end at which the ray meets the mesh's edge // (an unwalkable polygon, a missing tile, a wall of the mesh), or a value over 1 when it reaches end; -1 start is off the mesh; diff --git a/internal/navmesh/serverpath.go b/internal/navmesh/serverpath.go new file mode 100644 index 00000000..1640640b --- /dev/null +++ b/internal/navmesh/serverpath.go @@ -0,0 +1,111 @@ +package navmesh + +import "errors" + +// ServerPath is the core's own answer for one hop: the server's exact query +// (the core's PathInfo: BuildPolyPath + findSmoothPath, PathFinder.cpp) and +// its verdict inputs. Points are Detour's smooth-path points, the first near +// the start and the last what the run leader walks to; ActualEnd is that +// last point (the asked goal when the path is normal); StartDist/EndDist are +// the asked point's distance to its walk poly (FindWalkPoly; 0 when none). +// +// The core's PathType bits (PathFinder.h). +const ( + PathNormal = 0x1 + PathShortcut = 0x2 // unused: no swimming modelled + PathIncomplete = 0x4 + PathNoPath = 0x8 +) + +type ServerPath struct { + Type uint32 + Points []Vec3 + ActualEnd Vec3 + Length float32 + StartDist float32 // asked start to its walk poly (FindWalkPoly); 0 when none + EndDist float32 // asked goal to its walk poly (FindWalkPoly); 0 when none +} + +// ServerPath replays the server's own query from one point to another on a +// map. The error is for what a retry will not fix (no folder, no map, a +// broken tile, no engine); an unreachable goal is a ServerPath with type +// PathNoPath, not an error. +func (p *Planner) ServerPath(mapID uint32, from, to Vec3) (ServerPath, error) { + if p.dir == "" { + return ServerPath{}, errors.New(`navmesh: no tiles folder; set "mmaps_dir" in the settings file`) + } + p.mu.Lock() + m, err := p.mesh(mapID) + p.mu.Unlock() + if err != nil { + return ServerPath{}, err + } + names, err := p.list(mapID) + if err != nil { + return ServerPath{}, err + } + p.mu.Lock() + if p.maps[mapID] != m { + p.mu.Unlock() + return ServerPath{}, errClosed + } + m.missing = nil + want := wantTiles(names, mapID, from, to, 3, p.max) + p.mu.Unlock() + data, err := p.fetch(m, want) + if err != nil { + return ServerPath{}, err + } + p.mu.Lock() + defer p.mu.Unlock() + if p.maps[mapID] != m { + return ServerPath{}, errClosed + } + if err := p.load(m, want, data); err != nil { + return ServerPath{}, err + } + typ, pts, actual, sdist, edist := m.eng.serverPath(from, to) + sp := ServerPath{Type: typ, Points: pts, ActualEnd: actual, StartDist: sdist, EndDist: edist} + for i := 1; i < len(pts); i++ { + sp.Length += dist3D(pts[i-1], pts[i]) + } + return sp, nil +} + +// ServerMoveVerdict is TortoiseBots RemoteCommander::CheckMove +// (RemoteCommanderPolicy.h:1596-1619) for a run-leader order +// (fromRunLeader=true): "ok", "unreachable", "off-mesh" or "too-far" +// (reactDistance > 0 only; 0 skips it). +const ( + // AtLeadYd is how near (3D yd) the lead bot must stand to the point for + // it to count as reached (RemoteCommanderPolicy.h:43). + AtLeadYd = 1.0 + // NoProgressYd is the most an accepted incomplete path may end from where + // the lead bot stands before the move is refused as no progress. + NoProgressYd = 2.0 + // MaxMoveEndOffset is how far (3D yd) the path's end may lie from the + // ordered point before the move is refused off-mesh (:39). + MaxMoveEndOffset = 5.0 +) + +func ServerMoveVerdict(sp ServerPath, from, to Vec3, reactDistance float32) string { + if reactDistance > 0 && dist3D(from, to) > reactDistance { + return "too-far" + } + if dist3D(from, to) <= AtLeadYd { + return "ok" + } + if sp.Type&PathNoPath != 0 { + return "unreachable" + } + if sp.Type&PathIncomplete != 0 { + if dist3D(sp.ActualEnd, from) <= NoProgressYd { + return "unreachable" + } + return "ok" + } + if dist3D(sp.ActualEnd, to) > MaxMoveEndOffset { + return "off-mesh" + } + return "ok" +} diff --git a/internal/navmesh/serverpath_real_test.go b/internal/navmesh/serverpath_real_test.go new file mode 100644 index 00000000..4eda74e6 --- /dev/null +++ b/internal/navmesh/serverpath_real_test.go @@ -0,0 +1,236 @@ +//go:build cgo + +package navmesh + +import ( + "math" + "os" + "path/filepath" + "testing" +) + +// serverMmaps finds the navmesh files of maps 808, 818 and 820 (808.mmap and the +// 808*.mmtile tiles, and the same for the others): $TURTLE_AGENT_MMAPS or +// ~/turtle-agent-mmaps, as in zulfarrakMmaps +// (internal/dungeon/zulfarrak_test.go:410-425). +func serverMmaps() string { + home, _ := os.UserHomeDir() + for _, d := range []string{os.Getenv("TURTLE_AGENT_MMAPS"), filepath.Join(home, "turtle-agent-mmaps")} { + if d == "" { + continue + } + ok := true + for _, n := range []string{"808", "818", "820"} { + if _, err := os.Stat(filepath.Join(d, n+".mmap")); err != nil { + ok = false + break + } + if tiles, _ := filepath.Glob(filepath.Join(d, n+"*.mmtile")); len(tiles) == 0 { + ok = false + break + } + } + if ok { + return d + } + } + return "" +} + +func serverPlanner(t *testing.T) *Planner { + t.Helper() + dir := serverMmaps() + if dir == "" { + t.Skip("no map 808/818/820 navmesh files: set TURTLE_AGENT_MMAPS to a folder holding 808.mmap, 818.mmap and 820.mmap and the tiles") + } + p := NewPlanner(dir) + t.Cleanup(p.Close) + return p +} + +// TestServerPathWindhornStep641RunsOutOfNodes is case 1: the 986 yd climb the +// offline query takes with a complete path, but the server's 2048-node query +// cannot search. The server answers NOPATH, so the run leader's CheckMove +// refuses the hop "unreachable". +func TestServerPathWindhornStep641RunsOutOfNodes(t *testing.T) { + p := serverPlanner(t) + from := Vec3{-7640.0, -3636.0, 197.3} + to := Vec3{-7639.95, -3636.02, 224.08} + path, err := p.Plan(820, from, to) + if err != nil { + t.Fatal(err) + } + if path.Status != Complete || path.Length <= 900 { + t.Fatalf("offline: %+v (want complete, length > 900)", path) + } + sp, err := p.ServerPath(820, from, to) + if err != nil { + t.Fatal(err) + } + if sp.Type&PathNoPath == 0 { + t.Errorf("server type 0x%x, want NOPATH (0x8)", sp.Type) + } + if v := ServerMoveVerdict(sp, from, to, 0); v != "unreachable" { + t.Errorf("verdict %q, want unreachable", v) + } +} + +// TestServerPathHateforgeTerraceStartIsOK is the control: from the terrace +// itself (where plan-check used to start) both queries take the hop. +func TestServerPathHateforgeTerraceStartIsOK(t *testing.T) { + p := serverPlanner(t) + from := Vec3{-8321.5, -3447.5, 181.0} + to := Vec3{-8309.45, -3460.42, 194.43} + sp, err := p.ServerPath(808, from, to) + if err != nil { + t.Fatal(err) + } + if sp.Type != PathNormal { + t.Errorf("server type 0x%x, want NORMAL (0x1)", sp.Type) + } + if v := ServerMoveVerdict(sp, from, to, 0); v != "ok" { + t.Errorf("verdict %q, want ok", v) + } +} + +// TestServerPathHateforgeLowerStartWalksIntoTheSteepPocket is case 2: from the +// lower poly where the lead really stood, the server's filter (steep included) +// walks down the 0x11 slope and ends INCOMPLETE in the pocket, which CheckMove +// accepts. The offline query (steep excluded) sees only a 1-poly island. +func TestServerPathHateforgeLowerStartWalksIntoTheSteepPocket(t *testing.T) { + p := serverPlanner(t) + from := Vec3{-8320.0, -3447.5, 180.1} + to := Vec3{-8309.45, -3460.42, 194.43} + want := Vec3{-8312.5, -3474.8, 176.1} + sp, err := p.ServerPath(808, from, to) + if err != nil { + t.Fatal(err) + } + if sp.Type&PathIncomplete == 0 { + t.Errorf("server type 0x%x, want INCOMPLETE (0x4)", sp.Type) + } + if !near(sp.ActualEnd, want, 1.5) { + t.Errorf("actual end %v, want %v (±1.5)", sp.ActualEnd, want) + } + if v := ServerMoveVerdict(sp, from, to, 0); v != "ok" { + t.Errorf("verdict %q, want ok", v) + } + fl, err := p.Floors(808, want[0], want[1], want[2]-8, want[2]+8) + if err != nil { + t.Fatal(err) + } + found := false + for _, f := range fl { + if math.Abs(float64(f.Z-want[2])) <= 1.5 && f.Flags&NavSteep != 0 { + found = true + } + } + if !found { + t.Errorf("no steep floor at the pocket end %+v", fl) + } +} + +// TestServerPathHateforgePocketIsUnreachable is case 2's second half: from the +// pocket the server's own answer is NOPATH (or an incomplete leg that makes no +// progress), 22-25 yd from the goal. +func TestServerPathHateforgePocketIsUnreachable(t *testing.T) { + p := serverPlanner(t) + from := Vec3{-8312.5, -3474.8, 176.1} + to := Vec3{-8309.45, -3460.42, 194.43} + sp, err := p.ServerPath(808, from, to) + if err != nil { + t.Fatal(err) + } + if v := ServerMoveVerdict(sp, from, to, 0); v != "unreachable" { + t.Errorf("verdict %q, want unreachable (type 0x%x)", v, sp.Type) + } + d := dist3D(sp.ActualEnd, to) + if sp.Type&PathNoPath != 0 { + d = dist3D(from, to) + } + if d < 22 || d > 25 { + t.Errorf("distance %.1f, want 22-25", d) + } +} + +// TestServerPathStormwroughtIslandAfterTeleport is case 3's second half: from +// the real floor after the rescue teleport, the ledge poly is 3.6 yd above the +// lead, so the server refuses the hop. +func TestServerPathStormwroughtIslandAfterTeleport(t *testing.T) { + p := serverPlanner(t) + from := Vec3{-6333.4, -3947.2, 215.8} + to := Vec3{-6332.8, -3946.7, 219.4} + sp, err := p.ServerPath(818, from, to) + if err != nil { + t.Fatal(err) + } + if v := ServerMoveVerdict(sp, from, to, 0); v != "unreachable" { + t.Errorf("verdict %q, want unreachable (type 0x%x)", v, sp.Type) + } +} + +// TestServerPathStormwroughtStartPolyCutoff is case 3's first half: the ledge +// poly 2.8 yd above the lead still answers, 3.6 yd above it does not. +func TestServerPathStormwroughtStartPolyCutoff(t *testing.T) { + p := serverPlanner(t) + to := Vec3{-6333.1, -3947.2, 219.3} + ok, err := p.ServerPath(818, Vec3{-6317.6, -3945.9, 215.8}, to) + if err != nil { + t.Fatal(err) + } + if v := ServerMoveVerdict(ok, Vec3{-6317.6, -3945.9, 215.8}, to, 0); v != "ok" { + t.Errorf("z=215.8: verdict %q, want ok (type 0x%x)", v, ok.Type) + } + if math.Abs(float64(ok.StartDist)-2.8) > 1.5 { + t.Errorf("z=215.8: startDist %.1f, want ~2.8", ok.StartDist) + } + bad, err := p.ServerPath(818, Vec3{-6317.6, -3945.9, 215.5}, to) + if err != nil { + t.Fatal(err) + } + if bad.Type&PathNoPath == 0 || bad.StartDist != 0 { + t.Errorf("z=215.5: type 0x%x startDist %.1f, want NOPATH and 0", bad.Type, bad.StartDist) + } +} + +// TestServerMoveVerdictBranches covers every branch of ServerMoveVerdict in +// order, with synthetic ServerPath values (no tiles). +func TestServerMoveVerdictBranches(t *testing.T) { + from := Vec3{0, 0, 0} + far := Vec3{100, 0, 0} + near := Vec3{0.5, 0, 0} + mid := Vec3{30, 0, 0} + // 1. reactDistance first: too far wins over everything. + sp := ServerPath{Type: PathNoPath, ActualEnd: from} + if v := ServerMoveVerdict(sp, from, far, 60); v != "too-far" { + t.Errorf("react: %q, want too-far", v) + } + // 2. within AtLeadYd: ok even when far from the react distance is 0. + if v := ServerMoveVerdict(sp, from, near, 0); v != "ok" { + t.Errorf("at lead: %q, want ok", v) + } + // 3. NoPath gives unreachable. + if v := ServerMoveVerdict(sp, from, mid, 0); v != "unreachable" { + t.Errorf("nopath: %q, want unreachable", v) + } + // 4. Incomplete with no progress (end within NoProgressYd of from): unreachable. + pocket := ServerPath{Type: PathIncomplete, ActualEnd: Vec3{1.9, 0, 0}} + if v := ServerMoveVerdict(pocket, from, mid, 0); v != "unreachable" { + t.Errorf("incomplete no-progress: %q, want unreachable", v) + } + // 5. Incomplete with progress: ok (the run leader walks into the pocket). + pocket.ActualEnd = Vec3{2.1, 0, 0} + if v := ServerMoveVerdict(pocket, from, mid, 0); v != "ok" { + t.Errorf("incomplete progress: %q, want ok", v) + } + // 6. End more than MaxMoveEndOffset from the goal: off-mesh. + off := ServerPath{Type: PathNormal, ActualEnd: Vec3{24, 0, 0}} + if v := ServerMoveVerdict(off, from, mid, 0); v != "off-mesh" { + t.Errorf("off-mesh: %q, want off-mesh", v) + } + // 7. Otherwise ok. + ok := ServerPath{Type: PathNormal, ActualEnd: Vec3{29, 0, 0}} + if v := ServerMoveVerdict(ok, from, mid, 0); v != "ok" { + t.Errorf("ok: %q, want ok", v) + } +}