From b762c2984ba148928764c2d2ed3851fb4ebba930 Mon Sep 17 00:00:00 2001 From: pjerra <71784785+pjerra@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:21:58 +0200 Subject: [PATCH 1/6] route-oracle: measure a plan's stored route against the navmesh 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. --- README.md | 14 + cmd/turtle-agent/main.go | 3 + cmd/turtle-agent/route_oracle_cmd.go | 209 ++++++++++ cmd/turtle-agent/route_oracle_cmd_test.go | 33 ++ internal/routeoracle/metrics.go | 158 ++++++++ internal/routeoracle/metrics_test.go | 162 ++++++++ internal/routeoracle/oracle.go | 468 ++++++++++++++++++++++ internal/routeoracle/oracle_cgo_test.go | 192 +++++++++ 8 files changed, 1239 insertions(+) create mode 100644 cmd/turtle-agent/route_oracle_cmd.go create mode 100644 cmd/turtle-agent/route_oracle_cmd_test.go create mode 100644 internal/routeoracle/metrics.go create mode 100644 internal/routeoracle/metrics_test.go create mode 100644 internal/routeoracle/oracle.go create mode 100644 internal/routeoracle/oracle_cgo_test.go diff --git a/README.md b/README.md index 677411ea..2c1cacd8 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ turtle-agent map [--capture FILE] [--events FILE] [--report FILE] [--botwatch FI turtle-agent tune-diff turtle-agent plan-export [--dir dungeons] [--allow-draft] [--drop-companions-loot] [--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 route-oracle --plans DIR --mmaps DIR --out DIR [--plan NAME]... turtle-agent dashboard [--listen 127.0.0.1:8790] [--allow-host HOST[,HOST]] [--agents auto|] [--tests-dir DIR] [--traces-dir DIR] [--plans-dir DIR[,DIR]] [--minimap-dir DIR] [--embed-from ORIGIN[,ORIGIN]] [--config-dir DIR] turtle-agent minimap-extract --client DIR --out DIR [--maps 70,209|all-instances] [--mmaps DIR[,DIR]] [--prefer worldmap,minimap,navmesh] [--list] turtle-agent trace-score [--json] [--summary] [--set check=threshold]... ... @@ -855,6 +856,19 @@ that only the server's own pathing gives (a tank with no firing position), so a has navmesh files here (`$TURTLE_AGENT_MMAPS`, `~/tb-ci/mmaps-turtle` or `~/tortoise-wow-server/data/mmaps`); the plans that fail today are listed in `planCheckKnownFailing` (issue #187; most are drafts, which only warn); a listed plan that passes fails the test until it is taken off. The gate itself is tested with a stub navmesh, so it runs in CI without map files. +### Route oracle + +`turtle-agent route-oracle --plans dungeons --mmaps DIR --out DIR` compares a plan's **stored route** with what the navmesh **plans live** for the same dungeon, leg by leg, and writes `/.txt` (a human table) and `/.json` (the machine report, Stage 1's fixture). It reads plan files and mmaps and nothing else; a plan's status, live or draft, changes nothing (the report is a measurement, not a gate). Without `--plan` it reports the five mapped plans: `wailing_caverns ragefire_chasm uldaman zulfarrak deadmines`. + +Per plan it builds two chains: + +- the **objective chain**: the entrance, then per leg the boss's position, or, when the leg has no boss, its `to` (else the leg's last step's point). Per leg the walk from the previous objective to this one is planned twice: on the plain planner, and on a scratch planner with `SetAvoid` of the plan's avoid boxes — the avoid-aware one is the live path the metrics use, because it is the one a run plans with; +- the **stored polyline**: the plan's own points in the order planexport writes them (the `via` points, `to`, each pull group's `approach` points and its pull point — the positions' centroid, or a hold's spot —, the boss's approach points and position). + +Per leg the report holds: the status, end gap and live length of both runs; the stored chain's length and its ratio to the live length; the most and the mean distance of the leg's stored points to the live path; the path's Z jumps (consecutive samples of the corners and their ground sampling more than 8 yd apart in Z: ground the mesh joins without a climb); the leg's **breaks** — `via` points with `exact`, `direct`, `gm_cross` or `check_skip` — flagged when the live path passes within 6 yd of the foot, or when the leg is partial and the path's end stands within 10 yd of the foot; each pull group's corridor membership (one of its positions within the group's radius + 18 yd of the path, no more than 8 yd above or below it); and how many of the approach and via points are within 5 yd of the path ("redundant": the stored point adds nothing). The plan's line sums it up, and the report names the SHA-256 of every `.mmap`/`.mmtile` file the planning used. + +`internal/routeoracle` holds the report; its tests plan over a synthetic mesh (`internal/navmesh/navfixture`) and need no real mmaps. + ### Exporting plans for the TortoiseBots run leader `turtle-agent plan-export` writes the plan file as rows for the TortoiseBots run leader (`.bot run`; its tables `ai_dungeon_plan`, `ai_dungeon_leg`, diff --git a/cmd/turtle-agent/main.go b/cmd/turtle-agent/main.go index cb9d6532..7e51ffa3 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 "route-oracle": + return routeOracleCmd(args[1:], stdout, stderr) case "route-edits": return routeEditsCmd(ctx, args[1:], stdout, stderr) case "version": @@ -117,6 +119,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] [--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 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/route_oracle_cmd.go b/cmd/turtle-agent/route_oracle_cmd.go new file mode 100644 index 00000000..74ca93c8 --- /dev/null +++ b/cmd/turtle-agent/route_oracle_cmd.go @@ -0,0 +1,209 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "text/tabwriter" + "time" + + "github.com/pjerra/turtle-agent/internal/dungeon" + "github.com/pjerra/turtle-agent/internal/navmesh" + "github.com/pjerra/turtle-agent/internal/routeoracle" +) + +// exitRouteOracle: route-oracle could not report every plan (a failing plan is not a usage error). +const exitRouteOracle = 12 + +const routeOracleUsage = "usage: turtle-agent route-oracle --plans DIR --mmaps DIR --out DIR [--plan NAME]..." + +// routeOracleDefaultPlans are the plans the oracle reports when no --plan is given. +var routeOracleDefaultPlans = []string{"wailing_caverns", "ragefire_chasm", "uldaman", "zulfarrak", "deadmines"} + +// planList is a repeatable --plan flag. +type planList struct{ names *[]string } + +func (l *planList) String() string { + if l.names == nil { + return "" + } + return strings.Join(*l.names, ",") +} +func (l *planList) Set(v string) error { + *l.names = append(*l.names, v) + return nil +} + +// routeOracleCmd compares each plan's stored route with what the navmesh plans for its +// objective chain (entrance, then per leg its boss, else its to/last step point), and writes +// /.txt (a human table) and /.json (the machine report, Stage 1's +// fixture). Every leg is planned twice: plain, and on a scratch planner with the plan's avoid +// boxes; the report's metrics are against the avoid-aware path, the one a run plans with. +func routeOracleCmd(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("route-oracle", flag.ContinueOnError) + fs.SetOutput(stderr) + plansDir := fs.String("plans", "dungeons", "the folder with the plan files") + mmaps := fs.String("mmaps", "", "folder with the server's mmaps (.mmap and *.mmtile); required") + out := fs.String("out", "", "folder for .txt and .json; required") + var plans []string + fs.Var(&planList{&plans}, "plan", "a plan name; repeatable (default: "+strings.Join(routeOracleDefaultPlans, ", ")+")") + if err := fs.Parse(args); err != nil { + return exitUsage + } + if len(plans) == 0 { + plans = routeOracleDefaultPlans + } + if *mmaps == "" || *out == "" || fs.NArg() > 0 { + fmt.Fprintln(stderr, routeOracleUsage) + return exitUsage + } + if err := os.MkdirAll(*out, 0o755); err != nil { + fmt.Fprintf(stderr, "route-oracle: %v\n", err) + return exitUsage + } + planner := navmesh.NewPlanner(*mmaps) + defer planner.Close() + + failed := false + for _, n := range plans { + if !planName.MatchString(n) { + fmt.Fprintf(stderr, "route-oracle: %q is not a plan name (a-z, 0-9, _ and -)\n", n) + return exitUsage + } + p, err := dungeon.Load(filepath.Join(*plansDir, n+".json")) + if err != nil { + fmt.Fprintf(stderr, "route-oracle: %v\n", err) + failed = true + continue + } + t0 := time.Now() + rep, err := routeoracle.Oracle(planner, *mmaps, p) + if err != nil { + fmt.Fprintf(stderr, "route-oracle: %v\n", err) + failed = true + continue + } + if err := writeOracle(rep, *out); err != nil { + fmt.Fprintf(stderr, "route-oracle: %v\n", err) + failed = true + continue + } + fmt.Fprintf(stdout, "%s (%.0f ms wall)\n%s\n", rep.Plan, float64(time.Since(t0).Microseconds())/1000, rep.Summary()) + for _, t := range rep.Tiles { + fmt.Fprintf(stdout, " %s %s (%d bytes)\n", t.Name, t.Sha256, t.Bytes) + } + } + if failed { + return exitRouteOracle + } + return exitOK +} + +// writeOracle writes the plan's .txt and .json into dir. +func writeOracle(rep routeoracle.Report, dir string) error { + b, err := json.MarshalIndent(rep, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dir, rep.Plan+".json"), append(b, '\n'), 0o644); err != nil { + return err + } + txt, err := oracleText(rep) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, rep.Plan+".txt"), txt, 0o644) +} + +// oracleText is the plan's human table: one line per leg, then the touched breaks, the packs +// off the path, the stored points that are not redundant, the path's reasons, and the files +// used with their SHA-256. +func oracleText(rep routeoracle.Report) ([]byte, error) { + var buf bytes.Buffer + w := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0) + fmt.Fprintf(w, "%s (map %d, status %q, %d avoid boxes)\n", rep.Plan, rep.Map, rep.Status, rep.Avoid) + fmt.Fprintln(w, rep.Summary()) + fmt.Fprintln(w, "metrics against the avoid-aware path (a scratch planner with SetAvoid of the plan's boxes)") + fmt.Fprintf(w, "plan time %.0f ms\n", rep.PlanMs) + fmt.Fprintln(w, "leg\tname\tobjective\tfrom\tto\tplain\tavoid\tgap\tlive\tstored\tratio\tmaxd\tmeand\tzj\tbreaks\tpacks\tredundant") + for i := range rep.Legs { + lr := &rep.Legs[i] + fmt.Fprintf(w, "%d\t%s\t%s\t%.0f,%.0f\t%.0f,%.0f\t%s\t%s\t%.1f\t%.1f\t%.1f\t%.2f\t%.1f\t%.1f\t%d\t%d/%d\t%d/%d\t%d/%d\n", + lr.Leg, lr.Name, lr.Objective, lr.From.X, lr.From.Y, lr.To.X, lr.To.Y, + lr.Plain.Status, lr.Avoid.Status, lr.Avoid.EndGapYd, lr.Avoid.LiveLenYd, + lr.StoredLenYd, lr.Ratio, lr.StoredMaxYd, lr.StoredMeanYd, len(lr.ZJumps), + lr.BreaksTouched, len(lr.Breaks), lr.PacksOnPath, len(lr.Pulls), + redundantOf(lr), approachViaOf(lr)) + } + for i := range rep.Legs { + lr := &rep.Legs[i] + if lr.Avoid.Reason != "" { + fmt.Fprintf(w, "leg %d (%s): avoid %s: %s\n", lr.Leg, lr.Name, lr.Avoid.Status, lr.Avoid.Reason) + } + if lr.Plain.Status != lr.Avoid.Status && lr.Plain.Reason != "" { + fmt.Fprintf(w, "leg %d (%s): plain %s: %s\n", lr.Leg, lr.Name, lr.Plain.Status, lr.Plain.Reason) + } + for _, b := range lr.Breaks { + if b.Touched { + fmt.Fprintf(w, "leg %d (%s): break via %d (%s) at %.1f,%.1f,%.1f: %s\n", + lr.Leg, lr.Name, b.Via, b.Kind, b.At.X, b.At.Y, b.At.Z, b.Why) + } + } + for _, g := range lr.Pulls { + if !g.OnPath { + fmt.Fprintf(w, "leg %d (%s): pull group %d (entries %v) nearest position %.1f yd (dz %.1f) off the path (radius %.0f yd)\n", + lr.Leg, lr.Name, g.Group, g.Entries, g.NearYd, g.DZYd, g.RadiusYd) + } + } + for _, s := range lr.Stored { + if !s.Redundant && (s.Kind == "approach" || s.Kind == "via" || s.Kind == "boss_approach") { + fmt.Fprintf(w, "leg %d (%s): stored %s (seq %d) at %.1f,%.1f,%.1f is %.1f yd from the path\n", + lr.Leg, lr.Name, s.Kind, s.Seq, s.At.X, s.At.Y, s.At.Z, s.NearYd) + } + } + for _, j := range lr.ZJumps { + fmt.Fprintf(w, "leg %d (%s): z jump %.1f yd at %.1f,%.1f,%.1f\n", lr.Leg, lr.Name, j.DropYd, j.At[0], j.At[1], j.At[2]) + } + } + fmt.Fprintln(w, "tiles used (sha-256):") + for _, t := range rep.Tiles { + fmt.Fprintf(w, " %s\t%s\t%d bytes\n", t.Name, t.Sha256, t.Bytes) + } + if err := w.Flush(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// redundantOf is how many of the leg's stored approach and via points are redundant. +func redundantOf(lr *routeoracle.LegReport) int { + n := 0 + for _, s := range lr.Stored { + if s.Redundant && isApproachVia(s.Kind) { + n++ + } + } + return n +} + +// approachViaOf is how many approach and via points the leg stores. +func approachViaOf(lr *routeoracle.LegReport) int { + n := 0 + for _, s := range lr.Stored { + if isApproachVia(s.Kind) { + n++ + } + } + return n +} + +// isApproachVia says whether the stored point is an approach or via point (the redundancy +// check's kind; a boss's approach is one too). +func isApproachVia(kind string) bool { + return kind == "approach" || kind == "via" || kind == "boss_approach" +} diff --git a/cmd/turtle-agent/route_oracle_cmd_test.go b/cmd/turtle-agent/route_oracle_cmd_test.go new file mode 100644 index 00000000..081697ea --- /dev/null +++ b/cmd/turtle-agent/route_oracle_cmd_test.go @@ -0,0 +1,33 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +// The command's flags and failures, without navmesh: the oracle's own tests cover the +// report over a synthetic mesh (internal/routeoracle). +func TestRouteOracleCmdFlags(t *testing.T) { + cases := []struct { + name string + args []string + code int + want string // a prefix of stderr + }{ + {"no mmaps", []string{"--out", t.TempDir()}, exitUsage, routeOracleUsage}, + {"no out", []string{"--mmaps", "/nowhere"}, exitUsage, routeOracleUsage}, + {"a stray argument", []string{"--mmaps", "/nowhere", "--out", t.TempDir(), "extra"}, exitUsage, routeOracleUsage}, + {"a bad plan name", []string{"--mmaps", "/nowhere", "--out", t.TempDir(), "--plan", "Wailing"}, exitUsage, "route-oracle: \"Wailing\" is not a plan name"}, + {"a missing plan file", []string{"--mmaps", "/nowhere", "--out", t.TempDir(), "--plan", "nowhere"}, exitRouteOracle, "route-oracle: "}, + } + for _, c := range cases { + var stdout, stderr bytes.Buffer + if got := routeOracleCmd(c.args, &stdout, &stderr); got != c.code { + t.Errorf("%s: exit %d, want %d (stderr %q)", c.name, got, c.code, stderr.String()) + } + if c.want != "" && !strings.HasPrefix(stderr.String(), c.want) { + t.Errorf("%s: stderr %q, want it to start with %q", c.name, stderr.String(), c.want) + } + } +} diff --git a/internal/routeoracle/metrics.go b/internal/routeoracle/metrics.go new file mode 100644 index 00000000..ded37ddd --- /dev/null +++ b/internal/routeoracle/metrics.go @@ -0,0 +1,158 @@ +// Package routeoracle compares a dungeon plan's stored route with what the server's navmesh +// plans for the same walks, leg by leg, and reports how far the two are apart. It is the +// route-oracle command's engine and Stage 1's fixture writer: the stored polyline is what the +// plan and planexport hold, the live path is what Planner.Plan answers, and the report's +// numbers (ratio, corridor membership, break proximity, Z jumps) are the gap between them. +// +// The package reads plans and mmaps and nothing else. +package routeoracle + +import ( + "math" + + "github.com/pjerra/turtle-agent/internal/navmesh" +) + +// Vec3 is a WoW position (x, y, z). +type Vec3 = navmesh.Vec3 + +// The report's distances and the thresholds behind them, in yards. The brief fixes them: +// a pull group is on the path when one of its positions stands within its radius plus +// CorridorSlackYd of it (horizontally) and no more than CorridorMaxDZYd above or below it; +// a stored approach or via point within RedundantYd of the path adds nothing; a break +// (a waypoint with exact/direct/gm_cross/check_skip) is touched when the path passes within +// BreakNearYd of its foot, or when the leg is Partial and the path's end gap ends within +// BreakGapYd of the foot; consecutive path samples more than ZJumpYd apart in Z are a jump +// the mesh joins without a climb. +const ( + CorridorSlackYd = 18.0 + CorridorMaxDZYd = 8.0 + RedundantYd = 5.0 + BreakNearYd = 6.0 + BreakGapYd = 10.0 + ZJumpYd = 8.0 + + // DefaultPackRadius is the dungeon driver's radius around a pull position when a group + // gives none (driver.go's PackRadius default). + DefaultPackRadius = 25.0 +) + +func sq(v float64) float64 { return v * v } + +// dist3 is the straight distance between two points. +func dist3(a, b Vec3) float64 { + return math.Sqrt(sq(float64(a[0]-b[0])) + sq(float64(a[1]-b[1])) + sq(float64(a[2]-b[2]))) +} + +// dist2 is the horizontal distance between two points. +func dist2(a, b Vec3) float64 { + return math.Sqrt(sq(float64(a[0]-b[0])) + sq(float64(a[1]-b[1]))) +} + +// NearPoint is the distance of a point to a polyline: the straight (3D) distance to the +// nearest point of it, the horizontal (2D) one, the height between the two, and the nearest +// point itself. Dist3D is the honest "how far is it from what is walked"; the corridor tests +// take Dist2D and DZ apart, because a pack a floor above a corridor is not on it however +// close horizontally. +type NearPoint struct { + Dist3D float64 + Dist2D float64 + DZ float64 + At Vec3 // the nearest point of the polyline +} + +// NearestPolyline is the nearest point of the polyline pts to p. A one-point polyline is a +// point; an empty one returns nothing (Dist3D negative). +func NearestPolyline(pts []Vec3, p Vec3) NearPoint { + if len(pts) == 0 { + return NearPoint{Dist3D: -1} + } + best := NearPoint{Dist3D: math.Inf(1), Dist2D: math.Inf(1)} + upd := func(q Vec3) { + d3, d2, dz := dist3(p, q), dist2(p, q), math.Abs(float64(p[2]-q[2])) + if d3 < best.Dist3D { + best = NearPoint{Dist3D: d3, Dist2D: d2, DZ: dz, At: q} + } + } + upd(pts[0]) + upd(pts[len(pts)-1]) + for i := 1; i < len(pts); i++ { + a, b := pts[i-1], pts[i] + ab := [3]float64{float64(b[0] - a[0]), float64(b[1] - a[1]), float64(b[2] - a[2])} + ln := sq(ab[0]) + sq(ab[1]) + sq(ab[2]) + if ln <= 0 { + continue + } + ap := [3]float64{float64(p[0] - a[0]), float64(p[1] - a[1]), float64(p[2] - a[2])} + f := (ab[0]*ap[0] + ab[1]*ap[1] + ab[2]*ap[2]) / ln + if f <= 0 || f >= 1 { + continue // the ends are done + } + upd(Vec3{a[0] + float32(ab[0]*f), a[1] + float32(ab[1]*f), a[2] + float32(ab[2]*f)}) + } + return best +} + +// PolylineLength is the length of the walk along the polyline, in yards. +func PolylineLength(pts []Vec3) float64 { + var n float64 + for i := 1; i < len(pts); i++ { + n += dist3(pts[i-1], pts[i]) + } + return n +} + +// PathSamples flattens a navmesh path into the samples a walk passes: every corner and, +// between two corners, the surface samples of the segment. A gap between two samples of +// these more than ZJumpYd tall is ground the walk falls or hops. +func PathSamples(path navmesh.Path) []Vec3 { + if len(path.Points) == 0 { + return nil + } + out := make([]Vec3, 0, len(path.Points)) + out = append(out, path.Points[0]) + for i := 1; i < len(path.Points); i++ { + out = append(out, path.Ground[i-1]...) + out = append(out, path.Points[i]) + } + return out +} + +// ZJump is one step of more than ZJumpYd between two consecutive samples of a path: a rise +// or drop the mesh joins without a climb a walk can take. At is the lower of the two samples. +type ZJump struct { + At Vec3 + DropYd float64 +} + +// ZJumps lists the jumps between consecutive samples of a path, in walk order. +func ZJumps(samples []Vec3) []ZJump { + var out []ZJump + for i := 1; i < len(samples); i++ { + dz := float64(samples[i][2] - samples[i-1][2]) + if math.Abs(dz) > ZJumpYd { + at := samples[i-1] + if dz < 0 { + at = samples[i] + } + out = append(out, ZJump{At: at, DropYd: math.Abs(dz)}) + } + } + return out +} + +// OnCorridor says whether a position is in the corridor of the polyline: within radius +// (a pack's radius) plus CorridorSlackYd of it horizontally, and no more than +// CorridorMaxDZYd above or below the path at that point. +func OnCorridor(near NearPoint, radius float64) bool { + if near.Dist3D < 0 { + return false + } + return near.Dist2D <= radius+CorridorSlackYd && near.DZ <= CorridorMaxDZYd +} + +// Redundant says whether a stored approach or via point lies within RedundantYd of the +// live path: the walk the plan stores adds nothing the path does not already pass. +func Redundant(near NearPoint) bool { + return near.Dist3D >= 0 && near.Dist3D <= RedundantYd +} diff --git a/internal/routeoracle/metrics_test.go b/internal/routeoracle/metrics_test.go new file mode 100644 index 00000000..36225109 --- /dev/null +++ b/internal/routeoracle/metrics_test.go @@ -0,0 +1,162 @@ +package routeoracle + +import ( + "math" + "testing" + + "github.com/pjerra/turtle-agent/internal/navmesh" +) + +func near(t *testing.T, pts []Vec3, p Vec3) NearPoint { + t.Helper() + n := NearestPolyline(pts, p) + if n.Dist3D < 0 { + t.Fatalf("NearestPolyline of %v: empty polyline", p) + } + return n +} + +func TestNearestPolyline(t *testing.T) { + cases := []struct { + name string + pts []Vec3 + p Vec3 + want NearPoint + }{ + {"mid segment", []Vec3{{0, 0, 0}, {10, 0, 0}}, Vec3{5, 3, 0}, NearPoint{Dist3D: 3, Dist2D: 3, At: Vec3{5, 0, 0}}}, + {"past the end", []Vec3{{0, 0, 0}, {10, 0, 0}}, Vec3{14, 0, 0}, NearPoint{Dist3D: 4, Dist2D: 4, At: Vec3{10, 0, 0}}}, + {"before the start", []Vec3{{0, 0, 0}, {10, 0, 0}}, Vec3{-4, 0, 0}, NearPoint{Dist3D: 4, Dist2D: 4, At: Vec3{0, 0, 0}}}, + {"second segment wins", []Vec3{{0, 0, 0}, {10, 0, 0}, {10, 10, 0}}, Vec3{11, 6, 0}, NearPoint{Dist3D: 1, Dist2D: 1, At: Vec3{10, 6, 0}}}, + {"straight above", []Vec3{{0, 0, 0}, {10, 0, 0}}, Vec3{5, 0, 7}, NearPoint{Dist3D: 7, Dist2D: 0, DZ: 7, At: Vec3{5, 0, 0}}}, + {"single point", []Vec3{{2, 3, 1}}, Vec3{2, 3, 1}, NearPoint{Dist3D: 0, Dist2D: 0, At: Vec3{2, 3, 1}}}, + {"empty polyline", nil, Vec3{0, 0, 0}, NearPoint{Dist3D: -1}}, + } + for _, c := range cases { + got := NearestPolyline(c.pts, c.p) + if (got.Dist3D < 0) != (c.want.Dist3D < 0) { + t.Errorf("%s: got Dist3D %v, want %v", c.name, got.Dist3D, c.want.Dist3D) + continue + } + if got.Dist3D < 0 { + continue + } + if math.Abs(got.Dist3D-c.want.Dist3D) > 1e-6 || math.Abs(got.Dist2D-c.want.Dist2D) > 1e-6 || math.Abs(got.DZ-c.want.DZ) > 1e-6 { + t.Errorf("%s: got %+v, want %+v", c.name, got, c.want) + } + } +} + +func TestNearestPolylineEmpty(t *testing.T) { + if n := NearestPolyline(nil, Vec3{1, 2, 3}); n.Dist3D >= 0 { + t.Errorf("empty polyline: %+v", n) + } +} + +func TestPolylineLength(t *testing.T) { + cases := []struct { + name string + pts []Vec3 + want float64 + }{ + {"one point", []Vec3{{0, 0, 0}}, 0}, + {"two points", []Vec3{{0, 0, 0}, {3, 4, 0}}, 5}, + {"three points", []Vec3{{0, 0, 0}, {3, 4, 0}, {3, 4, 12}}, 17}, + } + for _, c := range cases { + if got := PolylineLength(c.pts); math.Abs(got-c.want) > 1e-6 { + t.Errorf("%s: %v, want %v", c.name, got, c.want) + } + } +} + +func TestZJumps(t *testing.T) { + cases := []struct { + name string + samples []Vec3 + want []float64 // the jumps' sizes, in order + }{ + {"flat", []Vec3{{0, 0, 0}, {4, 0, 0}, {8, 0, 0}}, nil}, + {"slope under the limit", []Vec3{{0, 0, 0}, {4, 0, 7}, {8, 0, 14}}, nil}, + {"one drop", []Vec3{{0, 0, 0}, {4, 0, 0}, {8, 0, -12}, {12, 0, -12}}, []float64{12}}, + {"one rise", []Vec3{{0, 0, 0}, {4, 0, 9}, {8, 0, 9}}, []float64{9}}, + {"exactly at the limit is no jump", []Vec3{{0, 0, 0}, {4, 0, 8}, {8, 0, 8}}, nil}, + } + for _, c := range cases { + got := ZJumps(c.samples) + if len(got) != len(c.want) { + t.Errorf("%s: %d jumps (%v), want %d", c.name, len(got), got, len(c.want)) + continue + } + for i := range got { + if math.Abs(got[i].DropYd-c.want[i]) > 1e-6 { + t.Errorf("%s: jump %d: %v, want %v", c.name, i, got[i].DropYd, c.want[i]) + } + } + } +} + +func TestZJumpsAtIsTheLowerSample(t *testing.T) { + got := ZJumps([]Vec3{{0, 0, 20}, {4, 0, 4}, {8, 0, 4}}) + if len(got) != 1 || got[0].At != (Vec3{4, 0, 4}) { + t.Errorf("drop: %+v, want the lower sample at At", got) + } + got = ZJumps([]Vec3{{0, 0, 0}, {4, 0, 12}, {8, 0, 12}}) + if len(got) != 1 || got[0].At != (Vec3{0, 0, 0}) { + t.Errorf("rise: %+v, want the lower sample at At", got) + } +} + +func TestOnCorridor(t *testing.T) { + cases := []struct { + name string + near NearPoint + radius float64 + want bool + }{ + {"on the path", NearPoint{Dist3D: 0, Dist2D: 0}, 25, true}, + {"just inside the radius", NearPoint{Dist3D: 42, Dist2D: 42, DZ: 1}, 25, true}, + {"too far horizontally", NearPoint{Dist3D: 44, Dist2D: 44, DZ: 0}, 25, false}, + {"a floor above", NearPoint{Dist3D: 20, Dist2D: 3, DZ: 20}, 25, false}, + {"no path at all", NearPoint{Dist3D: -1}, 25, false}, + } + for _, c := range cases { + if got := OnCorridor(c.near, c.radius); got != c.want { + t.Errorf("%s: %v, want %v", c.name, got, c.want) + } + } +} + +func TestRedundant(t *testing.T) { + cases := []struct { + name string + near NearPoint + want bool + }{ + {"on the path", NearPoint{Dist3D: 0}, true}, + {"just inside", NearPoint{Dist3D: RedundantYd}, true}, + {"just outside", NearPoint{Dist3D: RedundantYd + 0.1}, false}, + {"no path", NearPoint{Dist3D: -1}, false}, + } + for _, c := range cases { + if got := Redundant(c.near); got != c.want { + t.Errorf("%s: %v, want %v", c.name, got, c.want) + } + } +} + +func TestPathSamples(t *testing.T) { + p := navmesh.Path{Points: []Vec3{{0, 0, 0}, {10, 0, 0}}, Ground: [][]Vec3{{{4, 0, 1}, {8, 0, 2}}}} + got := PathSamples(p) + want := []Vec3{{0, 0, 0}, {4, 0, 1}, {8, 0, 2}, {10, 0, 0}} + if len(got) != len(want) { + t.Fatalf("got %d samples, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("sample %d: %v, want %v", i, got[i], want[i]) + } + } + if got := PathSamples(navmesh.Path{}); got != nil { + t.Errorf("empty path: %v", got) + } +} diff --git a/internal/routeoracle/oracle.go b/internal/routeoracle/oracle.go new file mode 100644 index 00000000..bb8978c2 --- /dev/null +++ b/internal/routeoracle/oracle.go @@ -0,0 +1,468 @@ +package routeoracle + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "sort" + "time" + + "github.com/pjerra/turtle-agent/internal/dungeon" + "github.com/pjerra/turtle-agent/internal/navmesh" +) + +// Run is one live planning of a leg: what the navmesh answered for it. +type Run struct { + Status string `json:"status"` // complete, partial, no_path; or error, when the planner failed + EndGapYd float64 `json:"end_gap_yd"` // from the path's end to the leg's objective + LiveLenYd float64 `json:"live_len_yd"` + Reason string `json:"reason,omitempty"` +} + +// StoredPoint is a stored point of a leg and how far the live path passes from it. +type StoredPoint struct { + Seq int `json:"seq"` // the point's place among the leg's stored points, from 1 + Kind string `json:"kind"` // via, to, approach, pull, hold, boss_approach, boss, objective + At dungeon.Point `json:"at"` + NearYd float64 `json:"near_yd"` + // Redundant: within RedundantYd of the live path, so the stored point adds nothing. + Redundant bool `json:"redundant"` +} + +// BreakTouched is a break waypoint of a leg (a via with exact/direct/gm_cross/check_skip) +// and whether the leg's live path touched it. +type BreakTouched struct { + Via int `json:"via"` // the waypoint's index in the leg's via, from 0 + Kind string `json:"kind"` + At dungeon.Point `json:"at"` + Touched bool `json:"touched"` + Why string `json:"why,omitempty"` +} + +// PullOnPath is a pull group and whether the live path's corridor holds one of its positions. +type PullOnPath struct { + Group int `json:"group"` + OnPath bool `json:"on_path"` + NearYd float64 `json:"near_yd"` // horizontal distance of the nearest position to the path + DZYd float64 `json:"dz_yd"` + RadiusYd float64 `json:"radius_yd"` + Entries []uint32 `json:"entries"` +} + +// LegReport is one leg's comparison of the stored route and the live path. +type LegReport struct { + Leg int `json:"leg"` + Name string `json:"name,omitempty"` + From dungeon.Point `json:"from"` // where the leg's live path starts (the previous leg's objective, or the entrance) + To dungeon.Point `json:"to"` // the leg's objective + // Objective says what the leg's objective point is: boss, to, step (a leg without a boss + // or to: its last step's point) or none (nothing planned for the leg). + Objective string `json:"objective"` + Plain Run `json:"plain"` // the plan on the plain planner + Avoid Run `json:"avoid"` // the plan on a scratch planner with SetAvoid(plan.avoid) + + StoredLenYd float64 `json:"stored_len_yd"` // the leg's stored chain, from the previous leg's end + // Ratio is stored length over live length (the avoid run's): below 1 the stored chain cuts + // corners the walk does not take, above 1 the walk goes where the stored points do not. + Ratio float64 `json:"ratio"` + StoredMaxYd float64 `json:"stored_max_yd"` // the stored point farthest from the live path + StoredMeanYd float64 `json:"stored_mean_yd"` + ZJumps []ZJump `json:"z_jumps,omitempty"` + Breaks []BreakTouched `json:"breaks,omitempty"` + Pulls []PullOnPath `json:"pulls,omitempty"` + Stored []StoredPoint `json:"stored,omitempty"` + BreaksTouched int `json:"breaks_touched"` + PacksOnPath int `json:"packs_on_path"` + PlanMs float64 `json:"plan_ms"` // both runs' planning time together +} + +// TileFile is one mmap or mmtile file the report's planning used. +type TileFile struct { + Name string `json:"name"` + Sha256 string `json:"sha256"` + Bytes int64 `json:"bytes"` +} + +// Report is one plan's oracle report. +type Report struct { + Plan string `json:"plan"` + Map uint32 `json:"map"` + Status string `json:"status,omitempty"` // the plan's own status line + Avoid int `json:"avoid_boxes"` + Legs []LegReport `json:"legs"` + PlanMs float64 `json:"plan_ms"` // the legs' planning time together + Tiles []TileFile `json:"tiles"` +} + +// Summary is the plan's one summary line: legs by status (the avoid run's), the mean ratio +// over the legs whose live walk is at least 1 yd (a leg whose objective stands on the last +// one's has no walk to measure), how many packs of how many the corridor holds, and the plan +// time. +func (r Report) Summary() string { + var complete, partial, nopath, other int + var ratio, on, packs float64 + liveLegs := 0 + for i := range r.Legs { + switch r.Legs[i].Avoid.Status { + case string(navmesh.Complete): + complete++ + case string(navmesh.Partial): + partial++ + case string(navmesh.NoPath): + nopath++ + default: + other++ + } + if r.Legs[i].Avoid.LiveLenYd >= 1 { + liveLegs++ + ratio += r.Legs[i].Ratio + } + on += float64(r.Legs[i].PacksOnPath) + packs += float64(len(r.Legs[i].Pulls)) + } + n := len(r.Legs) + if n == 0 { + return fmt.Sprintf("%s: map %d, no legs", r.Plan, r.Map) + } + var packsOn string + if packs > 0 { + packsOn = fmt.Sprintf(", packs on path %d/%d (%.0f%%)", int(on), int(packs), 100*on/packs) + } + mean := 0.0 + if liveLegs > 0 { + mean = ratio / float64(liveLegs) + } + return fmt.Sprintf("%s: map %d, %d legs: %d complete, %d partial, %d no_path; mean ratio %.2f%s; plan %.0f ms", + r.Plan, r.Map, n, complete, partial, nopath, mean, packsOn, r.PlanMs) +} + +// Oracle builds the plan's report: per leg, the objective chain's walk planned twice (plain, +// and on a scratch planner with the plan's avoid boxes), the stored chain's length and its +// points' distances to the live path, and the leg's breaks and pull groups. +// +// The live path of the metrics is the avoid run's: the one a run keeps out of the plan's +// avoid boxes with. A planner error (no folder, no engine, a broken tile) is returned, not +// reported. +func Oracle(p *navmesh.Planner, mmapsDir string, plan *dungeon.Plan) (Report, error) { + rep := Report{Plan: plan.Name, Map: plan.Map, Status: plan.Status, Avoid: len(plan.Avoid)} + scratch := p.Scratch() + defer scratch.Close() + scratch.SetAvoid(plan.Map, dungeon.NavBoxes(plan.Avoid)) + + // The stored chain: the entrance, then every leg's own points in the order planexport + // writes them. Leg i's stored walk runs from where leg i-1's chain ended to its own last + // point (the first index of the connection is legStart). + chain := []Vec3{vec(plan.Entrance)} + legStart := 0 + + prev := vec(plan.Entrance) // where the leg's live path starts: the previous leg's objective + for i := range plan.Legs { + l := &plan.Legs[i] + lr := LegReport{Leg: i, Name: l.Name, From: dungeon.Point{X: prev[0], Y: prev[1], Z: prev[2]}} + obj, kind := legObjective(l) + lr.To = obj + lr.Objective = kind + + // The leg's stored points go on the chain before the walk is measured, so the stored + // length holds the hop in from the previous leg's end. + for _, s := range legStored(l, obj, kind) { + chain = append(chain, s.at) + lr.Stored = append(lr.Stored, StoredPoint{Seq: len(lr.Stored) + 1, Kind: s.kind, + At: dungeon.Point{X: s.at[0], Y: s.at[1], Z: s.at[2]}}) + } + lr.StoredLenYd = PolylineLength(chain[legStart:]) + legStart = len(chain) - 1 + + if kind == "none" { + lr.Plain = Run{Status: "no_objective"} + lr.Avoid = lr.Plain + rep.Legs = append(rep.Legs, lr) + continue + } + t1 := time.Now() + plain, err := p.Plan(plan.Map, prev, vec(obj)) + if err != nil { + return rep, fmt.Errorf("plan %s leg %d (%s): plain plan: %w", plan.Name, i, l.Name, err) + } + aware, err := scratch.Plan(plan.Map, prev, vec(obj)) + if err != nil { + return rep, fmt.Errorf("plan %s leg %d (%s): avoid plan: %w", plan.Name, i, l.Name, err) + } + lr.PlanMs = float64(time.Since(t1).Microseconds()) / 1000 + rep.PlanMs += lr.PlanMs + lr.Plain = runOf(plain, obj) + lr.Avoid = runOf(aware, obj) + prev = vec(obj) + + if aware.Status != navmesh.NoPath && len(aware.Points) > 0 { + storedNear(&lr, aware) + lr.ZJumps = ZJumps(PathSamples(aware)) + lr.Breaks = breaks(l, aware) + lr.Pulls = pullGroups(l, aware) + } + for _, b := range lr.Breaks { + if b.Touched { + lr.BreaksTouched++ + } + } + for _, g := range lr.Pulls { + if g.OnPath { + lr.PacksOnPath++ + } + } + rep.Legs = append(rep.Legs, lr) + } + t, err := tiles(p, scratch, mmapsDir, plan) + rep.Tiles = t + if err != nil { + return rep, err + } + return rep, nil +} + +// runOf turns a planned path into a Run. +func runOf(p navmesh.Path, goal dungeon.Point) Run { + r := Run{Status: string(p.Status), LiveLenYd: float64(p.Length), Reason: p.Reason} + if n := len(p.Points); n > 0 { + r.EndGapYd = dist3(p.Points[n-1], vec(goal)) + } + return r +} + +// storedNear measures every stored point of the leg against the live path's polyline: how +// far the path passes from it, and whether the point is redundant (within RedundantYd of it). +// The mean and the most go in the leg's report. +func storedNear(lr *LegReport, aware navmesh.Path) { + var sum, most float64 + n := 0 + for i := range lr.Stored { + near := NearestPolyline(aware.Points, vec(lr.Stored[i].At)) + lr.Stored[i].NearYd = near.Dist3D + lr.Stored[i].Redundant = Redundant(near) + sum += near.Dist3D + if near.Dist3D > most { + most = near.Dist3D + } + n++ + } + if n > 0 { + lr.StoredMeanYd = sum / float64(n) + lr.StoredMaxYd = most + } + if lr.Avoid.LiveLenYd > 0 { + lr.Ratio = lr.StoredLenYd / lr.Avoid.LiveLenYd + } +} + +// breaks lists the leg's break waypoints (a via with exact/direct/gm_cross/check_skip) and +// whether the leg's live path touched them: within BreakNearYd of the foot, or, when the leg +// is Partial, with the path's end within BreakGapYd of the foot. +func breaks(l *dungeon.Leg, aware navmesh.Path) []BreakTouched { + var out []BreakTouched + for vi := range l.Via { + w := l.Via[vi] + kind := breakKind(w) + if kind == "" { + continue + } + b := BreakTouched{Via: vi, Kind: kind, At: w.Point} + if len(aware.Points) > 0 { + near := NearestPolyline(aware.Points, vec(w.Point)) + if near.Dist3D <= BreakNearYd { + b.Touched = true + b.Why = fmt.Sprintf("the path passes %.1f yd from it", near.Dist3D) + } + if !b.Touched && aware.Status == navmesh.Partial { + if end := aware.Points[len(aware.Points)-1]; dist3(end, vec(w.Point)) <= BreakGapYd { + b.Touched = true + b.Why = fmt.Sprintf("the partial path ends %.1f yd from it", dist3(end, vec(w.Point))) + } + } + } + out = append(out, b) + } + return out +} + +// breakKind is the waypoint's break flags, comma joined; "" when it is none. +func breakKind(w dungeon.Waypoint) string { + var parts []string + if w.Exact { + parts = append(parts, "exact") + } + if w.Direct { + parts = append(parts, "direct") + } + if w.GMCross { + parts = append(parts, "gm_cross") + } + if w.CheckSkip != "" { + parts = append(parts, "check_skip") + } + if len(parts) == 0 { + return "" + } + out := parts[0] + for _, p := range parts[1:] { + out += "," + p + } + return out +} + +// pullGroups is the leg's pull groups and whether the live path's corridor (the group's +// radius plus CorridorSlackYd horizontally, CorridorMaxDZYd in height) holds one of the +// group's positions. +func pullGroups(l *dungeon.Leg, aware navmesh.Path) []PullOnPath { + var out []PullOnPath + for gi := range l.PullGroups { + g := &l.PullGroups[gi] + radius := g.Radius + if radius <= 0 { + radius = DefaultPackRadius + } + pg := PullOnPath{Group: gi, RadiusYd: float64(radius), Entries: g.Entries} + for _, p := range g.Positions { + near := NearestPolyline(aware.Points, vec(p)) + if !pg.OnPath || near.Dist2D < pg.NearYd { + pg.NearYd, pg.DZYd = near.Dist2D, near.DZ + } + if OnCorridor(near, float64(radius)) { + pg.OnPath = true + } + } + out = append(out, pg) + } + return out +} + +// legObjective is the leg's objective chain point: the boss's position, else the leg's To, +// else the last step's point, else nothing. +func legObjective(l *dungeon.Leg) (dungeon.Point, string) { + if l.Boss != nil { + return l.Boss.Position, "boss" + } + if l.To != (dungeon.Point{}) { + return l.To, "to" + } + if p, ok := lastStepPoint(l.Steps); ok { + return p, "step" + } + if p, ok := lastStepPoint(l.AfterBoss); ok { + return p, "step" + } + return dungeon.Point{}, "none" +} + +// lastStepPoint is the last step of the list that holds a position: an object's or trigger's +// stand point, else the point the object stands at. +func lastStepPoint(steps []dungeon.Step) (dungeon.Point, bool) { + for i := len(steps) - 1; i >= 0; i-- { + st := steps[i] + switch { + case st.UseObject != nil: + if st.UseObject.Stand != nil { + return *st.UseObject.Stand, true + } + if st.UseObject.Near != nil { + return *st.UseObject.Near, true + } + case st.LootObject != nil: + if st.LootObject.Stand != nil { + return *st.LootObject.Stand, true + } + if st.LootObject.Near != nil { + return *st.LootObject.Near, true + } + case st.AreaTrigger != nil: + return st.AreaTrigger.Stand, true + } + } + return dungeon.Point{}, false +} + +// storedSpec is one stored point to put on the chain. +type storedSpec struct { + at Vec3 + kind string +} + +// legStored is the leg's own stored points in the order planexport writes the walk: the via +// waypoints, the To, each pull group's approach points and its pull point (the positions' +// centroid, or a hold's spot), the boss's approach points and its position. A leg without a +// boss or To ends its chain at its objective (the last step's point). +func legStored(l *dungeon.Leg, obj dungeon.Point, objKind string) []storedSpec { + var out []storedSpec + add := func(p dungeon.Point, kind string) { + out = append(out, storedSpec{at: vec(p), kind: kind}) + } + for _, w := range l.Via { + add(w.Point, "via") + } + if l.To != (dungeon.Point{}) { + add(l.To, "to") + } + for gi := range l.PullGroups { + g := &l.PullGroups[gi] + for _, a := range g.Approach { + add(a, "approach") + } + if g.Hold != nil { + add(g.Hold.Spot, "hold") + continue + } + add(centroid(g.Positions), "pull") + } + if l.Boss != nil { + for _, a := range l.Boss.Approach { + add(a, "boss_approach") + } + add(l.Boss.Position, "boss") + } + if objKind == "step" { + add(obj, "objective") + } + return out +} + +// centroid is the mean of the points, as planexport writes a pull row. +func centroid(pts []dungeon.Point) dungeon.Point { + var cx, cy, cz float64 + for _, p := range pts { + cx, cy, cz = cx+float64(p.X), cy+float64(p.Y), cz+float64(p.Z) + } + n := float64(len(pts)) + return dungeon.Point{X: float32(cx / n), Y: float32(cy / n), Z: float32(cz / n)} +} + +func vec(p dungeon.Point) Vec3 { return Vec3{p.X, p.Y, p.Z} } + +// tiles is the mmap and mmtile files the report's planning used, with their SHA-256 and +// sizes: the two planners' loaded tiles together and the map's own .mmap. A file that cannot +// be read is an error: the report would name files it does not have. +func tiles(p, scratch *navmesh.Planner, dir string, plan *dungeon.Plan) ([]TileFile, error) { + used := map[string]bool{} + for _, n := range p.Tiles(plan.Map) { + used[n] = true + } + for _, n := range scratch.Tiles(plan.Map) { + used[n] = true + } + used[fmt.Sprintf("%03d.mmap", plan.Map)] = true + var out []TileFile + var first error + for n := range used { + b, err := os.ReadFile(filepath.Join(dir, n)) + if err != nil { + if first == nil { + first = fmt.Errorf("plan %s tile %s: %w", plan.Name, n, err) + } + continue + } + sum := sha256.Sum256(b) + out = append(out, TileFile{Name: n, Sha256: hex.EncodeToString(sum[:]), Bytes: int64(len(b))}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, first +} diff --git a/internal/routeoracle/oracle_cgo_test.go b/internal/routeoracle/oracle_cgo_test.go new file mode 100644 index 00000000..2eb6b93e --- /dev/null +++ b/internal/routeoracle/oracle_cgo_test.go @@ -0,0 +1,192 @@ +//go:build cgo + +package routeoracle + +import ( + "encoding/json" + "testing" + + "github.com/pjerra/turtle-agent/internal/dungeon" + "github.com/pjerra/turtle-agent/internal/navmesh" + "github.com/pjerra/turtle-agent/internal/navmesh/navfixture" +) + +// The oracle on a synthetic mesh (navfixture's L corridor): a row along x from 10 to 50 at +// y 10..14, then a column up y to 50 at x 46..50. Start (12, 12), goal (48, 48); the live +// walk is 68 yd, the straight stored line 50.9 yd. No real mmaps are needed. +const corridorPlan = `{"version":1,"name":"corridor","map":0,"entrance":{"x":12,"y":12,"z":0},"legs":[ + {"name":"hall","to":{"x":46,"y":12,"z":0},"via":[{"x":22,"y":12,"z":0},{"x":30,"y":12,"z":0,"direct":true}], + "pull_groups":[{"entries":[100,101],"positions":[{"x":30,"y":12,"z":0}]}, + {"entries":[102],"positions":[{"x":200,"y":200,"z":0}]}]}, + {"name":"boss","to":{"x":48,"y":48,"z":0},"boss":{"entry":200,"position":{"x":48,"y":48,"z":0}}}]}` + +func corridorFixture(t *testing.T) (*navmesh.Planner, string, *dungeon.Plan) { + t.Helper() + dir := navfixture.WriteDir(t, 0, navfixture.LCorridor(0)) + p := navmesh.NewPlanner(dir) + t.Cleanup(p.Close) + plan, err := dungeon.Parse("corridor.json", []byte(corridorPlan)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + return p, dir, plan +} + +func TestOracleOnTheCorridorFixture(t *testing.T) { + p, dir, plan := corridorFixture(t) + rep, err := Oracle(p, dir, plan) + if err != nil { + t.Fatalf("Oracle: %v", err) + } + if rep.Plan != "corridor" || rep.Map != 0 || len(rep.Legs) != 2 { + t.Fatalf("report header: %+v", rep) + } + // Both legs plan complete; the second starts where the first ended. + for i, want := range []string{"to", "boss"} { + lr := rep.Legs[i] + if lr.Objective != want { + t.Errorf("leg %d: objective %q, want %q", i, lr.Objective, want) + } + if lr.Avoid.Status != string(navmesh.Complete) || lr.Plain.Status != string(navmesh.Complete) { + t.Errorf("leg %d: statuses plain %s avoid %s, want complete", i, lr.Plain.Status, lr.Avoid.Status) + } + if lr.Plain.EndGapYd > 0.5 || lr.Avoid.EndGapYd > 0.5 { + t.Errorf("leg %d: end gaps %v/%v, want ~0 on a complete path", i, lr.Plain.EndGapYd, lr.Avoid.EndGapYd) + } + } + if got := rep.Legs[1].From; got != (dungeon.Point{X: 46, Y: 12}) { + t.Errorf("leg 1 from %v, want the first leg's objective (46, 12)", got) + } + // The corridor membership of the two pull groups: the first's position is on the path, + // the second's is in the void far from it. + pulls := rep.Legs[0].Pulls + if len(pulls) != 2 || !pulls[0].OnPath || pulls[1].OnPath { + t.Errorf("pull groups on path: %+v", pulls) + } + if pulls[0].NearYd > 1 || pulls[1].NearYd < 100 { + t.Errorf("pull group distances: %v %v", pulls[0].NearYd, pulls[1].NearYd) + } + // The direct via lies on the path: touched. The leg's stored chain runs along the + // corridor but detours to the second group's pull point in the void (200, 200) and back, + // so the stored length dwarfs the live one: a plan's stored points really are walked in + // the order they are stored. + lr := rep.Legs[0] + if lr.BreaksTouched != 1 || len(lr.Breaks) != 1 || lr.Breaks[0].Kind != "direct" { + t.Errorf("leg 0 breaks: %+v", lr.Breaks) + } + if lr.Ratio < 5 { + t.Errorf("leg 0 ratio %v, want far above 1 (the void pull point is on the stored chain)", lr.Ratio) + } + for _, s := range lr.Stored { + want := s.Kind != "pull" || s.Seq == 4 // the first group's pull point is on the path + if s.Redundant != want { + t.Errorf("leg 0 stored point %d (%s): redundant %v, want %v (%.1f yd)", s.Seq, s.Kind, s.Redundant, want, s.NearYd) + } + } + // The boss leg's stored points: its to and its boss, both the same point at the path's end. + if got := rep.Legs[1].Stored; len(got) != 2 || got[0].Kind != "to" || got[1].Kind != "boss" { + t.Errorf("leg 1 stored points: %+v", got) + } + // No jumps on flat ground, and the tile file is named and hashed. + if len(rep.Legs[0].ZJumps) != 0 { + t.Errorf("leg 0 z jumps: %+v", rep.Legs[0].ZJumps) + } + if len(rep.Tiles) != 2 || rep.Tiles[0].Name != "000.mmap" || len(rep.Tiles[0].Sha256) != 64 { + t.Errorf("tiles: %+v", rep.Tiles) + } +} + +// The corridor membership and the break proximity of points against a really planned path. +func TestCorridorAndBreaksAgainstAPlannedPath(t *testing.T) { + p, _, _ := corridorFixture(t) + aware, err := p.Plan(0, Vec3{12, 12, 0}, Vec3{48, 48, 0}) + if err != nil { + t.Fatalf("Plan: %v", err) + } + if aware.Status != navmesh.Complete { + t.Fatalf("path %s (%s)", aware.Status, aware.Reason) + } + cases := []struct { + name string + p Vec3 + radius float64 + on bool + }{ + {"on the corridor", Vec3{12, 30, 0}, 25, true}, + {"a pack's radius inside it", Vec3{14, 30, 0}, 10, true}, + {"in the notch, over the slack of a small radius", Vec3{30, 30, 0}, -5, false}, + {"a floor above", Vec3{12, 30, 20}, 25, false}, + {"in the void", Vec3{200, 200, 0}, 25, false}, + } + for _, c := range cases { + near := NearestPolyline(aware.Points, c.p) + if got := OnCorridor(near, c.radius); got != c.on { + t.Errorf("%s: on corridor %v, want %v (near %+v)", c.name, got, c.on, near) + } + } + // A break within BreakNearYd of the path is touched; one in the void is not. + for _, c := range []struct { + p Vec3 + touched bool + }{ + {Vec3{30, 12, 0}, true}, + {Vec3{200, 200, 0}, false}, + } { + l := dungeon.Leg{Via: []dungeon.Waypoint{{Point: dungeon.Point{X: c.p[0], Y: c.p[1], Z: c.p[2]}, Direct: true}}} + got := breaks(&l, aware) + if len(got) != 1 || got[0].Touched != c.touched { + t.Errorf("break at %v: %+v, want touched %v", c.p, got, c.touched) + } + } +} + +// The ratio of the stored route to the live path on the L corridor: the plan's own points cut +// the corner the walk goes round, so the stored line is shorter. +func TestRatioCutsTheCorner(t *testing.T) { + p, _, _ := corridorFixture(t) + aware, err := p.Plan(0, Vec3{12, 12, 0}, Vec3{48, 48, 0}) + if err != nil { + t.Fatalf("Plan: %v", err) + } + stored := []Vec3{{12, 12, 0}, {48, 48, 0}} + ratio := PolylineLength(stored) / PolylineLength(aware.Points) + if ratio < 0.7 || ratio > 0.8 { + t.Errorf("ratio %v, want ~0.75 (50.9 yd straight over 68 yd round the corner)", ratio) + } +} + +// A Partial leg whose end gap ends near a break's foot touches it (the second break rule). +func TestBreakTouchedByAPartialEndGap(t *testing.T) { + // A path that ends short of the goal, 7 yd from the break's foot: within BreakGapYd. + aware := navmesh.Path{Status: navmesh.Partial, Points: []Vec3{{0, 0, 0}, {10, 0, 0}}} + l := dungeon.Leg{Via: []dungeon.Waypoint{ + {Point: dungeon.Point{X: 17, Y: 0, Z: 0}, CheckSkip: "the mesh does not link it"}, + {Point: dungeon.Point{X: 40, Y: 0, Z: 0}, CheckSkip: "far"}, + }} + got := breaks(&l, aware) + if len(got) != 2 { + t.Fatalf("breaks: %+v", got) + } + if !got[0].Touched || got[0].Kind != "check_skip" { + t.Errorf("near break: %+v, want touched", got[0]) + } + if got[1].Touched { + t.Errorf("far break: %+v, want untouched", got[1]) + } +} + +// The report marshals: Stage 1 reads these files as fixtures. +func TestReportMarshals(t *testing.T) { + p, dir, plan := corridorFixture(t) + rep, err := Oracle(p, dir, plan) + if err != nil { + t.Fatalf("Oracle: %v", err) + } + b, err := json.MarshalIndent(rep, "", " ") + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if len(b) < 100 { + t.Fatalf("report json is %d bytes", len(b)) + } +} From f4e3cec5760a16891704282a5338566f2adcdf61 Mon Sep 17 00:00:00 2001 From: pjerra <71784785+pjerra@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:20:57 +0200 Subject: [PATCH 2/6] route-oracle: score one route, and fail legs that do not path 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. --- README.md | 11 +- cmd/turtle-agent/route_oracle_cmd.go | 61 +- cmd/turtle-agent/route_oracle_cmd_test.go | 77 +++ internal/routeoracle/metrics.go | 5 +- internal/routeoracle/oracle.go | 693 ++++++++++++++++------ internal/routeoracle/oracle_cgo_test.go | 232 +++++--- internal/routeoracle/oracle_test.go | 58 ++ 7 files changed, 848 insertions(+), 289 deletions(-) create mode 100644 internal/routeoracle/oracle_test.go diff --git a/README.md b/README.md index 2c1cacd8..3f95b9b0 100644 --- a/README.md +++ b/README.md @@ -858,16 +858,15 @@ a listed plan that passes fails the test until it is taken off. The gate itself ### Route oracle -`turtle-agent route-oracle --plans dungeons --mmaps DIR --out DIR` compares a plan's **stored route** with what the navmesh **plans live** for the same dungeon, leg by leg, and writes `/.txt` (a human table) and `/.json` (the machine report, Stage 1's fixture). It reads plan files and mmaps and nothing else; a plan's status, live or draft, changes nothing (the report is a measurement, not a gate). Without `--plan` it reports the five mapped plans: `wailing_caverns ragefire_chasm uldaman zulfarrak deadmines`. +`turtle-agent route-oracle --plans dungeons --mmaps DIR --out DIR` compares a plan's **stored route** with what the navmesh **plans live** for the same dungeon, and writes `/.txt` (a human table) and `/.json` (the machine report, Stage 1's fixture). It reads plan files and mmaps and nothing else; a plan's status, live or draft, changes nothing (the report is a measurement, not a gate). Without `--plan` it reports the five mapped plans: `wailing_caverns ragefire_chasm uldaman zulfarrak deadmines`. -Per plan it builds two chains: +The walk it plans is the driver's own. Per leg the leg's stored points in the order planexport writes them — the `via` points, `to`, each pull group's `approach` points and its pull point (the positions' centroid, or a hold's spot), the steps' stand points, the boss's approach points and position, the after-boss stands — and every hop between two of them is planned twice: on the plain planner, and on a scratch planner with `SetAvoid` of the plan's avoid boxes. The avoid-aware paths are the live route the metrics use, because it is the one a run plans with; each stored hop is scored against its own navmesh hop (per-segment rows in the JSON, per-leg sums in the table). -- the **objective chain**: the entrance, then per leg the boss's position, or, when the leg has no boss, its `to` (else the leg's last step's point). Per leg the walk from the previous objective to this one is planned twice: on the plain planner, and on a scratch planner with `SetAvoid` of the plan's avoid boxes — the avoid-aware one is the live path the metrics use, because it is the one a run plans with; -- the **stored polyline**: the plan's own points in the order planexport writes them (the `via` points, `to`, each pull group's `approach` points and its pull point — the positions' centroid, or a hold's spot —, the boss's approach points and position). +A hop that does not plan complete **fails its leg**: the leg takes the failing hop's status (`partial`, `no_path`), the leg's remaining hops are planned on from the point the failed path reached and marked `non_comparable` (that walk is not the plan's, so it scores nothing), and the legs after a failed leg are `indeterminate`. Every aggregate — the mean ratio, the packs on path, the break and redundancy counts — is over the complete legs only; the failed and indeterminate legs stay in the report for reading. -Per leg the report holds: the status, end gap and live length of both runs; the stored chain's length and its ratio to the live length; the most and the mean distance of the leg's stored points to the live path; the path's Z jumps (consecutive samples of the corners and their ground sampling more than 8 yd apart in Z: ground the mesh joins without a climb); the leg's **breaks** — `via` points with `exact`, `direct`, `gm_cross` or `check_skip` — flagged when the live path passes within 6 yd of the foot, or when the leg is partial and the path's end stands within 10 yd of the foot; each pull group's corridor membership (one of its positions within the group's radius + 18 yd of the path, no more than 8 yd above or below it); and how many of the approach and via points are within 5 yd of the path ("redundant": the stored point adds nothing). The plan's line sums it up, and the report names the SHA-256 of every `.mmap`/`.mmtile` file the planning used. +Per leg the report holds: the status, end gap and live length of both runs; the stored hops' length and their ratio to the live length; the most and the mean distance of the leg's stored points to the live route; the route's Z jumps (consecutive samples of the corners and their ground sampling more than 8 yd apart in Z: ground the mesh joins without a climb); the leg's **breaks** — `via` points with `exact`, `direct`, `gm_cross` or `check_skip` — flagged when the live route passes within 6 yd of the foot, or when the failing hop is partial and its path's end stands within 10 yd of the foot; each pull group's corridor membership (one of its positions within the group's radius + 18 yd of the route, no more than 8 yd above or below it); and, for a complete leg, how many of the approach and via points are **redundant**: the navmesh path from the previous to the next stored point passes within 5 yd of the point, so the route would lose nothing without it. The plan's line sums it up, and the report names the SHA-256 of every `.mmap`/`.mmtile` file the planning used. -`internal/routeoracle` holds the report; its tests plan over a synthetic mesh (`internal/navmesh/navfixture`) and need no real mmaps. +The report is written under the **validated CLI plan name** (never the name inside the plan JSON — a plan file that says `name: "../escape"` is refused, and the destination must resolve under `--out`). `internal/routeoracle` holds the report; its tests plan over a synthetic mesh (`internal/navmesh/navfixture`) and need no real mmaps. ### Exporting plans for the TortoiseBots run leader diff --git a/cmd/turtle-agent/route_oracle_cmd.go b/cmd/turtle-agent/route_oracle_cmd.go index 74ca93c8..cd210dcf 100644 --- a/cmd/turtle-agent/route_oracle_cmd.go +++ b/cmd/turtle-agent/route_oracle_cmd.go @@ -81,6 +81,20 @@ func routeOracleCmd(args []string, stdout, stderr io.Writer) int { failed = true continue } + // The plan file's own name must be a plan name and the one asked for: the report is + // written under the name, so a name read from the JSON ("../escape") must never + // reach a path — the file names below come from the validated CLI name, and Oracle + // re-checks the plan's own. + if err := routeoracle.CheckName(p.Name); err != nil { + fmt.Fprintf(stderr, "route-oracle: %s.json: %v\n", n, err) + failed = true + continue + } + if p.Name != n { + fmt.Fprintf(stderr, "route-oracle: %s.json says name %q, want %q\n", n, p.Name, n) + failed = true + continue + } t0 := time.Now() rep, err := routeoracle.Oracle(planner, *mmaps, p) if err != nil { @@ -88,7 +102,7 @@ func routeOracleCmd(args []string, stdout, stderr io.Writer) int { failed = true continue } - if err := writeOracle(rep, *out); err != nil { + if err := writeOracle(rep, *out, n); err != nil { fmt.Fprintf(stderr, "route-oracle: %v\n", err) failed = true continue @@ -104,31 +118,38 @@ func routeOracleCmd(args []string, stdout, stderr io.Writer) int { return exitOK } -// writeOracle writes the plan's .txt and .json into dir. -func writeOracle(rep routeoracle.Report, dir string) error { +// writeOracle writes the plan's .txt and .json into dir, under the validated +// CLI name (never the plan JSON's own), and refuses a destination that resolves outside dir. +func writeOracle(rep routeoracle.Report, dir, name string) error { b, err := json.MarshalIndent(rep, "", " ") if err != nil { return err } - if err := os.WriteFile(filepath.Join(dir, rep.Plan+".json"), append(b, '\n'), 0o644); err != nil { + for _, target := range []string{filepath.Join(dir, name+".json"), filepath.Join(dir, name+".txt")} { + rel, err := filepath.Rel(dir, target) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return fmt.Errorf("report %s resolves outside %s", target, dir) + } + } + if err := os.WriteFile(filepath.Join(dir, name+".json"), append(b, '\n'), 0o644); err != nil { return err } txt, err := oracleText(rep) if err != nil { return err } - return os.WriteFile(filepath.Join(dir, rep.Plan+".txt"), txt, 0o644) + return os.WriteFile(filepath.Join(dir, name+".txt"), txt, 0o644) } -// oracleText is the plan's human table: one line per leg, then the touched breaks, the packs -// off the path, the stored points that are not redundant, the path's reasons, and the files -// used with their SHA-256. +// oracleText is the plan's human table: one line per leg, then the failed legs' hops, the +// touched breaks, the packs off the path, the stored points that are not redundant, the +// path's reasons, and the files used with their SHA-256. func oracleText(rep routeoracle.Report) ([]byte, error) { var buf bytes.Buffer w := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0) fmt.Fprintf(w, "%s (map %d, status %q, %d avoid boxes)\n", rep.Plan, rep.Map, rep.Status, rep.Avoid) fmt.Fprintln(w, rep.Summary()) - fmt.Fprintln(w, "metrics against the avoid-aware path (a scratch planner with SetAvoid of the plan's boxes)") + fmt.Fprintln(w, "metrics against the avoid-aware route (a scratch planner with SetAvoid of the plan's boxes); failed and indeterminate legs score nothing") fmt.Fprintf(w, "plan time %.0f ms\n", rep.PlanMs) fmt.Fprintln(w, "leg\tname\tobjective\tfrom\tto\tplain\tavoid\tgap\tlive\tstored\tratio\tmaxd\tmeand\tzj\tbreaks\tpacks\tredundant") for i := range rep.Legs { @@ -142,11 +163,21 @@ func oracleText(rep routeoracle.Report) ([]byte, error) { } for i := range rep.Legs { lr := &rep.Legs[i] - if lr.Avoid.Reason != "" { - fmt.Fprintf(w, "leg %d (%s): avoid %s: %s\n", lr.Leg, lr.Name, lr.Avoid.Status, lr.Avoid.Reason) + if lr.Reason != "" { + fmt.Fprintf(w, "leg %d (%s): %s: %s\n", lr.Leg, lr.Name, lr.Status, lr.Reason) } - if lr.Plain.Status != lr.Avoid.Status && lr.Plain.Reason != "" { - fmt.Fprintf(w, "leg %d (%s): plain %s: %s\n", lr.Leg, lr.Name, lr.Plain.Status, lr.Plain.Reason) + if lr.Status == "complete" { + continue // a complete leg's hops say nothing the table does not + } + for j := range lr.Segments { + seg := &lr.Segments[j] + mark := "" + if seg.NonComparable { + mark = ", non-comparable (planned on from the point the partial path reached)" + } + fmt.Fprintf(w, "leg %d (%s): hop %d (%s) %.1f,%.1f -> %.1f,%.1f: plain %s, avoid %s, live %.1f yd, stored %.1f yd, ratio %.2f%s\n", + lr.Leg, lr.Name, seg.Seq, seg.Kind, seg.From.X, seg.From.Y, seg.At.X, seg.At.Y, + seg.Plain.Status, seg.Avoid.Status, seg.Avoid.LiveLenYd, seg.StoredLenYd, seg.Ratio, mark) } for _, b := range lr.Breaks { if b.Touched { @@ -161,8 +192,8 @@ func oracleText(rep routeoracle.Report) ([]byte, error) { } } for _, s := range lr.Stored { - if !s.Redundant && (s.Kind == "approach" || s.Kind == "via" || s.Kind == "boss_approach") { - fmt.Fprintf(w, "leg %d (%s): stored %s (seq %d) at %.1f,%.1f,%.1f is %.1f yd from the path\n", + if !s.Redundant && isApproachVia(s.Kind) { + fmt.Fprintf(w, "leg %d (%s): stored %s (seq %d) at %.1f,%.1f,%.1f is %.1f yd from the route\n", lr.Leg, lr.Name, s.Kind, s.Seq, s.At.X, s.At.Y, s.At.Z, s.NearYd) } } diff --git a/cmd/turtle-agent/route_oracle_cmd_test.go b/cmd/turtle-agent/route_oracle_cmd_test.go index 081697ea..157d9891 100644 --- a/cmd/turtle-agent/route_oracle_cmd_test.go +++ b/cmd/turtle-agent/route_oracle_cmd_test.go @@ -2,8 +2,12 @@ package main import ( "bytes" + "os" + "path/filepath" "strings" "testing" + + "github.com/pjerra/turtle-agent/internal/navmesh/navfixture" ) // The command's flags and failures, without navmesh: the oracle's own tests cover the @@ -31,3 +35,76 @@ func TestRouteOracleCmdFlags(t *testing.T) { } } } + +// The plan JSON's own name never reaches a path: a plan file whose name is a traversal +// ("../escape") is refused, and nothing is written outside --out. (With the check removed, +// the report would be written to the parent of --out as escape.json, and this test's +// look-outside assertion would fail.) +func TestRouteOracleRefusesATraversalName(t *testing.T) { + dungeons := t.TempDir() + outParent := t.TempDir() + out := filepath.Join(outParent, "out") + plan := `{"version":1,"name":"../escape","map":0,"entrance":{"x":12,"y":12,"z":0},"legs":[ + {"name":"hall","to":{"x":46,"y":12,"z":0},"boss":{"entry":1,"position":{"x":48,"y":48,"z":0}}}]}` + if err := os.WriteFile(filepath.Join(dungeons, "escape.json"), []byte(plan), 0o644); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + code := routeOracleCmd([]string{"--plans", dungeons, "--mmaps", "/nowhere", "--out", out, "--plan", "escape"}, &stdout, &stderr) + if code != exitRouteOracle { + t.Fatalf("exit %d, want %d (stderr %q)", code, exitRouteOracle, stderr.String()) + } + if !strings.Contains(stderr.String(), "not a plan name") { + t.Errorf("stderr %q, want the name refusal", stderr.String()) + } + // Nothing outside --out: no report in the parent (the traversal target) and no --out made. + if _, err := os.Stat(filepath.Join(outParent, "escape.json")); err == nil { + t.Fatal("the report escaped to the parent of --out") + } + if _, err := os.Stat(out); err == nil { + if es, err := os.ReadDir(out); err == nil && len(es) > 0 { + t.Errorf("--out holds files for a refused plan: %v", es) + } + } +} + +// A plan file whose name differs from the file it is read from is refused: the report's +// name is the requested one, so the pairing must hold. +func TestRouteOracleRefusesANameMismatch(t *testing.T) { + dungeons := t.TempDir() + plan := `{"version":1,"name":"corridor","map":0,"entrance":{"x":12,"y":12,"z":0},"legs":[ + {"name":"hall","to":{"x":46,"y":12,"z":0},"boss":{"entry":1,"position":{"x":48,"y":48,"z":0}}}]}` + if err := os.WriteFile(filepath.Join(dungeons, "lie.json"), []byte(plan), 0o644); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + if code := routeOracleCmd([]string{"--mmaps", "/nowhere", "--out", t.TempDir(), "--plans", dungeons, "--plan", "lie"}, &stdout, &stderr); code != exitRouteOracle { + t.Fatalf("exit %d (stderr %q)", code, stderr.String()) + } + if !strings.Contains(stderr.String(), `says name "corridor", want "lie"`) { + t.Errorf("stderr %q", stderr.String()) + } +} + +// A run over a fixture mesh end to end, with --plans: the two files land under the +// requested name. (cgo: the fixture mesh is planned through the vendored Detour.) +func TestRouteOracleWritesTheFixtureReports(t *testing.T) { + dungeons := t.TempDir() + out := t.TempDir() + plan := `{"version":1,"name":"corridor","map":0,"entrance":{"x":12,"y":12,"z":0},"legs":[ + {"name":"hall","to":{"x":46,"y":12,"z":0},"pull_groups":[{"entries":[100],"positions":[{"x":44,"y":12,"z":0}]}]}]}` + if err := os.WriteFile(filepath.Join(dungeons, "corridor.json"), []byte(plan), 0o644); err != nil { + t.Fatal(err) + } + mmaps := navfixture.WriteDir(t, 0, navfixture.LCorridor(0)) + var stdout, stderr bytes.Buffer + if code := routeOracleCmd([]string{"--plans", dungeons, "--mmaps", mmaps, "--out", out, "--plan", "corridor"}, &stdout, &stderr); code != exitOK { + t.Fatalf("exit %d (stderr %q)", code, stderr.String()) + } + for _, f := range []string{"corridor.json", "corridor.txt"} { + b, err := os.ReadFile(filepath.Join(out, f)) + if err != nil || len(b) < 50 { + t.Errorf("%s: err %v len %d", f, err, len(b)) + } + } +} diff --git a/internal/routeoracle/metrics.go b/internal/routeoracle/metrics.go index ded37ddd..6a9bb03e 100644 --- a/internal/routeoracle/metrics.go +++ b/internal/routeoracle/metrics.go @@ -74,8 +74,9 @@ func NearestPolyline(pts []Vec3, p Vec3) NearPoint { best = NearPoint{Dist3D: d3, Dist2D: d2, DZ: dz, At: q} } } - upd(pts[0]) - upd(pts[len(pts)-1]) + for i := range pts { + upd(pts[i]) + } for i := 1; i < len(pts); i++ { a, b := pts[i-1], pts[i] ab := [3]float64{float64(b[0] - a[0]), float64(b[1] - a[1]), float64(b[2] - a[2])} diff --git a/internal/routeoracle/oracle.go b/internal/routeoracle/oracle.go index bb8978c2..0fbcb65e 100644 --- a/internal/routeoracle/oracle.go +++ b/internal/routeoracle/oracle.go @@ -4,35 +4,84 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "math" "os" "path/filepath" + "regexp" "sort" + "strings" "time" "github.com/pjerra/turtle-agent/internal/dungeon" "github.com/pjerra/turtle-agent/internal/navmesh" ) -// Run is one live planning of a leg: what the navmesh answered for it. +// PlanName is the strict basename a plan's name must take, in the plan file and on the +// command line (the same regex the CLI's --plan flags hold): the name is also the file stem +// the report is written under, so a name read from a plan JSON ("../escape") must never +// reach a path. +var PlanName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,39}$`) + +// CheckName says why the plan name is not one a report may be written under. +func CheckName(name string) error { + if !PlanName.MatchString(name) { + return fmt.Errorf("plan name %q is not a plan name (a-z, 0-9, _ and -, at most 40 characters)", name) + } + return nil +} + +// Run is one live planning of a walk: what the navmesh answered for it. type Run struct { Status string `json:"status"` // complete, partial, no_path; or error, when the planner failed - EndGapYd float64 `json:"end_gap_yd"` // from the path's end to the leg's objective + EndGapYd float64 `json:"end_gap_yd"` // from the path's end to the walk's goal LiveLenYd float64 `json:"live_len_yd"` - Reason string `json:"reason,omitempty"` + // End is where the path ends: the walk's goal, or, on a partial path, the nearest point + // to it the mesh reaches — where the party is left standing. + End dungeon.Point `json:"end,omitempty"` + Reason string `json:"reason,omitempty"` } -// StoredPoint is a stored point of a leg and how far the live path passes from it. +// Segment is one stored hop of a leg: from the previous stored walk point to this one, with +// the two navmesh plans of the same hop. A leg's segments are the hops the plan stores, in +// the order planexport writes the points, so the live route goes through exactly the points +// the plan does. +type Segment struct { + Seq int `json:"seq"` // the leg's hops, from 1 + Kind string `json:"kind"` // via, to, approach, pull, hold, stand, boss_approach, boss + // Group is the pull group the hop belongs to (the hop into the group's approach points + // or its pull centre); -1 for the leg's shared walk. A group's corridor membership is + // said against the route without its own hops. + Group int `json:"group,omitempty"` + From dungeon.Point `json:"from"` + At dungeon.Point `json:"at"` + StoredLenYd float64 `json:"stored_len_yd"` // the straight hop between the two stored points + Plain Run `json:"plain"` + Avoid Run `json:"avoid"` + // Ratio is the stored hop over the avoid run's live length: the mesh detour the hop takes. + Ratio float64 `json:"ratio"` + // Note says the hop kind beyond the plain walk: a direct hop walked in a line, a + // gm_cross landing a GM moves the party to, or a hop the plan's check_skip exempts. + Note string `json:"note,omitempty"` + // NonComparable: planned from the point the partial path before it reached, after the + // leg's break, so the walk is not the one the plan stores and its numbers score nothing. + NonComparable bool `json:"non_comparable,omitempty"` +} + +// StoredPoint is a stored walk point of a leg and how far the leg's live route passes from it. type StoredPoint struct { - Seq int `json:"seq"` // the point's place among the leg's stored points, from 1 - Kind string `json:"kind"` // via, to, approach, pull, hold, boss_approach, boss, objective + Seq int `json:"seq"` // the point's place among the leg's walk points, from 1 + Kind string `json:"kind"` At dungeon.Point `json:"at"` NearYd float64 `json:"near_yd"` - // Redundant: within RedundantYd of the live path, so the stored point adds nothing. + // Redundant: the route would lose nothing without the point — the navmesh path from the + // previous to the next stored point passes within RedundantYd of it. Said only of the + // approach and via points (a to, a stand and a boss are the leg's objectives, not hops + // the route may skip). Redundant bool `json:"redundant"` } // BreakTouched is a break waypoint of a leg (a via with exact/direct/gm_cross/check_skip) -// and whether the leg's live path touched it. +// and whether the leg's live route touched it. type BreakTouched struct { Via int `json:"via"` // the waypoint's index in the leg's via, from 0 Kind string `json:"kind"` @@ -41,41 +90,49 @@ type BreakTouched struct { Why string `json:"why,omitempty"` } -// PullOnPath is a pull group and whether the live path's corridor holds one of its positions. +// PullOnPath is a pull group and whether the leg's live route's corridor holds one of its +// positions. type PullOnPath struct { Group int `json:"group"` OnPath bool `json:"on_path"` - NearYd float64 `json:"near_yd"` // horizontal distance of the nearest position to the path + NearYd float64 `json:"near_yd"` // horizontal distance of the nearest position to the route DZYd float64 `json:"dz_yd"` RadiusYd float64 `json:"radius_yd"` Entries []uint32 `json:"entries"` } -// LegReport is one leg's comparison of the stored route and the live path. +// LegReport is one leg's comparison of the stored route and the live route. type LegReport struct { Leg int `json:"leg"` Name string `json:"name,omitempty"` - From dungeon.Point `json:"from"` // where the leg's live path starts (the previous leg's objective, or the entrance) + From dungeon.Point `json:"from"` // where the leg's route starts (the previous leg's objective, or the entrance) To dungeon.Point `json:"to"` // the leg's objective // Objective says what the leg's objective point is: boss, to, step (a leg without a boss - // or to: its last step's point) or none (nothing planned for the leg). + // or to: its last step's point) or none (nothing stored for the leg). Objective string `json:"objective"` - Plain Run `json:"plain"` // the plan on the plain planner - Avoid Run `json:"avoid"` // the plan on a scratch planner with SetAvoid(plan.avoid) - - StoredLenYd float64 `json:"stored_len_yd"` // the leg's stored chain, from the previous leg's end - // Ratio is stored length over live length (the avoid run's): below 1 the stored chain cuts - // corners the walk does not take, above 1 the walk goes where the stored points do not. - Ratio float64 `json:"ratio"` - StoredMaxYd float64 `json:"stored_max_yd"` // the stored point farthest from the live path - StoredMeanYd float64 `json:"stored_mean_yd"` - ZJumps []ZJump `json:"z_jumps,omitempty"` - Breaks []BreakTouched `json:"breaks,omitempty"` - Pulls []PullOnPath `json:"pulls,omitempty"` - Stored []StoredPoint `json:"stored,omitempty"` - BreaksTouched int `json:"breaks_touched"` - PacksOnPath int `json:"packs_on_path"` - PlanMs float64 `json:"plan_ms"` // both runs' planning time together + // Status is the leg's own: complete when every comparable segment plans complete, the + // first failing segment's status (partial, no_path) when one does not, or indeterminate + // when a leg before it failed and nothing is planned here. + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + // Plain and Avoid sum the comparable segments' runs; the avoid ones are the scratch + // planner with SetAvoid of the plan's avoid boxes, whose paths are the live route. + Plain Run `json:"plain"` + Avoid Run `json:"avoid"` + Segments []Segment `json:"segments,omitempty"` + + StoredLenYd float64 `json:"stored_len_yd"` // the comparable segments' stored hops together + Ratio float64 `json:"ratio"` // stored over live (the avoid run's) + StoredMaxYd float64 `json:"stored_max_yd"` // the stored point farthest from the live route + StoredMeanYd float64 `json:"stored_mean_yd"` + ZJumps []ZJump `json:"z_jumps,omitempty"` + Breaks []BreakTouched `json:"breaks,omitempty"` + Pulls []PullOnPath `json:"pulls,omitempty"` + Stored []StoredPoint `json:"stored,omitempty"` + + BreaksTouched int `json:"breaks_touched"` + PacksOnPath int `json:"packs_on_path"` + PlanMs float64 `json:"plan_ms"` // the leg's plans together } // TileFile is one mmap or mmtile file the report's planning used. @@ -92,35 +149,33 @@ type Report struct { Status string `json:"status,omitempty"` // the plan's own status line Avoid int `json:"avoid_boxes"` Legs []LegReport `json:"legs"` - PlanMs float64 `json:"plan_ms"` // the legs' planning time together + PlanMs float64 `json:"plan_ms"` // the legs' plans together Tiles []TileFile `json:"tiles"` } -// Summary is the plan's one summary line: legs by status (the avoid run's), the mean ratio -// over the legs whose live walk is at least 1 yd (a leg whose objective stands on the last -// one's has no walk to measure), how many packs of how many the corridor holds, and the plan -// time. +// Summary is the plan's one summary line. The aggregates are over the COMPLETE legs only: a +// leg with a partial or no-path hop is failed (its route is not the one the plan stores, and +// the point a partial path ends at would poison every number behind it), and the legs after a +// failed one are indeterminate. The mean ratio, the packs and the counts are theirs alone. func (r Report) Summary() string { - var complete, partial, nopath, other int + var complete, failed, indet, other int var ratio, on, packs float64 - liveLegs := 0 for i := range r.Legs { - switch r.Legs[i].Avoid.Status { - case string(navmesh.Complete): + switch r.Legs[i].Status { + case "complete": complete++ - case string(navmesh.Partial): - partial++ - case string(navmesh.NoPath): - nopath++ + case "indeterminate": + indet++ + case string(navmesh.Partial), string(navmesh.NoPath), "no_objective": + failed++ default: other++ } - if r.Legs[i].Avoid.LiveLenYd >= 1 { - liveLegs++ + if r.Legs[i].Status == "complete" { ratio += r.Legs[i].Ratio + on += float64(r.Legs[i].PacksOnPath) + packs += float64(len(r.Legs[i].Pulls)) } - on += float64(r.Legs[i].PacksOnPath) - packs += float64(len(r.Legs[i].Pulls)) } n := len(r.Legs) if n == 0 { @@ -131,87 +186,109 @@ func (r Report) Summary() string { packsOn = fmt.Sprintf(", packs on path %d/%d (%.0f%%)", int(on), int(packs), 100*on/packs) } mean := 0.0 - if liveLegs > 0 { - mean = ratio / float64(liveLegs) + if complete > 0 { + mean = ratio / float64(complete) } - return fmt.Sprintf("%s: map %d, %d legs: %d complete, %d partial, %d no_path; mean ratio %.2f%s; plan %.0f ms", - r.Plan, r.Map, n, complete, partial, nopath, mean, packsOn, r.PlanMs) + return fmt.Sprintf("%s: map %d, %d legs: %d complete, %d failed, %d indeterminate; mean ratio %.2f%s; plan %.0f ms", + r.Plan, r.Map, n, complete, failed, indet, mean, packsOn, r.PlanMs) } -// Oracle builds the plan's report: per leg, the objective chain's walk planned twice (plain, -// and on a scratch planner with the plan's avoid boxes), the stored chain's length and its -// points' distances to the live path, and the leg's breaks and pull groups. +// Oracle builds the plan's report. The walk it plans is the driver's own, leg by leg: the +// leg's stored points in the order planexport writes them (the via points, the To, each pull +// group's approach points and its pull point — the positions' centroid, or a hold's spot —, +// the steps' stand points, the boss's approach points and position, the after-boss stands), +// from where the previous leg left the party. Every hop of that walk is planned twice: plain, +// and on a scratch planner with the plan's avoid boxes, whose paths are the live route the +// metrics use, because it is the one a run plans with. +// +// A hop that does not plan complete fails the leg: the leg keeps the failing hop's status +// (partial, no_path), its remaining hops are planned on from the point the failed path +// reached and marked non-comparable, and the legs after it are indeterminate — the failed +// leg's and their numbers score nothing (they are still in the report, for the reading). // -// The live path of the metrics is the avoid run's: the one a run keeps out of the plan's -// avoid boxes with. A planner error (no folder, no engine, a broken tile) is returned, not -// reported. +// A planner error (no folder, no engine, a broken tile) is returned, not reported. func Oracle(p *navmesh.Planner, mmapsDir string, plan *dungeon.Plan) (Report, error) { + if err := CheckName(plan.Name); err != nil { + return Report{}, fmt.Errorf("plan file: %w", err) + } rep := Report{Plan: plan.Name, Map: plan.Map, Status: plan.Status, Avoid: len(plan.Avoid)} scratch := p.Scratch() defer scratch.Close() scratch.SetAvoid(plan.Map, dungeon.NavBoxes(plan.Avoid)) - // The stored chain: the entrance, then every leg's own points in the order planexport - // writes them. Leg i's stored walk runs from where leg i-1's chain ended to its own last - // point (the first index of the connection is legStart). - chain := []Vec3{vec(plan.Entrance)} - legStart := 0 - - prev := vec(plan.Entrance) // where the leg's live path starts: the previous leg's objective + cur := vec(plan.Entrance) // where the driver actually stands (a partial path ends short of its goal) + broken := "" // why nothing after the failed leg is comparable for i := range plan.Legs { l := &plan.Legs[i] - lr := LegReport{Leg: i, Name: l.Name, From: dungeon.Point{X: prev[0], Y: prev[1], Z: prev[2]}} + lr := LegReport{Leg: i, Name: l.Name, From: point(cur)} obj, kind := legObjective(l) lr.To = obj lr.Objective = kind - // The leg's stored points go on the chain before the walk is measured, so the stored - // length holds the hop in from the previous leg's end. - for _, s := range legStored(l, obj, kind) { - chain = append(chain, s.at) - lr.Stored = append(lr.Stored, StoredPoint{Seq: len(lr.Stored) + 1, Kind: s.kind, - At: dungeon.Point{X: s.at[0], Y: s.at[1], Z: s.at[2]}}) - } - lr.StoredLenYd = PolylineLength(chain[legStart:]) - legStart = len(chain) - 1 - - if kind == "none" { - lr.Plain = Run{Status: "no_objective"} - lr.Avoid = lr.Plain + switch { + case broken != "": + lr.Status, lr.Reason = "indeterminate", broken + rep.Legs = append(rep.Legs, lr) + continue + case kind == "none": + lr.Status = "no_objective" rep.Legs = append(rep.Legs, lr) continue } - t1 := time.Now() - plain, err := p.Plan(plan.Map, prev, vec(obj)) - if err != nil { - return rep, fmt.Errorf("plan %s leg %d (%s): plain plan: %w", plan.Name, i, l.Name, err) + + walk := legWalk(l) + lr.Stored = make([]StoredPoint, len(walk)) + for j, w := range walk { + lr.Stored[j] = StoredPoint{Seq: j + 1, Kind: w.kind, At: point(w.at)} } - aware, err := scratch.Plan(plan.Map, prev, vec(obj)) - if err != nil { - return rep, fmt.Errorf("plan %s leg %d (%s): avoid plan: %w", plan.Name, i, l.Name, err) + paths := make([]navmesh.Path, len(walk)) + failing := -1 + var failingEnd Vec3 + for j, w := range walk { + seg := Segment{Seq: j + 1, Kind: w.kind, Group: w.group, From: point(cur), At: point(w.at), StoredLenYd: dist3(cur, w.at)} + seg.NonComparable = failing >= 0 + t1 := time.Now() + plain, _, note, err := hop(plan.Map, p, cur, w) + if err != nil { + return rep, fmt.Errorf("plan %s leg %d (%s) hop %d: %s plan: %w", plan.Name, i, l.Name, j+1, note, err) + } + aware, anext, note, err := hop(plan.Map, scratch, cur, w) + if err != nil { + return rep, fmt.Errorf("plan %s leg %d (%s) hop %d: %s plan: %w", plan.Name, i, l.Name, j+1, note, err) + } + seg.Plain, seg.Avoid, seg.Note = runOf(plain, w.at), runOf(aware, w.at), note + if seg.Avoid.LiveLenYd > 0 { + seg.Ratio = seg.StoredLenYd / seg.Avoid.LiveLenYd + } + lr.PlanMs += float64(time.Since(t1).Microseconds()) / 1000 + cur = anext + paths[j] = aware + lr.Segments = append(lr.Segments, seg) + if failing < 0 && seg.Avoid.Status != string(navmesh.Complete) && seg.Avoid.Status != hopSkipped { + failing = j + failingEnd = anext + } } - lr.PlanMs = float64(time.Since(t1).Microseconds()) / 1000 - rep.PlanMs += lr.PlanMs - lr.Plain = runOf(plain, obj) - lr.Avoid = runOf(aware, obj) - prev = vec(obj) - - if aware.Status != navmesh.NoPath && len(aware.Points) > 0 { - storedNear(&lr, aware) - lr.ZJumps = ZJumps(PathSamples(aware)) - lr.Breaks = breaks(l, aware) - lr.Pulls = pullGroups(l, aware) - } - for _, b := range lr.Breaks { - if b.Touched { + sumLeg(&lr, l, failing, paths, failingEnd) + if failing >= 0 { + f := &lr.Segments[failing] + broken = fmt.Sprintf("leg %d (%s) failed: hop %d (%s) is %s: %s", i, l.Name, f.Seq, f.Kind, + f.Avoid.Status, firstLine(f.Avoid.Reason)) + } + if err := markRedundant(plan.Map, scratch, walk, &lr); err != nil { + return rep, fmt.Errorf("plan %s leg %d (%s): %w", plan.Name, i, l.Name, err) + } + for i := range lr.Breaks { + if lr.Breaks[i].Touched { lr.BreaksTouched++ } } - for _, g := range lr.Pulls { - if g.OnPath { + for i := range lr.Pulls { + if lr.Pulls[i].OnPath { lr.PacksOnPath++ } } + rep.PlanMs += lr.PlanMs rep.Legs = append(rep.Legs, lr) } t, err := tiles(p, scratch, mmapsDir, plan) @@ -222,44 +299,208 @@ func Oracle(p *navmesh.Planner, mmapsDir string, plan *dungeon.Plan) (Report, er return rep, nil } -// runOf turns a planned path into a Run. -func runOf(p navmesh.Path, goal dungeon.Point) Run { - r := Run{Status: string(p.Status), LiveLenYd: float64(p.Length), Reason: p.Reason} - if n := len(p.Points); n > 0 { - r.EndGapYd = dist3(p.Points[n-1], vec(goal)) +// sumLeg sums the comparable segments into the leg's own run, measures the stored points, +// the breaks, the pull groups and the Z jumps against the leg's live route, and says the +// leg's status. failingEnd is the point the failing hop's path reached (the party's spot on +// a partial path). +func sumLeg(lr *LegReport, l *dungeon.Leg, failing int, paths []navmesh.Path, failingEnd Vec3) { + var stored, plain, live, lastGap float64 + var route, samples []Vec3 + for i := range lr.Segments { + seg := &lr.Segments[i] + samples = append(samples, PathSamples(paths[i])...) + if seg.NonComparable { + continue + } + route = append(route, paths[i].Points...) + live += seg.Avoid.LiveLenYd + plain += seg.Plain.LiveLenYd + stored += seg.StoredLenYd + lastGap = seg.Avoid.EndGapYd + } + failingPartial := failing >= 0 && paths[failing].Status == navmesh.Partial + switch { + case failing >= 0: + f := &lr.Segments[failing] + lr.Status = f.Avoid.Status + lr.Reason = fmt.Sprintf("hop %d (%s) is %s: %s", f.Seq, f.Kind, f.Avoid.Status, firstLine(f.Avoid.Reason)) + lr.Avoid = f.Avoid + lr.Plain = f.Plain + default: + lr.Status = "complete" + lr.Avoid = Run{Status: "complete", EndGapYd: lastGap, LiveLenYd: live} + lr.Plain = Run{Status: "complete", EndGapYd: lastGap, LiveLenYd: plain} + } + lr.StoredLenYd = stored + if live > 0 { + lr.Ratio = stored / live + } + if len(route) > 0 { + var sum, most float64 + for i := range lr.Stored { + near := NearestPolyline(route, vec(lr.Stored[i].At)) + lr.Stored[i].NearYd = near.Dist3D + sum += near.Dist3D + if near.Dist3D > most { + most = near.Dist3D + } + } + lr.StoredMeanYd = sum / float64(len(lr.Stored)) + lr.StoredMaxYd = most } - return r + lr.ZJumps = ZJumps(samples) + lr.Breaks = breaksOf(l, route, failingEnd, failingPartial) + lr.Pulls = pullGroupsOf(l, paths, lr.Segments) } -// storedNear measures every stored point of the leg against the live path's polyline: how -// far the path passes from it, and whether the point is redundant (within RedundantYd of it). -// The mean and the most go in the leg's report. -func storedNear(lr *LegReport, aware navmesh.Path) { - var sum, most float64 - n := 0 - for i := range lr.Stored { - near := NearestPolyline(aware.Points, vec(lr.Stored[i].At)) - lr.Stored[i].NearYd = near.Dist3D - lr.Stored[i].Redundant = Redundant(near) - sum += near.Dist3D - if near.Dist3D > most { - most = near.Dist3D +// hop plans one hop of the leg's walk the way the dungeon driver takes it (plancheck's +// walks): a plain waypoint on the navmesh; a direct waypoint as a straight line within the +// module's limits, with ground under its end and a clear line over the mesh; a gm_cross +// waypoint as a landing the party needs mesh floor under, with no walk looked for (a GM +// moves the party there); and a hop the plan's check_skip exempts is not walked on the mesh +// at all. next is where the party stands afterwards: the path's end for a plain walk (its +// start again when no path plans), the landing point for the three break kinds. +func hop(mapID uint32, pl *navmesh.Planner, cur Vec3, spec storedSpec) (path navmesh.Path, next Vec3, note string, err error) { + next = cur + // A pull group's centre and a boss are looked at, the way plancheck's look is tolerant: + // the party stands at the pull's standoff when a complete path plans, or when the + // planned path's end comes within the pull's 25 yd view of the anchor — a pack on a + // ledge or under a bridge ends the path short of it and is pulled fine live. + switch spec.kind { + case "pull", "boss": + path, err = pl.Plan(mapID, cur, spec.at) + if err != nil { + return navmesh.Path{}, next, "look", err + } + end := pathEndOr(path, cur) + if path.Status != navmesh.Complete && dist3(end, spec.at) > dungeon.ClearSeeRadius { + return path, end, "look", nil } - n++ + if pts := pathPoints(path); len(pts) >= 2 { + stand, moved := dungeon.LookPoint(pts, point(spec.at), lookStandoffYd, lookStandoffYd, + func(p dungeon.Point) dungeon.Point { + if n, ok, _ := pl.Nearest(mapID, vec(p)); ok { + return point(n) + } + return p + }) + if moved { + next = vec(stand) + } + } + path.Status = navmesh.Complete + return path, next, "look", nil } - if n > 0 { - lr.StoredMeanYd = sum / float64(n) - lr.StoredMaxYd = most + if spec.via == nil { + path, err = pl.Plan(mapID, cur, spec.at) + if n := len(path.Points); n > 0 { + next = path.Points[n-1] + } + return path, next, "", err + } + w := spec.via + switch { + case w.GMCross: + n, ok, err := pl.Nearest(mapID, spec.at) + if err != nil { + return navmesh.Path{}, next, "gm_cross", err + } + if !ok || dist3(n, spec.at) > hopFloorYd { + return navmesh.Path{Status: navmesh.NoPath, Reason: fmt.Sprintf( + "the gm_cross landing %s is off the navmesh (no walkable polygon within %.1f yd)", + fmtPos(spec.at), hopFloorYd)}, spec.at, "gm_cross", nil + } + return navmesh.Path{Status: navmesh.Complete}, spec.at, "gm_cross (a GM moves the party here; no walk)", nil + case w.CheckSkip != "": + return navmesh.Path{Status: hopSkipped}, spec.at, "skipped (the plan's check_skip: the mesh cannot judge the hop on purpose)", nil + case w.Direct: + if !dungeon.DirectStepOK(point(cur), point(spec.at)) { + return navmesh.Path{Status: navmesh.NoPath, Reason: fmt.Sprintf( + "%.1f yd across and %.1f up or down: the direct limits are %g and %g", + dist2(cur, spec.at), math.Abs(float64(spec.at[2]-cur[2])), dungeon.DirectMaxDistance, dungeon.DirectMaxVertical)}, + spec.at, "direct", nil + } + n, ok, err := pl.Nearest(mapID, spec.at) + if err != nil { + return navmesh.Path{}, spec.at, "direct", err + } + if !ok || dist3(n, spec.at) > dungeon.DirectGroundTolerance { + return navmesh.Path{Status: navmesh.NoPath, Reason: fmt.Sprintf( + "no ground within %g yd under the landing %s", dungeon.DirectGroundTolerance, fmtPos(spec.at))}, + spec.at, "direct", nil + } + ray, err := pl.Raycast(mapID, cur, spec.at) + if err != nil { + return navmesh.Path{}, spec.at, "direct", err + } + if !ray.Known || float64(ray.Hit) < dist2(cur, spec.at)-checkRayEndYd { + return navmesh.Path{Status: navmesh.NoPath, Reason: fmt.Sprintf( + "the line from %s leaves the mesh after %.1f of %.1f yd", fmtPos(cur), ray.Hit, dist2(cur, spec.at))}, + spec.at, "direct", nil + } + return navmesh.Path{Status: navmesh.Complete, Points: []Vec3{cur, spec.at}, Length: float32(dist3(cur, spec.at))}, + spec.at, "direct (walked in a line)", nil + } + // A plain via: walked like the leg's other points. + path, err = pl.Plan(mapID, cur, spec.at) + if n := len(path.Points); n > 0 { + next = path.Points[n-1] + } + return path, next, "", err +} + +// The hop check's own limits, in yards: the plan check's walk and ray tolerances +// (plancheck.go's checkWalkDZ, checkWalkDH and checkRayEnd), and the driver's pull standoff +// (driver.go's look_standoff default). +const ( + hopFloorYd = 2.5 + checkRayEndYd = 1.5 + lookStandoffYd = 21.0 +) + +// hopSkipped is a hop the oracle does not measure: the plan exempts it (check_skip) or a GM +// moves the party past it. +const hopSkipped = "skipped" + +// pathPoints is a planned path's corners as the plan format's points. +func pathPoints(p navmesh.Path) []dungeon.Point { + out := make([]dungeon.Point, len(p.Points)) + for i, v := range p.Points { + out[i] = point(v) + } + return out +} + +// pathEndOr is where a planned path ends, or the fallback when it holds no corners. +func pathEndOr(p navmesh.Path, fallback Vec3) Vec3 { + if n := len(p.Points); n > 0 { + return p.Points[n-1] } - if lr.Avoid.LiveLenYd > 0 { - lr.Ratio = lr.StoredLenYd / lr.Avoid.LiveLenYd + return fallback +} + +// fmtPos is a position for a message. +func fmtPos(v Vec3) string { return fmt.Sprintf("%.1f %.1f %.1f", v[0], v[1], v[2]) } + +func firstLine(s string) string { + s, _, _ = strings.Cut(s, "\n") + return s +} + +// runOf turns a planned path into a Run. +func runOf(p navmesh.Path, goal Vec3) Run { + r := Run{Status: string(p.Status), LiveLenYd: float64(p.Length), Reason: p.Reason} + if n := len(p.Points); n > 0 { + r.End = point(p.Points[n-1]) + r.EndGapYd = dist3(p.Points[n-1], goal) } + return r } -// breaks lists the leg's break waypoints (a via with exact/direct/gm_cross/check_skip) and -// whether the leg's live path touched them: within BreakNearYd of the foot, or, when the leg -// is Partial, with the path's end within BreakGapYd of the foot. -func breaks(l *dungeon.Leg, aware navmesh.Path) []BreakTouched { +// breaksOf lists the leg's break waypoints (a via with exact/direct/gm_cross/check_skip) and +// whether the leg's live route touched them: within BreakNearYd of the foot, or, when the +// failing hop is partial, with the path's end within BreakGapYd of the foot. +func breaksOf(l *dungeon.Leg, route []Vec3, failingEnd Vec3, failingPartial bool) []BreakTouched { var out []BreakTouched for vi := range l.Via { w := l.Via[vi] @@ -268,17 +509,17 @@ func breaks(l *dungeon.Leg, aware navmesh.Path) []BreakTouched { continue } b := BreakTouched{Via: vi, Kind: kind, At: w.Point} - if len(aware.Points) > 0 { - near := NearestPolyline(aware.Points, vec(w.Point)) + if len(route) > 0 { + near := NearestPolyline(route, vec(w.Point)) if near.Dist3D <= BreakNearYd { b.Touched = true - b.Why = fmt.Sprintf("the path passes %.1f yd from it", near.Dist3D) + b.Why = fmt.Sprintf("the route passes %.1f yd from it", near.Dist3D) } - if !b.Touched && aware.Status == navmesh.Partial { - if end := aware.Points[len(aware.Points)-1]; dist3(end, vec(w.Point)) <= BreakGapYd { - b.Touched = true - b.Why = fmt.Sprintf("the partial path ends %.1f yd from it", dist3(end, vec(w.Point))) - } + } + if !b.Touched && failingPartial && len(route) > 0 { + if d := dist3(failingEnd, vec(w.Point)); d <= BreakGapYd { + b.Touched = true + b.Why = fmt.Sprintf("the partial path ends %.1f yd from it", d) } } out = append(out, b) @@ -301,20 +542,16 @@ func breakKind(w dungeon.Waypoint) string { if w.CheckSkip != "" { parts = append(parts, "check_skip") } - if len(parts) == 0 { - return "" - } - out := parts[0] - for _, p := range parts[1:] { - out += "," + p - } - return out + return strings.Join(parts, ",") } -// pullGroups is the leg's pull groups and whether the live path's corridor (the group's -// radius plus CorridorSlackYd horizontally, CorridorMaxDZYd in height) holds one of the -// group's positions. -func pullGroups(l *dungeon.Leg, aware navmesh.Path) []PullOnPath { +// pullGroupsOf is the leg's pull groups and whether the leg's live route passes one of the +// group's positions: within the group's radius plus CorridorSlackYd of the route +// horizontally, no more than CorridorMaxDZYd above or below the route at that point. The +// route measured is the one the driver walks anyway — every hop but the group's own (its +// approach hops and the hop into its pull centre) — so a group the party would pass without +// the stored waypoints counts as on the route. +func pullGroupsOf(l *dungeon.Leg, paths []navmesh.Path, segs []Segment) []PullOnPath { var out []PullOnPath for gi := range l.PullGroups { g := &l.PullGroups[gi] @@ -322,9 +559,18 @@ func pullGroups(l *dungeon.Leg, aware navmesh.Path) []PullOnPath { if radius <= 0 { radius = DefaultPackRadius } + var route []Vec3 + for i := range segs { + if segs[i].Group != gi && len(paths[i].Points) > 0 { + route = append(route, paths[i].Points...) + } + } pg := PullOnPath{Group: gi, RadiusYd: float64(radius), Entries: g.Entries} - for _, p := range g.Positions { - near := NearestPolyline(aware.Points, vec(p)) + for i := range g.Positions { + if len(route) == 0 { + continue + } + near := NearestPolyline(route, vec(g.Positions[i])) if !pg.OnPath || near.Dist2D < pg.NearYd { pg.NearYd, pg.DZYd = near.Dist2D, near.DZ } @@ -337,6 +583,34 @@ func pullGroups(l *dungeon.Leg, aware navmesh.Path) []PullOnPath { return out } +// markRedundant says, for each stored approach or via point of a complete leg, whether the +// route would lose nothing without it: the navmesh path from the previous to the next stored +// point (the avoid-aware one) passes within RedundantYd of the point. The leg's first and +// last walk points have no such pair of neighbours and are never redundant. +func markRedundant(mapID uint32, sc *navmesh.Planner, walk []storedSpec, lr *LegReport) error { + for i := range lr.Stored { + s := &lr.Stored[i] + if !isApproachVia(s.Kind) || i == 0 || i == len(walk)-1 { + continue + } + skip, err := sc.Plan(mapID, walk[i-1].at, walk[i+1].at) + if err != nil { + return fmt.Errorf("hop %d redundancy plan: %w", i+1, err) + } + if skip.Status == navmesh.NoPath || len(skip.Points) == 0 { + continue + } + s.Redundant = Redundant(NearestPolyline(skip.Points, walk[i].at)) + } + return nil +} + +// isApproachVia says whether the stored point is an approach or via point (the redundancy +// check's kind; a boss's approach is one too). +func isApproachVia(kind string) bool { + return kind == "approach" || kind == "via" || kind == "boss_approach" +} + // legObjective is the leg's objective chain point: the boss's position, else the leg's To, // else the last step's point, else nothing. func legObjective(l *dungeon.Leg) (dungeon.Point, string) { @@ -355,73 +629,97 @@ func legObjective(l *dungeon.Leg) (dungeon.Point, string) { return dungeon.Point{}, "none" } -// lastStepPoint is the last step of the list that holds a position: an object's or trigger's -// stand point, else the point the object stands at. +// lastStepPoint is the last step of the list that holds a stand point. func lastStepPoint(steps []dungeon.Step) (dungeon.Point, bool) { for i := len(steps) - 1; i >= 0; i-- { - st := steps[i] - switch { - case st.UseObject != nil: - if st.UseObject.Stand != nil { - return *st.UseObject.Stand, true - } - if st.UseObject.Near != nil { - return *st.UseObject.Near, true - } - case st.LootObject != nil: - if st.LootObject.Stand != nil { - return *st.LootObject.Stand, true - } - if st.LootObject.Near != nil { - return *st.LootObject.Near, true - } - case st.AreaTrigger != nil: - return st.AreaTrigger.Stand, true + if p, ok := stepPoint(steps[i]); ok { + return p, true } } return dungeon.Point{}, false } -// storedSpec is one stored point to put on the chain. +// stepPoint is a step's stand point: where the step wants the party standing (an object's or +// trigger's stand, else the point the object stands at). +func stepPoint(st dungeon.Step) (dungeon.Point, bool) { + switch { + case st.UseObject != nil: + return standOrNear(st.UseObject.Stand, st.UseObject.Near) + case st.LootObject != nil: + return standOrNear(st.LootObject.Stand, st.LootObject.Near) + case st.Ritual != nil: + return standOrNear(st.Ritual.Stand, &st.Ritual.Position) + case st.Gossip != nil: + return standOrNear(st.Gossip.Stand, &st.Gossip.Near) + case st.AreaTrigger != nil: + return st.AreaTrigger.Stand, true + } + return dungeon.Point{}, false +} + +func standOrNear(stand, near *dungeon.Point) (dungeon.Point, bool) { + if stand != nil { + return *stand, true + } + if near != nil { + return *near, true + } + return dungeon.Point{}, false +} + +// storedSpec is one stored point of the leg's walk; group is the pull group it belongs to +// (its approach points and pull point), -1 for the shared walk (via, to, stands, boss). via +// holds the waypoint a via point was stored from, with its direct/gm_cross/check_skip flags. type storedSpec struct { - at Vec3 - kind string + at Vec3 + kind string + group int + via *dungeon.Waypoint } -// legStored is the leg's own stored points in the order planexport writes the walk: the via -// waypoints, the To, each pull group's approach points and its pull point (the positions' -// centroid, or a hold's spot), the boss's approach points and its position. A leg without a -// boss or To ends its chain at its objective (the last step's point). -func legStored(l *dungeon.Leg, obj dungeon.Point, objKind string) []storedSpec { +// legWalk is the leg's stored walk: its own points in the order planexport writes them — the +// via waypoints, the To, each pull group's approach points and its pull point (the +// positions' centroid, or a hold's spot), the steps' stand points, the boss's approach +// points and position, the after-boss stands. The first point is reached from where the +// previous leg left the party; a leg without any of these has no walk. +func legWalk(l *dungeon.Leg) []storedSpec { var out []storedSpec - add := func(p dungeon.Point, kind string) { - out = append(out, storedSpec{at: vec(p), kind: kind}) + add := func(p dungeon.Point, kind string, group int) *storedSpec { + out = append(out, storedSpec{at: vec(p), kind: kind, group: group}) + return &out[len(out)-1] } - for _, w := range l.Via { - add(w.Point, "via") + for i := range l.Via { + add(l.Via[i].Point, "via", -1).via = &l.Via[i] } if l.To != (dungeon.Point{}) { - add(l.To, "to") + add(l.To, "to", -1) } for gi := range l.PullGroups { g := &l.PullGroups[gi] - for _, a := range g.Approach { - add(a, "approach") + for i := range g.Approach { + add(g.Approach[i], "approach", gi) } if g.Hold != nil { - add(g.Hold.Spot, "hold") + add(g.Hold.Spot, "hold", gi) continue } - add(centroid(g.Positions), "pull") + add(centroid(g.Positions), "pull", gi) + } + for i := range l.Steps { + if p, ok := stepPoint(l.Steps[i]); ok { + add(p, "stand", -1) + } } if l.Boss != nil { - for _, a := range l.Boss.Approach { - add(a, "boss_approach") + for i := range l.Boss.Approach { + add(l.Boss.Approach[i], "boss_approach", -1) } - add(l.Boss.Position, "boss") + add(l.Boss.Position, "boss", -1) } - if objKind == "step" { - add(obj, "objective") + for i := range l.AfterBoss { + if p, ok := stepPoint(l.AfterBoss[i]); ok { + add(p, "stand", -1) + } } return out } @@ -429,14 +727,15 @@ func legStored(l *dungeon.Leg, obj dungeon.Point, objKind string) []storedSpec { // centroid is the mean of the points, as planexport writes a pull row. func centroid(pts []dungeon.Point) dungeon.Point { var cx, cy, cz float64 - for _, p := range pts { - cx, cy, cz = cx+float64(p.X), cy+float64(p.Y), cz+float64(p.Z) + for i := range pts { + cx, cy, cz = cx+float64(pts[i].X), cy+float64(pts[i].Y), cz+float64(pts[i].Z) } n := float64(len(pts)) return dungeon.Point{X: float32(cx / n), Y: float32(cy / n), Z: float32(cz / n)} } -func vec(p dungeon.Point) Vec3 { return Vec3{p.X, p.Y, p.Z} } +func vec(p dungeon.Point) Vec3 { return Vec3{p.X, p.Y, p.Z} } +func point(v Vec3) dungeon.Point { return dungeon.Point{X: v[0], Y: v[1], Z: v[2]} } // tiles is the mmap and mmtile files the report's planning used, with their SHA-256 and // sizes: the two planners' loaded tiles together and the map's own .mmap. A file that cannot @@ -456,7 +755,7 @@ func tiles(p, scratch *navmesh.Planner, dir string, plan *dungeon.Plan) ([]TileF b, err := os.ReadFile(filepath.Join(dir, n)) if err != nil { if first == nil { - first = fmt.Errorf("plan %s tile %s: %w", plan.Name, n, err) + first = fmt.Errorf("tile %s: %w", n, err) } continue } diff --git a/internal/routeoracle/oracle_cgo_test.go b/internal/routeoracle/oracle_cgo_test.go index 2eb6b93e..e0f4f9b9 100644 --- a/internal/routeoracle/oracle_cgo_test.go +++ b/internal/routeoracle/oracle_cgo_test.go @@ -4,6 +4,7 @@ package routeoracle import ( "encoding/json" + "strings" "testing" "github.com/pjerra/turtle-agent/internal/dungeon" @@ -11,94 +12,187 @@ import ( "github.com/pjerra/turtle-agent/internal/navmesh/navfixture" ) -// The oracle on a synthetic mesh (navfixture's L corridor): a row along x from 10 to 50 at -// y 10..14, then a column up y to 50 at x 46..50. Start (12, 12), goal (48, 48); the live -// walk is 68 yd, the straight stored line 50.9 yd. No real mmaps are needed. -const corridorPlan = `{"version":1,"name":"corridor","map":0,"entrance":{"x":12,"y":12,"z":0},"legs":[ - {"name":"hall","to":{"x":46,"y":12,"z":0},"via":[{"x":22,"y":12,"z":0},{"x":30,"y":12,"z":0,"direct":true}], - "pull_groups":[{"entries":[100,101],"positions":[{"x":30,"y":12,"z":0}]}, - {"entries":[102],"positions":[{"x":200,"y":200,"z":0}]}]}, - {"name":"boss","to":{"x":48,"y":48,"z":0},"boss":{"entry":200,"position":{"x":48,"y":48,"z":0}}}]}` +// The fixtures are synthetic meshes (navfixture's L corridor: a row along x from 10 to 50 at +// y 10..14, then a column up y to 50 at x 46..50) plus, where a test needs one, a detached +// island of cells at (100..120). No real mmaps are needed. +const walkPlan = `{"version":1,"name":"corridor","map":0,"entrance":{"x":12,"y":12,"z":0},"legs":[ + {"name":"hall","to":{"x":46,"y":12,"z":0}, + "via":[{"x":22,"y":12,"z":0},{"x":30,"y":12,"z":0,"direct":true,"check_skip":"fixture"}], + "pull_groups":[{"entries":[100],"positions":[{"x":44,"y":12,"z":0}]}]}, + {"name":"boss","to":{"x":48,"y":30,"z":0}, + "pull_groups":[{"entries":[103],"approach":[{"x":48,"y":20,"z":0}],"positions":[{"x":48,"y":40,"z":0}]}], + "boss":{"entry":200,"position":{"x":48,"y":48,"z":0}}}, + {"name":"voidpull","to":{"x":48,"y":48,"z":0}, + "pull_groups":[{"entries":[102],"positions":[{"x":200,"y":200,"z":0}]}]}, + {"name":"after","to":{"x":46,"y":12,"z":0}, + "pull_groups":[{"entries":[105],"positions":[{"x":46,"y":12,"z":0}]}]}]}` -func corridorFixture(t *testing.T) (*navmesh.Planner, string, *dungeon.Plan) { +// A second plan whose third leg walks to a detached island: the hop fails PARTIAL, and the +// hop after it is planned on from the point the partial path reached. +const islandPlan = `{"version":1,"name":"island","map":0,"entrance":{"x":12,"y":12,"z":0},"legs":[ + {"name":"hall","to":{"x":46,"y":12,"z":0}, + "pull_groups":[{"entries":[100],"positions":[{"x":44,"y":12,"z":0}]}]}, + {"name":"island","to":{"x":102,"y":102,"z":0}, + "via":[{"x":48,"y":20,"z":0}], + "pull_groups":[{"entries":[104],"positions":[{"x":110,"y":110,"z":0}]}]}, + {"name":"after","to":{"x":46,"y":12,"z":0}, + "pull_groups":[{"entries":[105],"positions":[{"x":46,"y":12,"z":0}]}]}]}` + +func oracleFixture(t *testing.T, plan string, extra ...navfixture.Poly) (*navmesh.Planner, string, *dungeon.Plan) { t.Helper() - dir := navfixture.WriteDir(t, 0, navfixture.LCorridor(0)) + polys := navfixture.LCorridor(0) + polys = append(polys, extra...) + dir := navfixture.WriteDir(t, 0, polys) p := navmesh.NewPlanner(dir) t.Cleanup(p.Close) - plan, err := dungeon.Parse("corridor.json", []byte(corridorPlan)) + name := "corridor.json" + if strings.Contains(plan, `"name":"island"`) { + name = "island.json" + } + dp, err := dungeon.Parse(name, []byte(plan)) if err != nil { t.Fatalf("Parse: %v", err) } - return p, dir, plan + return p, dir, dp } -func TestOracleOnTheCorridorFixture(t *testing.T) { - p, dir, plan := corridorFixture(t) +// The walk the oracle plans is the driver's own, hop by hop: the boss leg holds its To, the +// pull group's approach point and pull centre, and the boss, and every hop of it plans +// complete, so the leg's stored length and its live length agree (ratio ~1). +func TestOraclePlansTheDriversWalk(t *testing.T) { + p, dir, plan := oracleFixture(t, walkPlan) rep, err := Oracle(p, dir, plan) if err != nil { t.Fatalf("Oracle: %v", err) } - if rep.Plan != "corridor" || rep.Map != 0 || len(rep.Legs) != 2 { - t.Fatalf("report header: %+v", rep) + if len(rep.Legs) != 4 { + t.Fatalf("legs: %d", len(rep.Legs)) } - // Both legs plan complete; the second starts where the first ended. - for i, want := range []string{"to", "boss"} { - lr := rep.Legs[i] - if lr.Objective != want { - t.Errorf("leg %d: objective %q, want %q", i, lr.Objective, want) - } - if lr.Avoid.Status != string(navmesh.Complete) || lr.Plain.Status != string(navmesh.Complete) { - t.Errorf("leg %d: statuses plain %s avoid %s, want complete", i, lr.Plain.Status, lr.Avoid.Status) + // Leg 0: the hall with its via points; hop 2's via is direct with the plan's own + // check_skip, so the oracle does not walk it on the mesh (skipped) and the hop after it + // plans on from the skipped point. + lr := rep.Legs[0] + if lr.Status != "complete" || lr.Objective != "to" { + t.Errorf("leg 0: status %s objective %s", lr.Status, lr.Objective) + } + if len(lr.Segments) != 4 { + t.Fatalf("leg 0 hops: %d", len(lr.Segments)) + } + wantStatus := []string{"complete", "skipped", "complete", "complete"} + for i := range lr.Segments { + s := lr.Segments[i] + if s.NonComparable || s.Avoid.Status != wantStatus[i] { + t.Errorf("leg 0 hop %d: status %s comparable %v, want %s", i+1, s.Avoid.Status, !s.NonComparable, wantStatus[i]) } - if lr.Plain.EndGapYd > 0.5 || lr.Avoid.EndGapYd > 0.5 { - t.Errorf("leg %d: end gaps %v/%v, want ~0 on a complete path", i, lr.Plain.EndGapYd, lr.Avoid.EndGapYd) + } + // The skipped hop is the leg's break waypoint: listed (not walked on the mesh), and + // touched — the route brings the party onto its foot, from where the direct step + // launches. + if len(lr.Breaks) != 1 || lr.Breaks[0].Kind != "direct,check_skip" || !lr.Breaks[0].Touched { + t.Errorf("leg 0 breaks: %+v", lr.Breaks) + } + // Leg 1: the boss leg with To and pulls — the case the review named. Its hops are the + // driver's sequence (to, approach, pull, boss), all complete, so the stored hops and the + // live route agree; a report that planned the boss directly instead would leave the + // stored chain (which holds the to, the approach and the pull) far longer than the live + // walk and fail this bound. + br := rep.Legs[1] + if br.Status != "complete" || br.Objective != "boss" { + t.Errorf("leg 1: status %s objective %s", br.Status, br.Objective) + } + kinds := []string{} + for i := range br.Segments { + kinds = append(kinds, br.Segments[i].Kind) + if s := br.Segments[i]; s.NonComparable || s.Avoid.Status != "complete" { + t.Errorf("leg 1 hop %d: %+v", i+1, s) } } - if got := rep.Legs[1].From; got != (dungeon.Point{X: 46, Y: 12}) { - t.Errorf("leg 1 from %v, want the first leg's objective (46, 12)", got) + if strings.Join(kinds, ",") != "to,approach,pull,boss" { + t.Errorf("leg 1 hop kinds %v", kinds) } - // The corridor membership of the two pull groups: the first's position is on the path, - // the second's is in the void far from it. - pulls := rep.Legs[0].Pulls - if len(pulls) != 2 || !pulls[0].OnPath || pulls[1].OnPath { - t.Errorf("pull groups on path: %+v", pulls) + if br.Ratio < 0.9 || br.Ratio > 1.15 { + t.Errorf("leg 1 ratio %v, want ~1 (the live route goes through the stored points)", br.Ratio) } - if pulls[0].NearYd > 1 || pulls[1].NearYd < 100 { - t.Errorf("pull group distances: %v %v", pulls[0].NearYd, pulls[1].NearYd) + if br.StoredMeanYd > 1 { + t.Errorf("leg 1 stored mean %.1f yd, want ~0 (the route visits every stored point)", br.StoredMeanYd) } - // The direct via lies on the path: touched. The leg's stored chain runs along the - // corridor but detours to the second group's pull point in the void (200, 200) and back, - // so the stored length dwarfs the live one: a plan's stored points really are walked in - // the order they are stored. - lr := rep.Legs[0] - if lr.BreaksTouched != 1 || len(lr.Breaks) != 1 || lr.Breaks[0].Kind != "direct" { - t.Errorf("leg 0 breaks: %+v", lr.Breaks) + // Leg 2's single hop to the void pull centre (200, 200) finds no mesh there: no_path, + // and it fails the leg. + vp := rep.Legs[2] + if vp.Status != string(navmesh.NoPath) { + t.Errorf("leg 2 status %s, want no_path", vp.Status) } - if lr.Ratio < 5 { - t.Errorf("leg 0 ratio %v, want far above 1 (the void pull point is on the stored chain)", lr.Ratio) + if len(vp.Segments) != 2 || vp.Segments[1].Kind != "pull" || vp.Segments[1].Avoid.Status != string(navmesh.NoPath) || vp.Segments[1].NonComparable { + t.Errorf("leg 2 hops: %+v", vp.Segments) } - for _, s := range lr.Stored { - want := s.Kind != "pull" || s.Seq == 4 // the first group's pull point is on the path - if s.Redundant != want { - t.Errorf("leg 0 stored point %d (%s): redundant %v, want %v (%.1f yd)", s.Seq, s.Kind, s.Redundant, want, s.NearYd) - } + // Leg 3 comes after a failed leg: indeterminate, nothing planned for it — the numbers a + // report that advanced prev anyway would print are not here. + af := rep.Legs[3] + if af.Status != "indeterminate" || len(af.Segments) != 0 || af.Avoid.LiveLenYd != 0 { + t.Errorf("leg 3: %+v", af) } - // The boss leg's stored points: its to and its boss, both the same point at the path's end. - if got := rep.Legs[1].Stored; len(got) != 2 || got[0].Kind != "to" || got[1].Kind != "boss" { - t.Errorf("leg 1 stored points: %+v", got) + if !strings.Contains(af.Reason, "leg 2 (voidpull) failed") { + t.Errorf("leg 3 reason %q", af.Reason) } - // No jumps on flat ground, and the tile file is named and hashed. - if len(rep.Legs[0].ZJumps) != 0 { - t.Errorf("leg 0 z jumps: %+v", rep.Legs[0].ZJumps) + // The aggregates are the complete legs' only: mean ratio and the pack counts of legs 0-1. + s := rep.Summary() + if !strings.Contains(s, "2 complete, 1 failed, 1 indeterminate") { + t.Errorf("summary %q", s) } + // The tile file is named and hashed. if len(rep.Tiles) != 2 || rep.Tiles[0].Name != "000.mmap" || len(rep.Tiles[0].Sha256) != 64 { t.Errorf("tiles: %+v", rep.Tiles) } } -// The corridor membership and the break proximity of points against a really planned path. -func TestCorridorAndBreaksAgainstAPlannedPath(t *testing.T) { - p, _, _ := corridorFixture(t) +// A partial hop fails its leg; the hop after it is planned on from the point the partial +// path reached and marked non-comparable, and the legs after the leg are indeterminate. +func TestOracleContinuesPastAPartialHop(t *testing.T) { + p, dir, plan := oracleFixture(t, islandPlan, navfixture.Square(100, 100, 120, 120, 0)) + rep, err := Oracle(p, dir, plan) + if err != nil { + t.Fatalf("Oracle: %v", err) + } + if len(rep.Legs) != 3 { + t.Fatalf("legs: %d", len(rep.Legs)) + } + if rep.Legs[0].Status != "complete" { + t.Errorf("leg 0 status %s", rep.Legs[0].Status) + } + il := rep.Legs[1] + if il.Status != string(navmesh.Partial) { + t.Fatalf("leg 1 status %s, want partial", il.Status) + } + if len(il.Segments) != 3 { + t.Fatalf("leg 1 hops: %d", len(il.Segments)) + } + if il.Segments[0].NonComparable || il.Segments[0].Avoid.Status != "complete" { + t.Errorf("leg 1 hop 1: %+v", il.Segments[0]) + } + f := il.Segments[1] // the failing hop: to the island, partial + if f.NonComparable || f.Kind != "to" || f.Avoid.Status != string(navmesh.Partial) { + t.Errorf("leg 1 hop 2: %+v", f) + } + c := il.Segments[2] // planned on from the partial path's end + if !c.NonComparable || c.From != il.Segments[1].Avoid.End { + t.Errorf("leg 1 hop 3: %+v (want it planned on from hop 2's path end %v)", c, il.Segments[1].Avoid.End) + } + if !strings.Contains(il.Reason, "hop 2 (to) is partial") { + t.Errorf("leg 1 reason %q", il.Reason) + } + af := rep.Legs[2] + if af.Status != "indeterminate" || !strings.Contains(af.Reason, "leg 1 (island) failed") { + t.Errorf("leg 2: %+v", af) + } + // The failed leg scores nothing: the plan's mean ratio is leg 0's alone. + if !strings.Contains(rep.Summary(), "1 complete, 1 failed, 1 indeterminate") { + t.Errorf("summary %q", rep.Summary()) + } +} + +// The corridor membership and the break proximity of points against a really planned route. +func TestCorridorAndBreaksAgainstAPlannedRoute(t *testing.T) { + p, _, _ := oracleFixture(t, walkPlan) aware, err := p.Plan(0, Vec3{12, 12, 0}, Vec3{48, 48, 0}) if err != nil { t.Fatalf("Plan: %v", err) @@ -114,7 +208,6 @@ func TestCorridorAndBreaksAgainstAPlannedPath(t *testing.T) { }{ {"on the corridor", Vec3{12, 30, 0}, 25, true}, {"a pack's radius inside it", Vec3{14, 30, 0}, 10, true}, - {"in the notch, over the slack of a small radius", Vec3{30, 30, 0}, -5, false}, {"a floor above", Vec3{12, 30, 20}, 25, false}, {"in the void", Vec3{200, 200, 0}, 25, false}, } @@ -124,7 +217,7 @@ func TestCorridorAndBreaksAgainstAPlannedPath(t *testing.T) { t.Errorf("%s: on corridor %v, want %v (near %+v)", c.name, got, c.on, near) } } - // A break within BreakNearYd of the path is touched; one in the void is not. + // A break within BreakNearYd of the route is touched; one in the void is not. for _, c := range []struct { p Vec3 touched bool @@ -133,17 +226,17 @@ func TestCorridorAndBreaksAgainstAPlannedPath(t *testing.T) { {Vec3{200, 200, 0}, false}, } { l := dungeon.Leg{Via: []dungeon.Waypoint{{Point: dungeon.Point{X: c.p[0], Y: c.p[1], Z: c.p[2]}, Direct: true}}} - got := breaks(&l, aware) + got := breaksOf(&l, aware.Points, Vec3{}, false) if len(got) != 1 || got[0].Touched != c.touched { t.Errorf("break at %v: %+v, want touched %v", c.p, got, c.touched) } } } -// The ratio of the stored route to the live path on the L corridor: the plan's own points cut -// the corner the walk goes round, so the stored line is shorter. +// The ratio of the stored route to the live route on the L corridor: the plan's own points +// cut the corner the walk goes round, so the stored line is shorter. func TestRatioCutsTheCorner(t *testing.T) { - p, _, _ := corridorFixture(t) + p, _, _ := oracleFixture(t, walkPlan) aware, err := p.Plan(0, Vec3{12, 12, 0}, Vec3{48, 48, 0}) if err != nil { t.Fatalf("Plan: %v", err) @@ -155,15 +248,16 @@ func TestRatioCutsTheCorner(t *testing.T) { } } -// A Partial leg whose end gap ends near a break's foot touches it (the second break rule). +// A partial hop whose path's end stands near a break's foot touches it (the second break +// rule). func TestBreakTouchedByAPartialEndGap(t *testing.T) { // A path that ends short of the goal, 7 yd from the break's foot: within BreakGapYd. - aware := navmesh.Path{Status: navmesh.Partial, Points: []Vec3{{0, 0, 0}, {10, 0, 0}}} + route := []Vec3{{0, 0, 0}, {10, 0, 0}} l := dungeon.Leg{Via: []dungeon.Waypoint{ {Point: dungeon.Point{X: 17, Y: 0, Z: 0}, CheckSkip: "the mesh does not link it"}, {Point: dungeon.Point{X: 40, Y: 0, Z: 0}, CheckSkip: "far"}, }} - got := breaks(&l, aware) + got := breaksOf(&l, route, Vec3{10, 0, 0}, true) if len(got) != 2 { t.Fatalf("breaks: %+v", got) } @@ -177,12 +271,12 @@ func TestBreakTouchedByAPartialEndGap(t *testing.T) { // The report marshals: Stage 1 reads these files as fixtures. func TestReportMarshals(t *testing.T) { - p, dir, plan := corridorFixture(t) + p, dir, plan := oracleFixture(t, walkPlan) rep, err := Oracle(p, dir, plan) if err != nil { t.Fatalf("Oracle: %v", err) } - b, err := json.MarshalIndent(rep, "", " ") + b, err := json.MarshalIndent(rep, "", " ") if err != nil { t.Fatalf("Marshal: %v", err) } diff --git a/internal/routeoracle/oracle_test.go b/internal/routeoracle/oracle_test.go new file mode 100644 index 00000000..2ed450e5 --- /dev/null +++ b/internal/routeoracle/oracle_test.go @@ -0,0 +1,58 @@ +package routeoracle + +import ( + "strings" + "testing" + + "github.com/pjerra/turtle-agent/internal/dungeon" + "github.com/pjerra/turtle-agent/internal/navmesh" +) + +func TestCheckName(t *testing.T) { + cases := []struct { + name string + valid bool + }{ + {"wailing_caverns", true}, + {"a", true}, + {"0", true}, + {"a-b_c9", true}, + {"../escape", false}, + {"../../escape", false}, + {"Wailing", false}, + {"", false}, + {"with space", false}, + {strings.Repeat("a", 41), false}, + {"/etc/passwd", false}, + } + for _, c := range cases { + err := CheckName(c.name) + if (err == nil) != c.valid { + t.Errorf("%q: err %v, want valid %v", c.name, err, c.valid) + } + } +} + +// The oracle refuses a plan whose own name is not a plan name, before anything is planned +// and before any caller could use the name as a path. (A name that passes the check but +// differs from the requested plan is refused by the command, which owns the pairing.) +func TestOracleRejectsABadName(t *testing.T) { + p := navmesh.NewPlanner("") + defer p.Close() + for _, name := range []string{"../escape", "../../etc/hosts", "Bad Name"} { + if _, err := Oracle(p, "", &dungeon.Plan{Name: name}); err == nil || !strings.Contains(err.Error(), "not a plan name") { + t.Errorf("%q: err %v, want a plan-name refusal", name, err) + } + } +} + +// A valid name the report is built from is the one the report carries. +func TestOracleCarriesThePlanName(t *testing.T) { + p := navmesh.NewPlanner("") + defer p.Close() + _, err := Oracle(p, "", &dungeon.Plan{Name: "wailing_caverns"}) + // No mmaps folder: the oracle fails on the first plan, but the refusal is not the name's. + if err == nil || strings.Contains(err.Error(), "not a plan name") { + t.Errorf("err %v", err) + } +} From 25db331fd4a355d68e52cc79efb9819317685379 Mon Sep 17 00:00:00 2001 From: pjerra <71784785+pjerra@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:43:57 +0200 Subject: [PATCH 3/6] route-oracle: --mode bosses plans entrance to each boss live, no hand 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 -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. --- cmd/turtle-agent/route_oracle_bosses_text.go | 128 ++++++ cmd/turtle-agent/route_oracle_cmd.go | 81 +++- cmd/turtle-agent/route_oracle_cmd_test.go | 66 +++ internal/routeoracle/bosses.go | 443 +++++++++++++++++++ internal/routeoracle/bosses_cgo_test.go | 253 +++++++++++ internal/routeoracle/oracle.go | 15 +- 6 files changed, 962 insertions(+), 24 deletions(-) create mode 100644 cmd/turtle-agent/route_oracle_bosses_text.go create mode 100644 internal/routeoracle/bosses.go create mode 100644 internal/routeoracle/bosses_cgo_test.go diff --git a/cmd/turtle-agent/route_oracle_bosses_text.go b/cmd/turtle-agent/route_oracle_bosses_text.go new file mode 100644 index 00000000..05e33e62 --- /dev/null +++ b/cmd/turtle-agent/route_oracle_bosses_text.go @@ -0,0 +1,128 @@ +package main + +import ( + "bytes" + "fmt" + "text/tabwriter" + + "github.com/pjerra/turtle-agent/internal/routeoracle" +) + +// failedLine says why a leg's live plan did not reach its objective and which stored break it +// ran into. +func failedLine(l *routeoracle.BossLeg) string { + s := fmt.Sprintf("leg %d (%s): %s, the live path ends %.1f yd short of the %s at %.1f,%.1f,%.1f (from %.1f,%.1f,%.1f)", + l.Leg, l.Name, l.Status, l.Live.EndGapYd, l.Objective, l.To.X, l.To.Y, l.To.Z, l.From.X, l.From.Y, l.From.Z) + if l.Status == "partial" { + s += fmt.Sprintf("; it stops at %.1f,%.1f,%.1f", l.Live.End.X, l.Live.End.Y, l.Live.End.Z) + } + if len(l.BreakHits) == 0 { + return s + "; no stored break bridges it or lies within 15 yd of the end or of the line to the objective" + } + for _, b := range l.BreakHits { + s += fmt.Sprintf("; stored break via %d (%s) at %.1f,%.1f,%.1f (%s)", b.Via, b.Kind, b.At.X, b.At.Y, b.At.Z, b.Why) + } + return s +} + +// failedBossLegs is the report's failed legs, one line each: the fair failures only (a +// non-comparable continuation is not a finding about the plan). +func failedBossLegs(rep routeoracle.BossesReport) []string { + var out []string + for i := range rep.Legs { + if rep.Legs[i].Failed() { + out = append(out, failedLine(&rep.Legs[i])) + } + } + return out +} + +// bossesText is the plan's human table: the summary, one line per leg, the failed legs with the +// breaks they hit, the non-comparable continuations, then what the complete legs say about the +// stored hand data (packs off the live path, hand points farther than 10 yd, floor jumps). +func bossesText(rep routeoracle.BossesReport) ([]byte, error) { + var buf bytes.Buffer + w := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0) + fmt.Fprintf(w, "%s (map %d, status %q, %d avoid boxes), bosses mode\n", rep.Plan, rep.Map, rep.Status, rep.Avoid) + fmt.Fprintln(w, rep.Summary()) + fmt.Fprintln(w, "each leg is one avoid-aware navmesh plan from the previous objective to this leg's objective: no hand points, no break hops;") + fmt.Fprintln(w, "a leg that does not reach it FAILED and scores nothing; a leg planned from a failed leg's end is non-comparable (NC) and scores nothing") + fmt.Fprintf(w, "plan time %.0f ms\n", rep.PlanMs) + fmt.Fprintln(w, "packs: centre within the pack radius of the live path; hand: via/to/approach/stand points within 10 yd of it (redundant); NC: non-comparable; bridged: the NC leg planned from the previous objective") + fmt.Fprintln(w, "leg\tname\tobjective\tfrom\tto\tstatus\tgap\tlive\tpacks\thand\tzj\tbreaks hit\tNC\tbridged") + for i := range rep.Legs { + l := &rep.Legs[i] + nc := "" + if l.NonComparable { + nc = "NC" + } + if l.Status == "complete" { + fmt.Fprintf(w, "%d\t%s\t%s\t%.0f,%.0f\t%.0f,%.0f\t%s\t%.1f\t%.1f\t%d/%d\t%d/%d\t%d\t-\t%s\t%s\n", + l.Leg, l.Name, l.Objective, l.From.X, l.From.Y, l.To.X, l.To.Y, l.Status, l.Live.EndGapYd, l.Live.LiveLenYd, + l.PacksOnPath, len(l.Packs), l.HandRedundant, len(l.Hand), len(l.ZJumps), nc, bridgedOf(l)) + continue + } + fmt.Fprintf(w, "%d\t%s\t%s\t%.0f,%.0f\t%.0f,%.0f\t%s\t%.1f\t%.1f\t-\t-\t-\t%d\t%s\t%s\n", + l.Leg, l.Name, l.Objective, l.From.X, l.From.Y, l.To.X, l.To.Y, l.Status, l.Live.EndGapYd, l.Live.LiveLenYd, len(l.BreakHits), nc, bridgedOf(l)) + } + for i := range rep.Legs { + l := &rep.Legs[i] + switch { + case l.Failed(): + fmt.Fprintf(w, "FAILED %s\n", failedLine(l)) + if l.Reason != "" { + fmt.Fprintf(w, " planner: %s\n", l.Reason) + } + case l.NonComparable: + fmt.Fprintf(w, "NON-COMPARABLE leg %d (%s): %s, %s: %s\n", l.Leg, l.Name, l.Status, l.NonComparableWhy, l.Reason) + if b := l.Bridged; b != nil && b.Status != "complete" { + fmt.Fprintf(w, " BRIDGED leg %d is a second failure: %s\n", l.Leg, failedLine(b)) + } + } + } + for i := range rep.Legs { + l := &rep.Legs[i] + if !l.Counted() { + continue + } + for _, g := range l.Packs { + if !g.OnPath { + fmt.Fprintf(w, "leg %d (%s): pack group %d (entries %v) centre %.1f yd (dz %.1f) from the live path, radius %.0f yd\n", + l.Leg, l.Name, g.Group, g.Entries, g.NearYd, g.DZYd, g.RadiusYd) + } + } + for _, h := range l.Hand { + if !h.Redundant { + fmt.Fprintf(w, "leg %d (%s): hand %s (seq %d) at %.1f,%.1f,%.1f is %.1f yd from the live path\n", + l.Leg, l.Name, h.Kind, h.Seq, h.At.X, h.At.Y, h.At.Z, h.NearYd) + } + } + for _, b := range l.Breaks { + fmt.Fprintf(w, "leg %d (%s): stored break via %d (%s) at %.1f,%.1f,%.1f: live path passes it %v\n", + l.Leg, l.Name, b.Via, b.Kind, b.At.X, b.At.Y, b.At.Z, b.Touched) + } + for _, j := range l.ZJumps { + fmt.Fprintf(w, "leg %d (%s): z jump %.1f yd at %.1f,%.1f,%.1f\n", l.Leg, l.Name, j.DropYd, j.At[0], j.At[1], j.At[2]) + } + } + fmt.Fprintln(w, "tiles used (sha-256):") + for _, t := range rep.Tiles { + fmt.Fprintf(w, " %s\t%s\t%d bytes\n", t.Name, t.Sha256, t.Bytes) + } + if err := w.Flush(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// bridgedOf is the table cell of a non-comparable leg's bridged re-plan. +func bridgedOf(l *routeoracle.BossLeg) string { + if l.Bridged == nil { + return "-" + } + if l.Bridged.Status == "complete" { + return fmt.Sprintf("complete %.0f yd, packs %d/%d, hand %d/%d", l.Bridged.Live.LiveLenYd, l.Bridged.PacksOnPath, len(l.Bridged.Packs), + l.Bridged.HandRedundant, len(l.Bridged.Hand)) + } + return fmt.Sprintf("%s, %.0f yd short", l.Bridged.Status, l.Bridged.Live.EndGapYd) +} diff --git a/cmd/turtle-agent/route_oracle_cmd.go b/cmd/turtle-agent/route_oracle_cmd.go index cd210dcf..52ade7df 100644 --- a/cmd/turtle-agent/route_oracle_cmd.go +++ b/cmd/turtle-agent/route_oracle_cmd.go @@ -20,7 +20,15 @@ import ( // exitRouteOracle: route-oracle could not report every plan (a failing plan is not a usage error). const exitRouteOracle = 12 -const routeOracleUsage = "usage: turtle-agent route-oracle --plans DIR --mmaps DIR --out DIR [--plan NAME]..." +const routeOracleUsage = "usage: turtle-agent route-oracle --plans DIR --mmaps DIR --out DIR [--mode walk|bosses] [--plan NAME]..." + +// The two modes. walk (the default) plans each plan's own stored sequence hop by hop and +// scores the stored hops against the navmesh; bosses plans entrance -> each leg's objective +// live, with no hand points and no break hops, to see what a boss list alone could do. +const ( + modeWalk = "walk" + modeBosses = "bosses" +) // routeOracleDefaultPlans are the plans the oracle reports when no --plan is given. var routeOracleDefaultPlans = []string{"wailing_caverns", "ragefire_chasm", "uldaman", "zulfarrak", "deadmines"} @@ -49,7 +57,8 @@ func routeOracleCmd(args []string, stdout, stderr io.Writer) int { fs.SetOutput(stderr) plansDir := fs.String("plans", "dungeons", "the folder with the plan files") mmaps := fs.String("mmaps", "", "folder with the server's mmaps (.mmap and *.mmtile); required") - out := fs.String("out", "", "folder for .txt and .json; required") + out := fs.String("out", "", "folder for .txt and .json (-bosses.* in bosses mode); required") + mode := fs.String("mode", modeWalk, "walk: score the plan's stored hops on the navmesh; bosses: plan entrance -> each boss live, with no hand points") var plans []string fs.Var(&planList{&plans}, "plan", "a plan name; repeatable (default: "+strings.Join(routeOracleDefaultPlans, ", ")+")") if err := fs.Parse(args); err != nil { @@ -62,6 +71,10 @@ func routeOracleCmd(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stderr, routeOracleUsage) return exitUsage } + if *mode != modeWalk && *mode != modeBosses { + fmt.Fprintf(stderr, "route-oracle: unknown --mode %q (want %s or %s)\n", *mode, modeWalk, modeBosses) + return exitUsage + } if err := os.MkdirAll(*out, 0o755); err != nil { fmt.Fprintf(stderr, "route-oracle: %v\n", err) return exitUsage @@ -96,19 +109,36 @@ func routeOracleCmd(args []string, stdout, stderr io.Writer) int { continue } t0 := time.Now() - rep, err := routeoracle.Oracle(planner, *mmaps, p) - if err != nil { - fmt.Fprintf(stderr, "route-oracle: %v\n", err) - failed = true - continue - } - if err := writeOracle(rep, *out, n); err != nil { - fmt.Fprintf(stderr, "route-oracle: %v\n", err) - failed = true - continue + var summary string + var tiles []routeoracle.TileFile + if *mode == modeBosses { + rep, err := routeoracle.Bosses(planner, *mmaps, p) + if err == nil { + err = writeBosses(rep, *out, n) + } + if err != nil { + fmt.Fprintf(stderr, "route-oracle: %v\n", err) + failed = true + continue + } + summary, tiles = rep.Summary(), rep.Tiles + for _, f := range failedBossLegs(rep) { + summary += "\n " + f + } + } else { + rep, err := routeoracle.Oracle(planner, *mmaps, p) + if err == nil { + err = writeOracle(rep, *out, n) + } + if err != nil { + fmt.Fprintf(stderr, "route-oracle: %v\n", err) + failed = true + continue + } + summary, tiles = rep.Summary(), rep.Tiles } - fmt.Fprintf(stdout, "%s (%.0f ms wall)\n%s\n", rep.Plan, float64(time.Since(t0).Microseconds())/1000, rep.Summary()) - for _, t := range rep.Tiles { + fmt.Fprintf(stdout, "%s (%.0f ms wall)\n%s\n", p.Name, float64(time.Since(t0).Microseconds())/1000, summary) + for _, t := range tiles { fmt.Fprintf(stdout, " %s %s (%d bytes)\n", t.Name, t.Sha256, t.Bytes) } } @@ -121,6 +151,25 @@ func routeOracleCmd(args []string, stdout, stderr io.Writer) int { // writeOracle writes the plan's .txt and .json into dir, under the validated // CLI name (never the plan JSON's own), and refuses a destination that resolves outside dir. func writeOracle(rep routeoracle.Report, dir, name string) error { + txt, err := oracleText(rep) + if err != nil { + return err + } + return writeReport(rep, txt, dir, name) +} + +// writeBosses writes the plan's -bosses.txt and -bosses.json, under the validated +// CLI name like writeOracle. +func writeBosses(rep routeoracle.BossesReport, dir, name string) error { + txt, err := bossesText(rep) + if err != nil { + return err + } + return writeReport(rep, txt, dir, name+"-bosses") +} + +// writeReport writes .json (the report) and .txt (the table) into dir. +func writeReport(rep any, txt []byte, dir, name string) error { b, err := json.MarshalIndent(rep, "", " ") if err != nil { return err @@ -134,10 +183,6 @@ func writeOracle(rep routeoracle.Report, dir, name string) error { if err := os.WriteFile(filepath.Join(dir, name+".json"), append(b, '\n'), 0o644); err != nil { return err } - txt, err := oracleText(rep) - if err != nil { - return err - } return os.WriteFile(filepath.Join(dir, name+".txt"), txt, 0o644) } diff --git a/cmd/turtle-agent/route_oracle_cmd_test.go b/cmd/turtle-agent/route_oracle_cmd_test.go index 157d9891..3f6d9cfc 100644 --- a/cmd/turtle-agent/route_oracle_cmd_test.go +++ b/cmd/turtle-agent/route_oracle_cmd_test.go @@ -108,3 +108,69 @@ func TestRouteOracleWritesTheFixtureReports(t *testing.T) { } } } + +// An unknown --mode is a usage error, refused before anything is made or planned. +func TestRouteOracleRefusesAnUnknownMode(t *testing.T) { + out := filepath.Join(t.TempDir(), "out") + for _, mode := range []string{"boss", "Walk", "", "walk,bosses"} { + var stdout, stderr bytes.Buffer + code := routeOracleCmd([]string{"--mmaps", "/nowhere", "--out", out, "--mode", mode}, &stdout, &stderr) + if code != exitUsage { + t.Errorf("mode %q: exit %d, want %d (stderr %q)", mode, code, exitUsage, stderr.String()) + } + if !strings.HasPrefix(stderr.String(), "route-oracle: unknown --mode") { + t.Errorf("mode %q: stderr %q", mode, stderr.String()) + } + } + if _, err := os.Stat(out); err == nil { + t.Error("--out was made for a refused mode") + } +} + +// The default mode is walk: without --mode the walk files are written and no bosses file is; +// with --mode bosses the -bosses files are written and the walk files are not. The plan +// has a gap in its mesh, so the bosses report carries a failed leg. +func TestRouteOracleModeDefaultsToWalk(t *testing.T) { + dungeons := t.TempDir() + plan := `{"version":1,"name":"corridor","map":0,"entrance":{"x":12,"y":12,"z":0},"legs":[ + {"name":"hall","to":{"x":46,"y":12,"z":0},"pull_groups":[{"entries":[100],"positions":[{"x":44,"y":12,"z":0}]}]}, + {"name":"far","to":{"x":48,"y":44,"z":0},"boss":{"entry":2,"position":{"x":48,"y":48,"z":0}}}]}` + if err := os.WriteFile(filepath.Join(dungeons, "corridor.json"), []byte(plan), 0o644); err != nil { + t.Fatal(err) + } + var polys []navfixture.Poly + for _, p := range navfixture.LCorridor(0) { + if !(p[0][0] == 46 && p[0][1] == 26) { + polys = append(polys, p) + } + } + mmaps := navfixture.WriteDir(t, 0, polys) + exists := func(dir, f string) bool { _, err := os.Stat(filepath.Join(dir, f)); return err == nil } + + walkOut := t.TempDir() + var stdout, stderr bytes.Buffer + if code := routeOracleCmd([]string{"--plans", dungeons, "--mmaps", mmaps, "--out", walkOut, "--plan", "corridor"}, &stdout, &stderr); code != exitOK { + t.Fatalf("default: exit %d (stderr %q)", code, stderr.String()) + } + if !exists(walkOut, "corridor.json") || !exists(walkOut, "corridor.txt") || exists(walkOut, "corridor-bosses.json") || exists(walkOut, "corridor-bosses.txt") { + t.Errorf("default mode did not write the walk files alone") + } + + bossOut := t.TempDir() + stdout.Reset() + stderr.Reset() + if code := routeOracleCmd([]string{"--plans", dungeons, "--mmaps", mmaps, "--out", bossOut, "--plan", "corridor", "--mode", "bosses"}, &stdout, &stderr); code != exitOK { + t.Fatalf("bosses: exit %d (stderr %q)", code, stderr.String()) + } + if !exists(bossOut, "corridor-bosses.json") || !exists(bossOut, "corridor-bosses.txt") || exists(bossOut, "corridor.json") || exists(bossOut, "corridor.txt") { + t.Errorf("bosses mode did not write the -bosses files alone") + } + // The failed leg is listed with its reason on stdout and in the table. + if !strings.Contains(stdout.String(), "1 complete, 1 failed") || !strings.Contains(stdout.String(), "leg 1 (far): partial") { + t.Errorf("stdout %q", stdout.String()) + } + txt, err := os.ReadFile(filepath.Join(bossOut, "corridor-bosses.txt")) + if err != nil || !strings.Contains(string(txt), "FAILED leg 1 (far)") { + t.Errorf("table: err %v\n%s", err, txt) + } +} diff --git a/internal/routeoracle/bosses.go b/internal/routeoracle/bosses.go new file mode 100644 index 00000000..3b9655e5 --- /dev/null +++ b/internal/routeoracle/bosses.go @@ -0,0 +1,443 @@ +package routeoracle + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/pjerra/turtle-agent/internal/dungeon" + "github.com/pjerra/turtle-agent/internal/navmesh" +) + +// The bosses mode asks a different question from the walk mode. The walk mode proves the +// plan's stored hops are walkable. The bosses mode asks whether a route could be planned LIVE +// from a boss list alone: each leg is one navmesh plan from where the party stands to the leg's +// objective (its boss, else its to/last point), avoid-aware, with NONE of the plan's hand +// points and NONE of its break hops. Whatever the mesh cannot reach that way is where hand data +// is still needed, and the report names the stored break that sits there. + +const ( + // HandNearYd is how close a stored hand point (via, approach, stand) must lie to the live + // path to be called redundant: the live path already passes it. + HandNearYd = 10.0 + // BreakHitYd is how close a stored break must lie to where the live path ends, or to the + // line from there to the objective, to be named as the break the live plan ran into. + BreakHitYd = 15.0 +) + +// BossLeg is one leg's live planning, entrance (or the previous leg's end) to its objective. +type BossLeg struct { + Leg int `json:"leg"` + Name string `json:"name,omitempty"` + From dungeon.Point `json:"from"` // where the live plan starts + To dungeon.Point `json:"to"` // the leg's objective + // Objective says what the objective is: boss, to, step, or none. + Objective string `json:"objective"` + // Status: complete (the mesh reaches the objective), partial or no_path (it does not: the + // leg is FAILED), or no_objective (nothing stored to plan to). + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + // NonComparable: the leg started from the point a failed leg's path reached, not from the + // previous objective. Its result is a continuation, labelled and excluded from every + // aggregate: it says nothing about the plan's own journey. + NonComparable bool `json:"non_comparable,omitempty"` + NonComparableWhy string `json:"non_comparable_why,omitempty"` + // Live is the avoid-aware run. End is where the party stands: the objective on a complete + // path, the nearest reachable point on a partial one. + Live Run `json:"live"` + PlanMs float64 `json:"plan_ms"` + + // BreakHits (failed legs): the stored breaks the live plan ran into: those that bridge the + // gap (the mesh reaches the hop's foot from the start and the objective from its landing) + // and those lying within BreakHitYd of the live end or of the line from it to the + // objective. + BreakHits []BreakHit `json:"break_hits,omitempty"` + // Bridged (a non-comparable leg only): the same leg planned from the previous leg's + // OBJECTIVE, as if the hand data had crossed the break before it. It is the answer to "what + // else does the mesh not reach", and enters no primary aggregate (see Totals.Bridged). + Bridged *BossLeg `json:"bridged,omitempty"` + // Breaks: all the leg's stored breaks, and whether the live path passes one (within + // BreakNearYd), which a complete leg shows the mesh needs no hop for. + Breaks []BreakTouched `json:"breaks,omitempty"` + + // The corridor metrics, complete legs only. + Packs []PullOnPath `json:"packs,omitempty"` + PacksOnPath int `json:"packs_on_path"` + Hand []HandPoint `json:"hand,omitempty"` + HandRedundant int `json:"hand_redundant"` + ZJumps []ZJump `json:"z_jumps,omitempty"` +} + +// BreakHit is a stored break a failed live plan ran into. +type BreakHit struct { + Via int `json:"via"` // the waypoint's index in the leg's via, from 0 + Kind string `json:"kind"` // exact, direct, check_skip, gm_cross (comma joined) + At dungeon.Point `json:"at"` + // EndYd and LineYd: the break's distance to where the live path ends, and to the line from + // there to the objective. + EndYd float64 `json:"end_yd"` + LineYd float64 `json:"line_yd"` + // Bridges: the mesh reaches the hop's foot (the previous via, or the leg's start) and the + // objective from the hop's landing, so this hop is what the live plan lacks. + Bridges bool `json:"bridges"` + // Why says which of the rules named it. + Why string `json:"why"` +} + +// HandPoint is a stored hand point of a complete leg (a via, approach, boss approach or stand +// that is not a break and not the objective) and how far the live path passes from it. +type HandPoint struct { + Seq int `json:"seq"` + Kind string `json:"kind"` + At dungeon.Point `json:"at"` + NearYd float64 `json:"near_yd"` + Redundant bool `json:"redundant"` // within HandNearYd of the live path +} + +// BossesReport is one plan's bosses-mode report. +type BossesReport struct { + Plan string `json:"plan"` + Map uint32 `json:"map"` + Status string `json:"status,omitempty"` + Avoid int `json:"avoid_boxes"` + Legs []BossLeg `json:"legs"` + PlanMs float64 `json:"plan_ms"` + Tiles []TileFile `json:"tiles"` +} + +// Failed is a leg the live plan could not finish and that is a fair test: it started where the +// plan's own journey does. +func (l *BossLeg) Failed() bool { + return !l.NonComparable && (l.Status == string(navmesh.Partial) || l.Status == string(navmesh.NoPath)) +} + +// Counted is a leg whose metrics enter the aggregates: complete and comparable. +func (l *BossLeg) Counted() bool { return l.Status == "complete" && !l.NonComparable } + +// Totals are the aggregates of a bosses report, over the counted legs only. +type Totals struct { + Legs, Complete, Failed, NoObjective int + Continuations, ContinuationsOK int + Sum Sums // the counted legs + // Bridged: the counted legs plus the bridged re-plans of the non-comparable ones (each + // planned from the previous objective): what the mesh reaches when the hand data crosses + // every break it must. Never mixed into the primary numbers. + BridgedComplete, BridgedFailed int + Bridged Sums +} + +// Sums are the metric sums of a set of complete legs. +type Sums struct { + Packs, PacksOn, PacksCorridor int + Hand, HandRedundant int + ZJumps int + LiveYd float64 +} + +func (s *Sums) add(l *BossLeg) { + s.Packs += len(l.Packs) + s.PacksOn += l.PacksOnPath + for i := range l.Packs { + if l.Packs[i].InCorridor { + s.PacksCorridor++ + } + } + s.Hand += len(l.Hand) + s.HandRedundant += l.HandRedundant + s.ZJumps += len(l.ZJumps) + s.LiveYd += l.Live.LiveLenYd +} + +// Totals sums the report. A failed leg and a non-comparable continuation add nothing to the +// primary sums. +func (r BossesReport) Totals() Totals { + t := Totals{Legs: len(r.Legs)} + for i := range r.Legs { + l := &r.Legs[i] + switch { + case l.Status == "no_objective": + t.NoObjective++ + case l.NonComparable: + t.Continuations++ + if l.Status == "complete" { + t.ContinuationsOK++ + } + if b := l.Bridged; b != nil { + if b.Status == "complete" { + t.BridgedComplete++ + t.Bridged.add(b) + } else { + t.BridgedFailed++ + } + } + case l.Failed(): + t.Failed++ + t.BridgedFailed++ + case l.Status == "complete": + t.Complete++ + t.BridgedComplete++ + t.Sum.add(l) + t.Bridged.add(l) + } + } + return t +} + +func pct(a, b int) float64 { + if b == 0 { + return 0 + } + return 100 * float64(a) / float64(b) +} + +// Summary is the plan's one summary line. +func (r BossesReport) Summary() string { + t := r.Totals() + if t.Legs == 0 { + return fmt.Sprintf("%s: map %d, no legs", r.Plan, r.Map) + } + s := fmt.Sprintf("%s: map %d, %d legs: %d complete, %d failed", r.Plan, r.Map, t.Legs, t.Complete, t.Failed) + if t.Continuations > 0 { + s += fmt.Sprintf(", %d non-comparable (%d reach their objective)", t.Continuations, t.ContinuationsOK) + } + if t.NoObjective > 0 { + s += fmt.Sprintf(", %d without an objective", t.NoObjective) + } + if t.Sum.Packs > 0 { + s += fmt.Sprintf("; packs on path %d/%d (%.0f%%), in corridor %d (%.0f%%)", t.Sum.PacksOn, t.Sum.Packs, pct(t.Sum.PacksOn, t.Sum.Packs), + t.Sum.PacksCorridor, pct(t.Sum.PacksCorridor, t.Sum.Packs)) + } + if t.Sum.Hand > 0 { + s += fmt.Sprintf("; hand points redundant %d/%d (%.0f%%)", t.Sum.HandRedundant, t.Sum.Hand, pct(t.Sum.HandRedundant, t.Sum.Hand)) + } + s += fmt.Sprintf("; z jumps %d; live %.0f yd; plan %.0f ms", t.Sum.ZJumps, t.Sum.LiveYd, r.PlanMs) + if t.Continuations > 0 { + s += fmt.Sprintf("; bridged (non-comparable legs planned from the previous objective): %d complete, %d failed", t.BridgedComplete-t.Complete, t.BridgedFailed-t.Failed) + } + return s +} + +// Bosses plans each leg of the plan live: from where the party stands straight to the leg's +// objective on a scratch planner with the plan's avoid boxes, no hand points and no break hops. +// The party starts at the entrance; a leg that reaches its objective leaves it standing there. +// +// A leg whose plan does not reach the objective FAILED (partial or no_path): it names the stored +// breaks it ran into, and adds nothing to any aggregate. The leg after it starts from the last +// point the failed path REACHED, which is not where the plan's journey goes on: that leg is +// labelled non-comparable and is excluded as well, until a leg completes and puts the party on +// a real objective again. Such a leg is also planned a second time from the previous leg's +// objective (Bridged), to show what else the mesh does not reach once the break is crossed by +// hand data; that is reported beside the primary numbers, never in them. +// +// A planner error (no folder, no engine, a broken tile) is returned, not reported. +func Bosses(p *navmesh.Planner, mmapsDir string, plan *dungeon.Plan) (BossesReport, error) { + if err := CheckName(plan.Name); err != nil { + return BossesReport{}, fmt.Errorf("plan file: %w", err) + } + rep := BossesReport{Plan: plan.Name, Map: plan.Map, Status: plan.Status, Avoid: len(plan.Avoid)} + scratch := p.Scratch() + defer scratch.Close() + scratch.SetAvoid(plan.Map, dungeon.NavBoxes(plan.Avoid)) + + cur := vec(plan.Entrance) // where the party stands + anchor := vec(plan.Entrance) // the previous objective: where the plan's journey stands + lost := "" // why the party is not on the plan's journey: the last failed leg + for i := range plan.Legs { + l := &plan.Legs[i] + obj, kind := legObjective(l) + if kind == "none" { + rep.Legs = append(rep.Legs, BossLeg{Leg: i, Name: l.Name, From: point(cur), To: obj, Objective: kind, Status: "no_objective"}) + continue + } + bl, end, err := planBossLeg(scratch, plan, i, cur, kind) + if err != nil { + return rep, err + } + if lost != "" { + bl.NonComparable = true + bl.NonComparableWhy = "planned from " + lost + ", not from the previous objective" + if anchor != cur { + b, _, err := planBossLeg(scratch, plan, i, anchor, kind) + if err != nil { + return rep, err + } + bl.Bridged = &b + } + } + if bl.Status == string(navmesh.Complete) { + cur, lost = end, "" + } else { + lost = fmt.Sprintf("where leg %d (%s) ended: %s, %.1f yd short of its objective", i, l.Name, fmtPos(end), dist3(end, vec(obj))) + cur = end + } + anchor = vec(obj) + rep.PlanMs += bl.PlanMs + rep.Legs = append(rep.Legs, bl) + } + t, err := tiles(p, scratch, mmapsDir, plan) + rep.Tiles = t + return rep, err +} + +// planBossLeg plans one leg from a start to its objective and measures the result: a complete +// path gets the corridor metrics; a failed one gets the stored breaks it ran into. end is where +// the party stands afterwards. +func planBossLeg(sc *navmesh.Planner, plan *dungeon.Plan, i int, from Vec3, kind string) (BossLeg, Vec3, error) { + l := &plan.Legs[i] + obj, _ := legObjective(l) + bl := BossLeg{Leg: i, Name: l.Name, From: point(from), To: obj, Objective: kind} + t0 := time.Now() + path, err := sc.Plan(plan.Map, from, vec(obj)) + if err != nil { + return bl, from, fmt.Errorf("plan %s leg %d (%s): %w", plan.Name, i, l.Name, err) + } + bl.PlanMs = float64(time.Since(t0).Microseconds()) / 1000 + bl.Status = string(path.Status) + bl.Live = runOf(path, vec(obj)) + bl.Reason = firstLine(path.Reason) + end := pathEndOr(path, from) + if path.Status == navmesh.Complete { + measureBossLeg(&bl, l, path, obj) + return bl, end, nil + } + bl.Breaks = breaksOf(l, path.Points, end, path.Status == navmesh.Partial) + bl.BreakHits, err = breakHits(sc, plan.Map, l, from, end, vec(obj)) + if err != nil { + return bl, end, fmt.Errorf("plan %s leg %d (%s): %w", plan.Name, i, l.Name, err) + } + return bl, end, nil +} + +// measureBossLeg fills the corridor metrics of a leg whose live path completed: the packs the +// path passes, the hand points it makes redundant, the floor jumps, the breaks it passes. +func measureBossLeg(bl *BossLeg, l *dungeon.Leg, path navmesh.Path, obj dungeon.Point) { + route := path.Points + bl.Packs = packsOnRoute(l, route) + for i := range bl.Packs { + if bl.Packs[i].OnPath { + bl.PacksOnPath++ + } + } + bl.Hand = handPoints(l, route, obj) + for i := range bl.Hand { + if bl.Hand[i].Redundant { + bl.HandRedundant++ + } + } + bl.ZJumps = ZJumps(PathSamples(path)) + bl.Breaks = breaksOf(l, route, Vec3{}, false) +} + +// packsOnRoute says, for each pull group, whether its centre lies within the group's pack +// radius of the route (horizontally, and no more than CorridorMaxDZYd above or below it). The +// centre is the one the driver pulls at: the positions' centroid, or a hold's spot. +func packsOnRoute(l *dungeon.Leg, route []Vec3) []PullOnPath { + var out []PullOnPath + for gi := range l.PullGroups { + g := &l.PullGroups[gi] + radius := g.Radius + if radius <= 0 { + radius = DefaultPackRadius + } + pg := PullOnPath{Group: gi, RadiusYd: float64(radius), Entries: g.Entries} + centre := centroid(g.Positions) + if g.Hold != nil { + centre = g.Hold.Spot + } + if len(g.Positions) > 0 || g.Hold != nil { + near := NearestPolyline(route, vec(centre)) + pg.NearYd, pg.DZYd = near.Dist2D, near.DZ + pg.OnPath = near.Dist3D >= 0 && near.Dist2D <= float64(radius) && near.DZ <= CorridorMaxDZYd + pg.InCorridor = OnCorridor(near, float64(radius)) + } + out = append(out, pg) + } + return out +} + +// handPoints lists the leg's stored hand points (via, to, approach, boss approach, stand) and +// how far the live route passes from each. A break waypoint is not one (it is the break, listed +// on its own) and neither is the objective itself. +func handPoints(l *dungeon.Leg, route []Vec3, obj dungeon.Point) []HandPoint { + var out []HandPoint + for j, w := range legWalk(l) { + switch w.kind { + case "via", "to", "approach", "boss_approach", "stand": + default: + continue + } + if w.via != nil && breakKind(*w.via) != "" { + continue + } + if w.at == vec(obj) { + continue + } + near := NearestPolyline(route, w.at) + out = append(out, HandPoint{Seq: j + 1, Kind: w.kind, At: point(w.at), NearYd: near.Dist3D, + Redundant: near.Dist3D >= 0 && near.Dist3D <= HandNearYd}) + } + return out +} + +// breakHits lists the stored breaks a failed live plan ran into. A break hop (a via that is a +// direct step, a gm_cross landing or a check_skip) is named when +// - it bridges: the mesh reaches the hop's foot (the previous via, else the leg's start) from +// the start and the objective from the hop's landing, so the missing link is that hop; or +// - it lies within BreakHitYd of where the live path ended, or of the line from there to the +// objective (the break sits at the end of the reachable ground). +// +// The exact via before a named hop is the hop's foot and is named with it. +func breakHits(sc *navmesh.Planner, mapID uint32, l *dungeon.Leg, start, end, obj Vec3) ([]BreakHit, error) { + var out []BreakHit + line := []Vec3{end, obj} + named := map[int]bool{} + for vi := range l.Via { + w := l.Via[vi] + kind := breakKind(w) + if kind == "" || (!w.Direct && !w.GMCross && w.CheckSkip == "") { + continue + } + at := vec(w.Point) + foot := start + if vi > 0 { + foot = vec(l.Via[vi-1].Point) + } + h := BreakHit{Via: vi, Kind: kind, At: w.Point, EndYd: dist3(end, at), LineYd: NearestPolyline(line, at).Dist3D} + var why []string + toFoot, err := sc.Plan(mapID, start, foot) + if err != nil { + return nil, err + } + if toFoot.Status == navmesh.Complete { + fromLanding, err := sc.Plan(mapID, at, obj) + if err != nil { + return nil, err + } + if fromLanding.Status == navmesh.Complete { + h.Bridges = true + why = append(why, fmt.Sprintf("the mesh reaches its foot %s from the start and the objective from its landing", fmtPos(foot))) + } + } + if h.EndYd <= BreakHitYd || h.LineYd <= BreakHitYd { + why = append(why, fmt.Sprintf("it lies %.1f yd from the live end and %.1f yd from the line to the objective", h.EndYd, h.LineYd)) + } + if len(why) == 0 { + continue + } + h.Why = strings.Join(why, "; ") + named[vi] = true + out = append(out, h) + } + // The exact foot before a named hop. + for vi := range l.Via { + if vi+1 < len(l.Via) && named[vi+1] && l.Via[vi].Exact && !named[vi] { + w := l.Via[vi] + at := vec(w.Point) + out = append(out, BreakHit{Via: vi, Kind: breakKind(w), At: w.Point, EndYd: dist3(end, at), + LineYd: NearestPolyline(line, at).Dist3D, Why: fmt.Sprintf("the exact foot of via %d's hop", vi+1)}) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Via < out[j].Via }) + return out, nil +} diff --git a/internal/routeoracle/bosses_cgo_test.go b/internal/routeoracle/bosses_cgo_test.go new file mode 100644 index 00000000..be51e9b4 --- /dev/null +++ b/internal/routeoracle/bosses_cgo_test.go @@ -0,0 +1,253 @@ +//go:build cgo + +package routeoracle + +import ( + "strings" + "testing" + + "github.com/pjerra/turtle-agent/internal/dungeon" + "github.com/pjerra/turtle-agent/internal/navmesh" + "github.com/pjerra/turtle-agent/internal/navmesh/navfixture" +) + +// gapMesh is the L corridor with the column cell y 26..30 taken out: the mesh has a break at +// (48, 28) that only a hand hop (a direct step or a GM) crosses. +func gapMesh() []navfixture.Poly { + var out []navfixture.Poly + for _, p := range navfixture.LCorridor(0) { + if p[0][0] == 46 && p[0][1] == 26 { + continue + } + out = append(out, p) + } + return out +} + +// Leg 2 walks past the gap to the boss at (48, 48). Its stored via at (48, 34) is the break the +// plan crosses (check_skip, so the walk mode skips it); the exact and direct vias at (20, 12) +// and (36, 12) are breaks far from where the live path stops. Leg 3 goes back to (46, 12) from +// where leg 2's path stopped; leg 4 goes on from leg 3's objective. +const gapPlan = `{"version":1,"name":"gap","map":0,"entrance":{"x":12,"y":12,"z":0},"legs":[ + {"name":"hall","to":{"x":46,"y":12,"z":0}, + "via":[{"x":22,"y":12,"z":0}], + "pull_groups":[ + {"entries":[100],"positions":[{"x":44,"y":12,"z":0}]}, + {"entries":[101],"approach":[{"x":48,"y":40,"z":0}],"positions":[{"x":48,"y":40,"z":0}],"radius":10}]}, + {"name":"stairs","to":{"x":48,"y":20,"z":0}, + "via":[{"x":48,"y":34,"z":0,"check_skip":"fixture"}], + "boss":{"entry":200,"position":{"x":48,"y":24,"z":0}}}, + {"name":"serpentis","to":{"x":48,"y":44,"z":0}, + "via":[{"x":20,"y":12,"z":0,"exact":true},{"x":36,"y":12,"z":0,"direct":true,"check_skip":"fixture"},{"x":48,"y":34,"z":0,"check_skip":"fixture"}], + "pull_groups":[{"entries":[102],"positions":[{"x":48,"y":40,"z":0}]}], + "boss":{"entry":201,"position":{"x":48,"y":48,"z":0}}}, + {"name":"back","to":{"x":46,"y":12,"z":0}, + "via":[{"x":48,"y":16,"z":0}], + "pull_groups":[{"entries":[103],"positions":[{"x":44,"y":12,"z":0}]}]}, + {"name":"onward","to":{"x":30,"y":12,"z":0}, + "pull_groups":[{"entries":[104],"positions":[{"x":30,"y":12,"z":0}]}]}]}` + +func bossesFixture(t *testing.T) BossesReport { + t.Helper() + dir := navfixture.WriteDir(t, 0, gapMesh()) + p := navmesh.NewPlanner(dir) + t.Cleanup(p.Close) + plan, err := dungeon.Parse("gap.json", []byte(gapPlan)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + rep, err := Bosses(p, dir, plan) + if err != nil { + t.Fatalf("Bosses: %v", err) + } + if len(rep.Legs) != 5 { + t.Fatalf("legs: %d", len(rep.Legs)) + } + return rep +} + +// A leg the mesh cannot finish FAILS and names the stored break at the gap: the walk mode skips +// that break hop, the bosses mode must not. A stored break far from where the path stops is not +// named. +func TestBossesGapFailsTheLegAndNamesTheBreak(t *testing.T) { + rep := bossesFixture(t) + for _, i := range []int{0, 1} { + if rep.Legs[i].Status != "complete" || rep.Legs[i].NonComparable || rep.Legs[i].Failed() { + t.Errorf("leg %d: %+v", i, rep.Legs[i]) + } + } + f := rep.Legs[2] + if f.Status != string(navmesh.Partial) || !f.Failed() { + t.Fatalf("leg 2: status %s failed %v, want a failed partial", f.Status, f.Failed()) + } + if f.Live.EndGapYd < 15 || f.Live.EndGapYd > 25 { + t.Errorf("leg 2 gap %.1f yd, want ~22 (the path stops at the gap)", f.Live.EndGapYd) + } + if len(f.BreakHits) != 1 { + t.Fatalf("leg 2 break hits: %+v", f.BreakHits) + } + h := f.BreakHits[0] + if h.Via != 2 || h.Kind != "check_skip" || h.At.Y != 34 || h.EndYd > 15 { + t.Errorf("break hit %+v, want via 2 check_skip at y 34 within 15 yd of the end", h) + } + // The failed leg's own metrics stay empty. + if len(f.Packs) != 0 || len(f.Hand) != 0 || len(f.ZJumps) != 0 { + t.Errorf("failed leg carries metrics: %+v", f) + } + lines := strings.Join(failedBossLegsForTest(rep), "\n") + if !strings.Contains(lines, "serpentis") || !strings.Contains(lines, "check_skip") { + t.Errorf("failed lines %q", lines) + } +} + +// failedBossLegsForTest keeps the package test free of the command's text. +func failedBossLegsForTest(rep BossesReport) []string { + var out []string + for i := range rep.Legs { + if rep.Legs[i].Failed() { + out = append(out, rep.Legs[i].Name+" "+rep.Legs[i].BreakHits[0].Kind) + } + } + return out +} + +// The leg after the failed one starts from the last point the failed path reached and is +// non-comparable; it adds nothing to the aggregates; the leg after it (which started on a real +// objective) counts again. +func TestBossesFailedLegDoesNotContaminate(t *testing.T) { + rep := bossesFixture(t) + f, nc, next := rep.Legs[2], rep.Legs[3], rep.Legs[4] + if !nc.NonComparable || nc.NonComparableWhy == "" || !strings.Contains(nc.NonComparableWhy, "leg 2 (serpentis)") { + t.Errorf("leg 3 not labelled non-comparable: %+v", nc) + } + if nc.From != f.Live.End { + t.Errorf("leg 3 starts at %v, want the failed path's end %v", nc.From, f.Live.End) + } + if nc.Status != "complete" || nc.Failed() || nc.Counted() { + t.Errorf("leg 3: status %s failed %v counted %v", nc.Status, nc.Failed(), nc.Counted()) + } + if next.NonComparable || !next.Counted() { + t.Errorf("leg 4 should count again: %+v", next) + } + if next.From.X != 46 || next.From.Y < 11 || next.From.Y > 13 { + t.Errorf("leg 4 starts at %v, want leg 3's objective (46, 12)", next.From) + } + // Totals: legs 0, 1 and 4 only. + tot := rep.Totals() + if tot.Complete != 3 || tot.Failed != 1 || tot.Continuations != 1 || tot.ContinuationsOK != 1 { + t.Errorf("totals %+v", tot) + } + var packs, on, hand, red, zj int + var live float64 + for _, i := range []int{0, 1, 4} { + l := rep.Legs[i] + packs += len(l.Packs) + on += l.PacksOnPath + hand += len(l.Hand) + red += l.HandRedundant + zj += len(l.ZJumps) + live += l.Live.LiveLenYd + } + if tot.Sum.Packs != packs || tot.Sum.PacksOn != on || tot.Sum.Hand != hand || tot.Sum.HandRedundant != red || tot.Sum.ZJumps != zj || tot.Sum.LiveYd != live { + t.Errorf("totals %+v do not equal the counted legs' sums (%d/%d packs, %d/%d hand, %d jumps, %.1f yd)", tot, on, packs, red, hand, zj, live) + } + // Leg 3 would have changed the sums had it been counted: its pack sits on its path. + if nc.PacksOnPath == 0 || len(nc.Hand) == 0 { + t.Errorf("leg 3 carries no metrics to exclude: %+v", nc) + } + s := rep.Summary() + if !strings.Contains(s, "3 complete, 1 failed, 1 non-comparable (1 reach their objective)") { + t.Errorf("summary %q", s) + } +} + +// The corridor metrics of a complete leg: a pack whose centre the live path passes is on it, a +// pack far from it is not; a via the path passes is redundant, an approach far from it is not; +// a break via is no hand point. +func TestBossesCorridorMetrics(t *testing.T) { + rep := bossesFixture(t) + h := rep.Legs[0] + if len(h.Packs) != 2 || !h.Packs[0].OnPath || h.Packs[1].OnPath { + t.Errorf("hall packs: %+v", h.Packs) + } + if len(h.Hand) != 2 { + t.Fatalf("hall hand points: %+v", h.Hand) + } + if h.Hand[0].Kind != "via" || !h.Hand[0].Redundant { + t.Errorf("via (22,12): %+v, want redundant", h.Hand[0]) + } + if h.Hand[1].Kind != "approach" || h.Hand[1].Redundant || h.Hand[1].NearYd < 20 { + t.Errorf("approach (48,40): %+v, want far from the live path", h.Hand[1]) + } + // Leg 1's only via is a break: not a hand point, but listed with the breaks, untouched. + st := rep.Legs[1] + if len(st.Hand) != 1 || st.Hand[0].Kind != "to" { + t.Errorf("stairs hand points %+v: want the to alone (a break via is not one)", st.Hand) + } + if len(st.Breaks) != 1 || st.Breaks[0].Touched { + t.Errorf("stairs breaks %+v", st.Breaks) + } + if st.Live.LiveLenYd < 12 { + t.Errorf("stairs live %.1f yd", st.Live.LiveLenYd) + } +} + +// A planner on a mesh with no mmaps is an error, not a report; a bad plan name is refused. +func TestBossesRefusesABadName(t *testing.T) { + p := navmesh.NewPlanner("") + defer p.Close() + if _, err := Bosses(p, "", &dungeon.Plan{Name: "../escape"}); err == nil || !strings.Contains(err.Error(), "not a plan name") { + t.Errorf("err %v", err) + } +} + +// A break far from where the live path stops is still named when it is the missing link: the +// island at (100..120) is reached by the plan's check_skip hop at (102, 102), 70 yd from the +// end of the reachable ground; a decoy break at (30, 12) does not lead to the island and is +// not named. Then the leg after the failed one is planned a second time from the failed leg's +// objective (bridged), so the mesh's other gaps show up. +func TestBossesNamesTheBridgingBreak(t *testing.T) { + plan := `{"version":1,"name":"island","map":0,"entrance":{"x":12,"y":12,"z":0},"legs":[ + {"name":"crossing","to":{"x":110,"y":110,"z":0}, + "via":[{"x":30,"y":12,"z":0,"check_skip":"decoy"},{"x":48,"y":20,"z":0},{"x":102,"y":102,"z":0,"check_skip":"the island"}], + "boss":{"entry":300,"position":{"x":110,"y":110,"z":0}}}, + {"name":"island hall","to":{"x":104,"y":104,"z":0},"boss":{"entry":301,"position":{"x":116,"y":116,"z":0}}}, + {"name":"voided","to":{"x":300,"y":300,"z":0},"boss":{"entry":302,"position":{"x":300,"y":300,"z":0}}}]}` + polys := append(navfixture.LCorridor(0), navfixture.Square(100, 100, 120, 120, 0)) + dir := navfixture.WriteDir(t, 0, polys) + p := navmesh.NewPlanner(dir) + t.Cleanup(p.Close) + dp, err := dungeon.Parse("island.json", []byte(plan)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + rep, err := Bosses(p, dir, dp) + if err != nil { + t.Fatalf("Bosses: %v", err) + } + f := rep.Legs[0] + if !f.Failed() || len(f.BreakHits) != 1 { + t.Fatalf("leg 0: status %s hits %+v", f.Status, f.BreakHits) + } + h := f.BreakHits[0] + if h.Via != 2 || !h.Bridges || h.EndYd < 40 || !strings.Contains(h.Why, "reaches its foot") { + t.Errorf("hit %+v, want via 2 named only because it bridges (end %.0f yd away)", h, h.EndYd) + } + // Leg 1 starts on the mainland (the failed path's end): non-comparable and failing; planned + // from leg 0's objective on the island it completes, which the bridged re-plan says. + n := rep.Legs[1] + if !n.NonComparable || n.Status == "complete" { + t.Errorf("leg 1: %+v", n) + } + if n.Bridged == nil || n.Bridged.Status != "complete" || n.Bridged.From != f.To || len(n.Bridged.Hand) != 1 { + t.Errorf("leg 1 bridged: %+v", n.Bridged) + } + tot := rep.Totals() + if tot.Complete != 0 || tot.Failed != 1 || tot.Continuations != 2 || tot.BridgedComplete != 1 || tot.Sum.LiveYd != 0 || tot.Bridged.Hand != 1 { + t.Errorf("totals %+v", tot) + } + // The void leg is non-comparable too, and its bridged plan fails as well. + if v := rep.Legs[2]; !v.NonComparable || v.Bridged == nil || v.Bridged.Status == "complete" { + t.Errorf("leg 2: %+v", v) + } +} diff --git a/internal/routeoracle/oracle.go b/internal/routeoracle/oracle.go index 0fbcb65e..2a7ed982 100644 --- a/internal/routeoracle/oracle.go +++ b/internal/routeoracle/oracle.go @@ -93,12 +93,15 @@ type BreakTouched struct { // PullOnPath is a pull group and whether the leg's live route's corridor holds one of its // positions. type PullOnPath struct { - Group int `json:"group"` - OnPath bool `json:"on_path"` - NearYd float64 `json:"near_yd"` // horizontal distance of the nearest position to the route - DZYd float64 `json:"dz_yd"` - RadiusYd float64 `json:"radius_yd"` - Entries []uint32 `json:"entries"` + Group int `json:"group"` + OnPath bool `json:"on_path"` + // InCorridor (bosses mode): the centre is within the radius plus CorridorSlackYd of the + // route, the walk mode's own on_path measure. + InCorridor bool `json:"in_corridor,omitempty"` + NearYd float64 `json:"near_yd"` // horizontal distance of the nearest position to the route + DZYd float64 `json:"dz_yd"` + RadiusYd float64 `json:"radius_yd"` + Entries []uint32 `json:"entries"` } // LegReport is one leg's comparison of the stored route and the live route. From 44c0ca5b848ff990c26bd77f33da78534f680371 Mon Sep 17 00:00:00 2001 From: pjerra <71784785+pjerra@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:50:07 +0200 Subject: [PATCH 4/6] route-oracle: write the reports without following a symlink The containment check was lexical: a .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. --- cmd/turtle-agent/route_oracle_cmd.go | 16 +-- cmd/turtle-agent/route_oracle_out.go | 132 ++++++++++++++++++ cmd/turtle-agent/route_oracle_out_test.go | 159 ++++++++++++++++++++++ 3 files changed, 295 insertions(+), 12 deletions(-) create mode 100644 cmd/turtle-agent/route_oracle_out.go create mode 100644 cmd/turtle-agent/route_oracle_out_test.go diff --git a/cmd/turtle-agent/route_oracle_cmd.go b/cmd/turtle-agent/route_oracle_cmd.go index 52ade7df..6979b29e 100644 --- a/cmd/turtle-agent/route_oracle_cmd.go +++ b/cmd/turtle-agent/route_oracle_cmd.go @@ -6,7 +6,6 @@ import ( "flag" "fmt" "io" - "os" "path/filepath" "strings" "text/tabwriter" @@ -75,10 +74,12 @@ func routeOracleCmd(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "route-oracle: unknown --mode %q (want %s or %s)\n", *mode, modeWalk, modeBosses) return exitUsage } - if err := os.MkdirAll(*out, 0o755); err != nil { + outDir, err := safeOutDir(*out) + if err != nil { fmt.Fprintf(stderr, "route-oracle: %v\n", err) return exitUsage } + *out = outDir planner := navmesh.NewPlanner(*mmaps) defer planner.Close() @@ -174,16 +175,7 @@ func writeReport(rep any, txt []byte, dir, name string) error { if err != nil { return err } - for _, target := range []string{filepath.Join(dir, name+".json"), filepath.Join(dir, name+".txt")} { - rel, err := filepath.Rel(dir, target) - if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return fmt.Errorf("report %s resolves outside %s", target, dir) - } - } - if err := os.WriteFile(filepath.Join(dir, name+".json"), append(b, '\n'), 0o644); err != nil { - return err - } - return os.WriteFile(filepath.Join(dir, name+".txt"), txt, 0o644) + return writeFilesIn(dir, map[string][]byte{name + ".json": append(b, '\n'), name + ".txt": txt}) } // oracleText is the plan's human table: one line per leg, then the failed legs' hops, the diff --git a/cmd/turtle-agent/route_oracle_out.go b/cmd/turtle-agent/route_oracle_out.go new file mode 100644 index 00000000..82c61753 --- /dev/null +++ b/cmd/turtle-agent/route_oracle_out.go @@ -0,0 +1,132 @@ +package main + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// The report files are written under --out and nowhere else, whatever the folder already +// holds. Two things guard that, and a lexical check is neither: +// +// - safeOutDir refuses a --out that is, or has a component that is, a symlink, so the +// folder the reports land in is the real one the user named; +// - writeFilesIn refuses a destination that already exists as anything but a regular file +// (a symlink, even a dangling one, a directory), checks both before writing either, and +// writes each file to a new temporary file of its own (O_EXCL) in a directory handle it +// opened (os.Root: no component may leave the folder) and renames it over the target. +// A rename replaces whatever is at the target, a symlink included, without following it, +// so a link planted between the check and the write still overwrites nothing outside. + +// safeOutDir makes dir (and the folders above it that are missing) and returns its absolute +// path, or says which component is a symlink. Nothing is created through a symlink: the +// existing components are checked before anything is made, and all of them again after. +func safeOutDir(dir string) (string, error) { + abs, err := filepath.Abs(dir) + if err != nil { + return "", err + } + if err := noSymlinkIn(abs); err != nil { + return "", err + } + if err := os.MkdirAll(abs, 0o755); err != nil { + return "", err + } + if err := noSymlinkIn(abs); err != nil { + return "", err + } + fi, err := os.Lstat(abs) + if err != nil { + return "", err + } + if !fi.IsDir() { + return "", fmt.Errorf("--out %s is not a folder", abs) + } + return abs, nil +} + +// noSymlinkIn walks the absolute path from the root down and refuses a component that is a +// symlink; the walk stops at the first component that does not exist. +func noSymlinkIn(abs string) error { + vol := filepath.VolumeName(abs) + rest := strings.TrimPrefix(abs, vol) + cur := vol + string(filepath.Separator) + for _, part := range strings.Split(rest, string(filepath.Separator)) { + if part == "" { + continue + } + cur = filepath.Join(cur, part) + fi, err := os.Lstat(cur) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if fi.Mode()&fs.ModeSymlink != 0 { + return fmt.Errorf("--out %s contains a symlink (%s): name the real folder", abs, cur) + } + } + return nil +} + +// writeFilesIn writes the named files (base name -> content) into dir without following a +// symlink, and writes nothing when any destination is not a plain file or absent. +func writeFilesIn(dir string, files map[string][]byte) error { + root, err := os.OpenRoot(dir) + if err != nil { + return err + } + defer root.Close() + for name := range files { + if name != filepath.Base(name) || name == "." || name == ".." || strings.ContainsAny(name, `/\`) { + return fmt.Errorf("report file name %q is not a plain file name", name) + } + fi, err := root.Lstat(name) + switch { + case errors.Is(err, fs.ErrNotExist): + case err != nil: + return err + case !fi.Mode().IsRegular(): + return fmt.Errorf("report %s exists in %s and is not a regular file (%s): refusing to write through it", name, dir, fi.Mode().Type()) + } + } + for name, content := range files { + if err := writeThroughTemp(root, name, content); err != nil { + return err + } + } + return nil +} + +// writeThroughTemp writes content to a fresh temporary file in root and renames it to name. +func writeThroughTemp(root *os.Root, name string, content []byte) (err error) { + var tmp string + var f *os.File + for i := 0; ; i++ { + tmp = fmt.Sprintf(".%s.%d.%d.tmp", name, os.Getpid(), i) + f, err = root.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err == nil { + break + } + if !errors.Is(err, fs.ErrExist) || i > 100 { + return err + } + } + defer func() { + if err != nil { + f.Close() + root.Remove(tmp) + } + }() + if _, err = f.Write(content); err != nil { + return err + } + if err = f.Close(); err != nil { + return err + } + return root.Rename(tmp, name) +} diff --git a/cmd/turtle-agent/route_oracle_out_test.go b/cmd/turtle-agent/route_oracle_out_test.go new file mode 100644 index 00000000..64aaabaf --- /dev/null +++ b/cmd/turtle-agent/route_oracle_out_test.go @@ -0,0 +1,159 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/pjerra/turtle-agent/internal/navmesh/navfixture" +) + +// outFixture is a one-leg plan on the fixture mesh: the plans folder and the mmaps folder. +func outFixture(t *testing.T) (plans, mmaps string) { + t.Helper() + plans = t.TempDir() + plan := `{"version":1,"name":"corridor","map":0,"entrance":{"x":12,"y":12,"z":0},"legs":[ + {"name":"hall","to":{"x":46,"y":12,"z":0},"pull_groups":[{"entries":[100],"positions":[{"x":44,"y":12,"z":0}]}]}]}` + if err := os.WriteFile(filepath.Join(plans, "corridor.json"), []byte(plan), 0o644); err != nil { + t.Fatal(err) + } + return plans, navfixture.WriteDir(t, 0, navfixture.LCorridor(0)) +} + +func runOracle(plans, mmaps, out, mode string) (int, string) { + var stdout, stderr bytes.Buffer + code := routeOracleCmd([]string{"--plans", plans, "--mmaps", mmaps, "--out", out, "--plan", "corridor", "--mode", mode}, &stdout, &stderr) + return code, stderr.String() +} + +func readOr(t *testing.T, p string) string { + t.Helper() + b, err := os.ReadFile(p) + if err != nil { + return "<" + err.Error() + ">" + } + return string(b) +} + +// A report file that already exists as a symlink to somewhere outside --out is never written +// through: the target keeps its content (or stays absent when the link dangles), nothing at all +// is written, and the command fails. With the check and the rename removed, os.WriteFile followed +// the link and truncated the target. +func TestRouteOracleDoesNotWriteThroughASymlinkedReport(t *testing.T) { + plans, mmaps := outFixture(t) + for _, mode := range []string{"walk", "bosses"} { + stem := "corridor" + if mode == "bosses" { + stem = "corridor-bosses" + } + for _, ext := range []string{".txt", ".json"} { + for _, dangling := range []bool{false, true} { + outside := t.TempDir() + victim := filepath.Join(outside, "victim") + if !dangling { + if err := os.WriteFile(victim, []byte("KEEP"), 0o644); err != nil { + t.Fatal(err) + } + } + out := t.TempDir() + if err := os.Symlink(victim, filepath.Join(out, stem+ext)); err != nil { + t.Fatal(err) + } + code, stderr := runOracle(plans, mmaps, out, mode) + name := mode + " " + ext + " dangling=" + map[bool]string{false: "no", true: "yes"}[dangling] + if code != exitRouteOracle || !strings.Contains(stderr, "not a regular file") { + t.Errorf("%s: exit %d stderr %q, want the refusal", name, code, stderr) + } + if dangling { + if _, err := os.Lstat(victim); err == nil { + t.Errorf("%s: the dangling link's target was created", name) + } + } else if got := readOr(t, victim); got != "KEEP" { + t.Errorf("%s: the file outside --out now holds %q", name, got) + } + // Nothing else was written: the other report is absent, and no temp file is left. + es, _ := os.ReadDir(out) + if len(es) != 1 { + t.Errorf("%s: --out holds %d entries, want only the planted link", name, len(es)) + } + } + } + } +} + +// A destination that is a folder is refused too. +func TestRouteOracleRefusesAFolderAsAReport(t *testing.T) { + plans, mmaps := outFixture(t) + out := t.TempDir() + if err := os.Mkdir(filepath.Join(out, "corridor.json"), 0o755); err != nil { + t.Fatal(err) + } + code, stderr := runOracle(plans, mmaps, out, "walk") + if code != exitRouteOracle || !strings.Contains(stderr, "not a regular file") { + t.Errorf("exit %d stderr %q", code, stderr) + } + if _, err := os.Stat(filepath.Join(out, "corridor.txt")); err == nil { + t.Error("the other report was written") + } +} + +// --out that is a symlink, or has a symlink component, is refused before anything is made or +// written, and nothing lands in the link's target. +func TestRouteOracleRefusesASymlinkedOut(t *testing.T) { + plans, mmaps := outFixture(t) + base := t.TempDir() + real := filepath.Join(base, "real") + if err := os.Mkdir(real, 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(base, "link") + if err := os.Symlink(real, link); err != nil { + t.Fatal(err) + } + for _, out := range []string{link, filepath.Join(link, "sub"), filepath.Join(link, "a", "b")} { + code, stderr := runOracle(plans, mmaps, out, "walk") + if code != exitUsage || !strings.Contains(stderr, "contains a symlink") { + t.Errorf("--out %s: exit %d stderr %q, want a usage refusal", out, code, stderr) + } + } + if es, _ := os.ReadDir(real); len(es) != 0 { + t.Errorf("the link's target holds %v: something was made through the link", es) + } + // A real folder still works. + if code, stderr := runOracle(plans, mmaps, filepath.Join(base, "fresh", "deep"), "walk"); code != exitOK { + t.Errorf("real --out: exit %d stderr %q", code, stderr) + } +} + +// The write itself does not follow a link planted after the check: the rename replaces the +// link and the file it pointed at is untouched. +func TestWriteThroughTempReplacesALinkWithoutFollowingIt(t *testing.T) { + dir := t.TempDir() + victim := filepath.Join(t.TempDir(), "victim") + if err := os.WriteFile(victim, []byte("KEEP"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(victim, filepath.Join(dir, "r.txt")); err != nil { + t.Fatal(err) + } + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatal(err) + } + defer root.Close() + if err := writeThroughTemp(root, "r.txt", []byte("report")); err != nil { + t.Fatal(err) + } + if got := readOr(t, victim); got != "KEEP" { + t.Errorf("the file behind the link holds %q", got) + } + fi, err := os.Lstat(filepath.Join(dir, "r.txt")) + if err != nil || !fi.Mode().IsRegular() || readOr(t, filepath.Join(dir, "r.txt")) != "report" { + t.Errorf("r.txt: %v %v", fi, err) + } + if es, _ := os.ReadDir(dir); len(es) != 1 { + t.Errorf("temp file left behind: %v", es) + } +} From d9f4f1d2bcc0e932f7b7a7d674a4451c4ebb3abf Mon Sep 17 00:00:00 2001 From: pjerra <71784785+pjerra@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:48:34 +0200 Subject: [PATCH 5/6] route-oracle: --objectives bosses measures the boss list alone; one pack 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: -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. --- README.md | 11 +- cmd/turtle-agent/route_oracle_bosses_text.go | 21 +- cmd/turtle-agent/route_oracle_cmd.go | 21 +- cmd/turtle-agent/route_oracle_cmd_test.go | 48 +++ internal/routeoracle/bosses.go | 366 ++++++++++++------- internal/routeoracle/bosses_cgo_test.go | 183 +++++++++- internal/routeoracle/bosses_test.go | 55 +++ internal/routeoracle/oracle.go | 113 ++++-- 8 files changed, 638 insertions(+), 180 deletions(-) create mode 100644 internal/routeoracle/bosses_test.go diff --git a/README.md b/README.md index 3f95b9b0..a0b73f37 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ turtle-agent map [--capture FILE] [--events FILE] [--report FILE] [--botwatch FI turtle-agent tune-diff turtle-agent plan-export [--dir dungeons] [--allow-draft] [--drop-companions-loot] [--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 route-oracle --plans DIR --mmaps DIR --out DIR [--plan NAME]... +turtle-agent route-oracle --plans DIR --mmaps DIR --out DIR [--mode walk|bosses] [--objectives plan|bosses] [--plan NAME]... turtle-agent dashboard [--listen 127.0.0.1:8790] [--allow-host HOST[,HOST]] [--agents auto|] [--tests-dir DIR] [--traces-dir DIR] [--plans-dir DIR[,DIR]] [--minimap-dir DIR] [--embed-from ORIGIN[,ORIGIN]] [--config-dir DIR] turtle-agent minimap-extract --client DIR --out DIR [--maps 70,209|all-instances] [--mmaps DIR[,DIR]] [--prefer worldmap,minimap,navmesh] [--list] turtle-agent trace-score [--json] [--summary] [--set check=threshold]... ... @@ -868,6 +868,15 @@ Per leg the report holds: the status, end gap and live length of both runs; the The report is written under the **validated CLI plan name** (never the name inside the plan JSON — a plan file that says `name: "../escape"` is refused, and the destination must resolve under `--out`). `internal/routeoracle` holds the report; its tests plan over a synthetic mesh (`internal/navmesh/navfixture`) and need no real mmaps. +**Modes.** `--mode walk` (the default) is everything above: the plan's own stored sequence, hop by hop. `--mode bosses` asks a different question, how much of a route the live navmesh finds with the plan's hand data left out: each objective is ONE avoid-aware navmesh plan from where the party stands, using no hand point and skipping no break hop (a `direct`, `gm_cross` or `check_skip` via). It writes `-bosses.txt/.json`. `--objectives` picks the objectives: + +- `--objectives plan` (the default): each stored leg's own objective, its boss, else its `to`, else its last step's point. A leg without a boss is planned to a stored `to` point (a hand-chosen waypoint), so this is not a bare boss list. +- `--objectives bosses` (needs `--mode bosses`): ONLY the chain entrance -> boss 1 -> boss 2 -> ...; a leg without a boss is folded into the next boss's row (its pull groups, stands and `to` are measured against that row's live path; no stored `to` is an objective), and the legs after the last boss are not planned. It writes `-bosses-only.txt/.json`. + +An objective the mesh cannot reach is a failed leg (`partial` / `no_path`): it scores nothing, names the stored break it ran into (a break hop whose foot the mesh reaches and whose landing reaches the objective, or one within 15 yd of the live end or of the line to the objective), and the objective after it is planned from the last point reached and labelled non-comparable (excluded from every aggregate); it is also re-planned from the previous objective (`bridged`), beside the primary numbers. Both modes judge a pack the same way: one of its positions within its radius plus 18 yd of the route, 8 yd of height. A pull group with neither a position nor a hold is skipped and counted (`empty_groups`). + +**Output safety.** `--out` is refused when it is, or has a component that is, a symlink (a host where `/tmp` or `/home` is itself a symlink must name the real folder). A report file that already exists as anything but a regular file (a symlink, even a dangling one, a folder) is refused, and each file is written to a temp file in the folder and renamed over the target, so no existing link is followed. Documented limit: the folder is validated by path, so a concurrent rename of a writable ancestor between the check and the open could redirect the writes; this is a local, owner-run tool and the limit is accepted. + ### Exporting plans for the TortoiseBots run leader `turtle-agent plan-export` writes the plan file as rows for the TortoiseBots run leader (`.bot run`; its tables `ai_dungeon_plan`, `ai_dungeon_leg`, diff --git a/cmd/turtle-agent/route_oracle_bosses_text.go b/cmd/turtle-agent/route_oracle_bosses_text.go index 05e33e62..1beeb86b 100644 --- a/cmd/turtle-agent/route_oracle_bosses_text.go +++ b/cmd/turtle-agent/route_oracle_bosses_text.go @@ -20,7 +20,7 @@ func failedLine(l *routeoracle.BossLeg) string { return s + "; no stored break bridges it or lies within 15 yd of the end or of the line to the objective" } for _, b := range l.BreakHits { - s += fmt.Sprintf("; stored break via %d (%s) at %.1f,%.1f,%.1f (%s)", b.Via, b.Kind, b.At.X, b.At.Y, b.At.Z, b.Why) + s += fmt.Sprintf("; stored break leg %d via %d (%s) at %.1f,%.1f,%.1f (%s)", b.Leg, b.Via, b.Kind, b.At.X, b.At.Y, b.At.Z, b.Why) } return s } @@ -43,12 +43,15 @@ func failedBossLegs(rep routeoracle.BossesReport) []string { func bossesText(rep routeoracle.BossesReport) ([]byte, error) { var buf bytes.Buffer w := tabwriter.NewWriter(&buf, 0, 4, 2, ' ', 0) - fmt.Fprintf(w, "%s (map %d, status %q, %d avoid boxes), bosses mode\n", rep.Plan, rep.Map, rep.Status, rep.Avoid) + fmt.Fprintf(w, "%s (map %d, status %q, %d avoid boxes), bosses mode, objectives: %s\n", rep.Plan, rep.Map, rep.Status, rep.Avoid, rep.Objectives) + if rep.Objectives == routeoracle.ObjectivesBosses { + fmt.Fprintf(w, "boss-only chain: entrance -> boss 1 -> boss 2 ...; a leg without a boss is folded into the next boss (name a+b), no stored to point is an objective; legs after the last boss are not planned: %v\n", rep.Unplanned) + } fmt.Fprintln(w, rep.Summary()) fmt.Fprintln(w, "each leg is one avoid-aware navmesh plan from the previous objective to this leg's objective: no hand points, no break hops;") fmt.Fprintln(w, "a leg that does not reach it FAILED and scores nothing; a leg planned from a failed leg's end is non-comparable (NC) and scores nothing") fmt.Fprintf(w, "plan time %.0f ms\n", rep.PlanMs) - fmt.Fprintln(w, "packs: centre within the pack radius of the live path; hand: via/to/approach/stand points within 10 yd of it (redundant); NC: non-comparable; bridged: the NC leg planned from the previous objective") + fmt.Fprintln(w, "packs: a pack position within its radius plus 18 yd of the live path (8 yd of height), the same definition as the walk mode; hand: via/to/approach/stand points within 10 yd of it (redundant); NC: non-comparable; bridged: the NC leg planned from the previous objective") fmt.Fprintln(w, "leg\tname\tobjective\tfrom\tto\tstatus\tgap\tlive\tpacks\thand\tzj\tbreaks hit\tNC\tbridged") for i := range rep.Legs { l := &rep.Legs[i] @@ -87,19 +90,19 @@ func bossesText(rep routeoracle.BossesReport) ([]byte, error) { } for _, g := range l.Packs { if !g.OnPath { - fmt.Fprintf(w, "leg %d (%s): pack group %d (entries %v) centre %.1f yd (dz %.1f) from the live path, radius %.0f yd\n", - l.Leg, l.Name, g.Group, g.Entries, g.NearYd, g.DZYd, g.RadiusYd) + fmt.Fprintf(w, "leg %d (%s): pack group %d of stored leg %d (entries %v) nearest position %.1f yd (dz %.1f) from the live path, radius %.0f yd\n", + l.Leg, l.Name, g.Group, g.Leg, g.Entries, g.NearYd, g.DZYd, g.RadiusYd) } } for _, h := range l.Hand { if !h.Redundant { - fmt.Fprintf(w, "leg %d (%s): hand %s (seq %d) at %.1f,%.1f,%.1f is %.1f yd from the live path\n", - l.Leg, l.Name, h.Kind, h.Seq, h.At.X, h.At.Y, h.At.Z, h.NearYd) + fmt.Fprintf(w, "leg %d (%s): hand %s (stored leg %d, seq %d) at %.1f,%.1f,%.1f is %.1f yd from the live path\n", + l.Leg, l.Name, h.Kind, h.Leg, h.Seq, h.At.X, h.At.Y, h.At.Z, h.NearYd) } } for _, b := range l.Breaks { - fmt.Fprintf(w, "leg %d (%s): stored break via %d (%s) at %.1f,%.1f,%.1f: live path passes it %v\n", - l.Leg, l.Name, b.Via, b.Kind, b.At.X, b.At.Y, b.At.Z, b.Touched) + fmt.Fprintf(w, "leg %d (%s): stored break (stored leg %d) via %d (%s) at %.1f,%.1f,%.1f: live path passes it %v\n", + l.Leg, l.Name, b.Leg, b.Via, b.Kind, b.At.X, b.At.Y, b.At.Z, b.Touched) } for _, j := range l.ZJumps { fmt.Fprintf(w, "leg %d (%s): z jump %.1f yd at %.1f,%.1f,%.1f\n", l.Leg, l.Name, j.DropYd, j.At[0], j.At[1], j.At[2]) diff --git a/cmd/turtle-agent/route_oracle_cmd.go b/cmd/turtle-agent/route_oracle_cmd.go index 6979b29e..39e84c0b 100644 --- a/cmd/turtle-agent/route_oracle_cmd.go +++ b/cmd/turtle-agent/route_oracle_cmd.go @@ -19,7 +19,7 @@ import ( // exitRouteOracle: route-oracle could not report every plan (a failing plan is not a usage error). const exitRouteOracle = 12 -const routeOracleUsage = "usage: turtle-agent route-oracle --plans DIR --mmaps DIR --out DIR [--mode walk|bosses] [--plan NAME]..." +const routeOracleUsage = "usage: turtle-agent route-oracle --plans DIR --mmaps DIR --out DIR [--mode walk|bosses] [--objectives plan|bosses] [--plan NAME]..." // The two modes. walk (the default) plans each plan's own stored sequence hop by hop and // scores the stored hops against the navmesh; bosses plans entrance -> each leg's objective @@ -56,7 +56,8 @@ func routeOracleCmd(args []string, stdout, stderr io.Writer) int { fs.SetOutput(stderr) plansDir := fs.String("plans", "dungeons", "the folder with the plan files") mmaps := fs.String("mmaps", "", "folder with the server's mmaps (.mmap and *.mmtile); required") - out := fs.String("out", "", "folder for .txt and .json (-bosses.* in bosses mode); required") + out := fs.String("out", "", "folder for .txt and .json (-bosses.* in bosses mode, -bosses-only.* with --objectives bosses); required") + objectives := fs.String("objectives", routeoracle.ObjectivesPlan, "bosses mode only. plan: each leg's own objective (its boss, else its to point); bosses: ONLY the chain entrance -> boss 1 -> boss 2 ... (a leg without a boss is folded into the next boss; no stored to point is an objective)") mode := fs.String("mode", modeWalk, "walk: score the plan's stored hops on the navmesh; bosses: plan entrance -> each boss live, with no hand points") var plans []string fs.Var(&planList{&plans}, "plan", "a plan name; repeatable (default: "+strings.Join(routeOracleDefaultPlans, ", ")+")") @@ -74,6 +75,14 @@ func routeOracleCmd(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "route-oracle: unknown --mode %q (want %s or %s)\n", *mode, modeWalk, modeBosses) return exitUsage } + if *objectives != routeoracle.ObjectivesPlan && *objectives != routeoracle.ObjectivesBosses { + fmt.Fprintf(stderr, "route-oracle: unknown --objectives %q (want %s or %s)\n", *objectives, routeoracle.ObjectivesPlan, routeoracle.ObjectivesBosses) + return exitUsage + } + if *mode == modeWalk && *objectives != routeoracle.ObjectivesPlan { + fmt.Fprintf(stderr, "route-oracle: --objectives %s needs --mode %s (the walk mode plans the plan's own stored sequence)\n", *objectives, modeBosses) + return exitUsage + } outDir, err := safeOutDir(*out) if err != nil { fmt.Fprintf(stderr, "route-oracle: %v\n", err) @@ -113,7 +122,7 @@ func routeOracleCmd(args []string, stdout, stderr io.Writer) int { var summary string var tiles []routeoracle.TileFile if *mode == modeBosses { - rep, err := routeoracle.Bosses(planner, *mmaps, p) + rep, err := routeoracle.Bosses(planner, *mmaps, p, *objectives) if err == nil { err = writeBosses(rep, *out, n) } @@ -166,7 +175,11 @@ func writeBosses(rep routeoracle.BossesReport, dir, name string) error { if err != nil { return err } - return writeReport(rep, txt, dir, name+"-bosses") + stem := name + "-bosses" + if rep.Objectives == routeoracle.ObjectivesBosses { + stem = name + "-bosses-only" + } + return writeReport(rep, txt, dir, stem) } // writeReport writes .json (the report) and .txt (the table) into dir. diff --git a/cmd/turtle-agent/route_oracle_cmd_test.go b/cmd/turtle-agent/route_oracle_cmd_test.go index 3f6d9cfc..ebf118b3 100644 --- a/cmd/turtle-agent/route_oracle_cmd_test.go +++ b/cmd/turtle-agent/route_oracle_cmd_test.go @@ -174,3 +174,51 @@ func TestRouteOracleModeDefaultsToWalk(t *testing.T) { t.Errorf("table: err %v\n%s", err, txt) } } + +// --objectives: an unknown value is a usage error; boss-only objectives need the bosses mode; +// the default is the plan's own objectives (-bosses.*), and boss-only writes +// -bosses-only.* beside them without touching the others. +func TestRouteOracleObjectivesFlag(t *testing.T) { + out := filepath.Join(t.TempDir(), "out") + for _, c := range []struct { + args []string + want string + }{ + {[]string{"--objectives", "boss"}, "unknown --objectives"}, + {[]string{"--objectives", "bosses"}, "needs --mode bosses"}, + {[]string{"--mode", "walk", "--objectives", "bosses"}, "needs --mode bosses"}, + } { + var stdout, stderr bytes.Buffer + args := append([]string{"--mmaps", "/nowhere", "--out", out}, c.args...) + if code := routeOracleCmd(args, &stdout, &stderr); code != exitUsage || !strings.Contains(stderr.String(), c.want) { + t.Errorf("%v: exit %d stderr %q, want %q", c.args, code, stderr.String(), c.want) + } + } + if _, err := os.Stat(out); err == nil { + t.Error("--out was made for a refused flag") + } + + plans, mmaps := outFixture(t) + dir := t.TempDir() + run := func(extra ...string) int { + var stdout, stderr bytes.Buffer + args := append([]string{"--plans", plans, "--mmaps", mmaps, "--out", dir, "--plan", "corridor", "--mode", "bosses"}, extra...) + code := routeOracleCmd(args, &stdout, &stderr) + if code != exitOK { + t.Logf("stderr %q", stderr.String()) + } + return code + } + has := func(f string) bool { _, err := os.Stat(filepath.Join(dir, f)); return err == nil } + if run() != exitOK || !has("corridor-bosses.json") || has("corridor-bosses-only.json") { + t.Error("the default objectives did not write the plan-objectives files alone") + } + // The fixture plan has no boss: the boss-only chain is empty and every leg is unplanned. + if run("--objectives", "bosses") != exitOK || !has("corridor-bosses-only.json") || !has("corridor-bosses-only.txt") { + t.Error("--objectives bosses did not write the boss-only files") + } + b, _ := os.ReadFile(filepath.Join(dir, "corridor-bosses-only.json")) + if !strings.Contains(string(b), `"objectives": "bosses"`) || !strings.Contains(string(b), `"unplanned_legs"`) { + t.Errorf("boss-only json: %s", b) + } +} diff --git a/internal/routeoracle/bosses.go b/internal/routeoracle/bosses.go index 3b9655e5..c6d0539e 100644 --- a/internal/routeoracle/bosses.go +++ b/internal/routeoracle/bosses.go @@ -12,26 +12,45 @@ import ( // The bosses mode asks a different question from the walk mode. The walk mode proves the // plan's stored hops are walkable. The bosses mode asks whether a route could be planned LIVE -// from a boss list alone: each leg is one navmesh plan from where the party stands to the leg's -// objective (its boss, else its to/last point), avoid-aware, with NONE of the plan's hand -// points and NONE of its break hops. Whatever the mesh cannot reach that way is where hand data -// is still needed, and the report names the stored break that sits there. +// with the plan's hand data left out: each objective is one navmesh plan from where the party +// stands, avoid-aware, with NONE of the plan's hand points and NONE of its break hops. Whatever +// the mesh cannot reach that way is where hand data is still needed, and the report names the +// stored break that sits there. +// +// Two sets of objectives (the --objectives flag): +// +// plan (default) each stored leg's own objective: its boss, else its to, else its last +// step's point. A leg that has a boss is a boss-to-boss plan; the other legs are planned +// to a hand-chosen point the plan stores, so this is NOT a bare boss list. +// bosses ONLY the chain entrance -> boss 1 -> boss 2 -> ...: a leg without a boss is folded into +// the next boss objective (its pull groups, stands and to point are measured against +// that one live path, and no stored to point is an objective), and legs after the last +// boss are not planned. const ( - // HandNearYd is how close a stored hand point (via, approach, stand) must lie to the live - // path to be called redundant: the live path already passes it. + // HandNearYd is how close a stored hand point (via, to, approach, stand) must lie to the + // live path to be called redundant: the live path already passes it. HandNearYd = 10.0 // BreakHitYd is how close a stored break must lie to where the live path ends, or to the // line from there to the objective, to be named as the break the live plan ran into. BreakHitYd = 15.0 ) -// BossLeg is one leg's live planning, entrance (or the previous leg's end) to its objective. +// The two objective sets of Bosses. +const ( + ObjectivesPlan = "plan" + ObjectivesBosses = "bosses" +) + +// BossLeg is one objective's live planning, from the entrance (or the previous objective) to it. type BossLeg struct { + // Leg is the stored leg the objective belongs to (in boss-only mode the leg that holds the + // boss); Legs lists the stored legs folded into this row (boss-only mode). Leg int `json:"leg"` + Legs []int `json:"legs,omitempty"` Name string `json:"name,omitempty"` From dungeon.Point `json:"from"` // where the live plan starts - To dungeon.Point `json:"to"` // the leg's objective + To dungeon.Point `json:"to"` // the objective // Objective says what the objective is: boss, to, step, or none. Objective string `json:"objective"` // Status: complete (the mesh reaches the objective), partial or no_path (it does not: the @@ -71,24 +90,26 @@ type BossLeg struct { // BreakHit is a stored break a failed live plan ran into. type BreakHit struct { - Via int `json:"via"` // the waypoint's index in the leg's via, from 0 + Leg int `json:"leg"` // the stored leg + Via int `json:"via"` // the waypoint's index in that leg's via, from 0 Kind string `json:"kind"` // exact, direct, check_skip, gm_cross (comma joined) At dungeon.Point `json:"at"` // EndYd and LineYd: the break's distance to where the live path ends, and to the line from // there to the objective. EndYd float64 `json:"end_yd"` LineYd float64 `json:"line_yd"` - // Bridges: the mesh reaches the hop's foot (the previous via, or the leg's start) and the - // objective from the hop's landing, so this hop is what the live plan lacks. + // Bridges: the mesh reaches the hop's foot (the stored point before it, or the start) and + // the objective from the hop's landing, so this hop is what the live plan lacks. Bridges bool `json:"bridges"` // Why says which of the rules named it. Why string `json:"why"` } -// HandPoint is a stored hand point of a complete leg (a via, approach, boss approach or stand -// that is not a break and not the objective) and how far the live path passes from it. +// HandPoint is a stored hand point of a complete leg (a via, to, approach, boss approach or +// stand that is not a break and not the objective) and how far the live path passes from it. type HandPoint struct { Seq int `json:"seq"` + Leg int `json:"leg,omitempty"` // boss-only mode: the stored leg Kind string `json:"kind"` At dungeon.Point `json:"at"` NearYd float64 `json:"near_yd"` @@ -97,13 +118,18 @@ type HandPoint struct { // BossesReport is one plan's bosses-mode report. type BossesReport struct { - Plan string `json:"plan"` - Map uint32 `json:"map"` - Status string `json:"status,omitempty"` - Avoid int `json:"avoid_boxes"` - Legs []BossLeg `json:"legs"` - PlanMs float64 `json:"plan_ms"` - Tiles []TileFile `json:"tiles"` + Plan string `json:"plan"` + Map uint32 `json:"map"` + Status string `json:"status,omitempty"` + Objectives string `json:"objectives"` // plan or bosses + Avoid int `json:"avoid_boxes"` + // Unplanned (boss-only): the stored legs after the last boss, which have no boss to plan to. + Unplanned []int `json:"unplanned_legs,omitempty"` + // EmptyGroups: pull groups with no position and no hold, left out of every count. + EmptyGroups int `json:"empty_groups,omitempty"` + Legs []BossLeg `json:"legs"` + PlanMs float64 `json:"plan_ms"` + Tiles []TileFile `json:"tiles"` } // Failed is a leg the live plan could not finish and that is a fair test: it started where the @@ -129,20 +155,15 @@ type Totals struct { // Sums are the metric sums of a set of complete legs. type Sums struct { - Packs, PacksOn, PacksCorridor int - Hand, HandRedundant int - ZJumps int - LiveYd float64 + Packs, PacksOn int + Hand, HandRedundant int + ZJumps int + LiveYd float64 } func (s *Sums) add(l *BossLeg) { s.Packs += len(l.Packs) s.PacksOn += l.PacksOnPath - for i := range l.Packs { - if l.Packs[i].InCorridor { - s.PacksCorridor++ - } - } s.Hand += len(l.Hand) s.HandRedundant += l.HandRedundant s.ZJumps += len(l.ZJumps) @@ -194,19 +215,25 @@ func pct(a, b int) float64 { // Summary is the plan's one summary line. func (r BossesReport) Summary() string { t := r.Totals() + unit := "legs" + if r.Objectives == ObjectivesBosses { + unit = "boss objectives" + } if t.Legs == 0 { - return fmt.Sprintf("%s: map %d, no legs", r.Plan, r.Map) + return fmt.Sprintf("%s: map %d, no %s", r.Plan, r.Map, unit) } - s := fmt.Sprintf("%s: map %d, %d legs: %d complete, %d failed", r.Plan, r.Map, t.Legs, t.Complete, t.Failed) + s := fmt.Sprintf("%s: map %d, %d %s: %d complete, %d failed", r.Plan, r.Map, t.Legs, unit, t.Complete, t.Failed) if t.Continuations > 0 { s += fmt.Sprintf(", %d non-comparable (%d reach their objective)", t.Continuations, t.ContinuationsOK) } if t.NoObjective > 0 { s += fmt.Sprintf(", %d without an objective", t.NoObjective) } + if len(r.Unplanned) > 0 { + s += fmt.Sprintf(", %d legs after the last boss not planned", len(r.Unplanned)) + } if t.Sum.Packs > 0 { - s += fmt.Sprintf("; packs on path %d/%d (%.0f%%), in corridor %d (%.0f%%)", t.Sum.PacksOn, t.Sum.Packs, pct(t.Sum.PacksOn, t.Sum.Packs), - t.Sum.PacksCorridor, pct(t.Sum.PacksCorridor, t.Sum.Packs)) + s += fmt.Sprintf("; packs on path %d/%d (%.0f%%)", t.Sum.PacksOn, t.Sum.Packs, pct(t.Sum.PacksOn, t.Sum.Packs)) } if t.Sum.Hand > 0 { s += fmt.Sprintf("; hand points redundant %d/%d (%.0f%%)", t.Sum.HandRedundant, t.Sum.Hand, pct(t.Sum.HandRedundant, t.Sum.Hand)) @@ -215,42 +242,88 @@ func (r BossesReport) Summary() string { if t.Continuations > 0 { s += fmt.Sprintf("; bridged (non-comparable legs planned from the previous objective): %d complete, %d failed", t.BridgedComplete-t.Complete, t.BridgedFailed-t.Failed) } + if r.EmptyGroups > 0 { + s += fmt.Sprintf("; %d empty pull groups skipped", r.EmptyGroups) + } return s } -// Bosses plans each leg of the plan live: from where the party stands straight to the leg's -// objective on a scratch planner with the plan's avoid boxes, no hand points and no break hops. -// The party starts at the entrance; a leg that reaches its objective leaves it standing there. +// objective is one thing the live chain plans to: the stored legs it covers (the last one holds +// the objective point) and what the point is. +type objective struct { + legs []int + at dungeon.Point + kind string +} + +// planObjectives is each stored leg's own objective (a leg with nothing stored has kind none). +func planObjectives(plan *dungeon.Plan) []objective { + out := make([]objective, len(plan.Legs)) + for i := range plan.Legs { + at, kind := legObjective(&plan.Legs[i]) + out[i] = objective{legs: []int{i}, at: at, kind: kind} + } + return out +} + +// bossObjectives is the chain of the plan's bosses only: one objective per leg that has a boss, +// covering that leg and the boss-less legs just before it; the legs after the last boss are +// returned as unplanned. +func bossObjectives(plan *dungeon.Plan) (out []objective, unplanned []int) { + var pending []int + for i := range plan.Legs { + pending = append(pending, i) + if b := plan.Legs[i].Boss; b != nil { + out = append(out, objective{legs: pending, at: b.Position, kind: "boss"}) + pending = nil + } + } + return out, pending +} + +// Bosses plans the plan's objectives live (objectives is ObjectivesPlan or ObjectivesBosses; see +// the file comment): from where the party stands straight to the objective on a scratch planner +// with the plan's avoid boxes, no hand points and no break hops. The party starts at the +// entrance; an objective the mesh reaches leaves it standing there. // -// A leg whose plan does not reach the objective FAILED (partial or no_path): it names the stored -// breaks it ran into, and adds nothing to any aggregate. The leg after it starts from the last -// point the failed path REACHED, which is not where the plan's journey goes on: that leg is -// labelled non-comparable and is excluded as well, until a leg completes and puts the party on -// a real objective again. Such a leg is also planned a second time from the previous leg's +// An objective whose plan does not reach it FAILED (partial or no_path): it names the stored +// breaks it ran into, and adds nothing to any aggregate. The objective after it starts from the +// last point the failed path REACHED, which is not where the plan's journey goes on: it is +// labelled non-comparable and is excluded as well, until one completes and puts the party on a +// real objective again. Such an objective is also planned a second time from the previous // objective (Bridged), to show what else the mesh does not reach once the break is crossed by // hand data; that is reported beside the primary numbers, never in them. // // A planner error (no folder, no engine, a broken tile) is returned, not reported. -func Bosses(p *navmesh.Planner, mmapsDir string, plan *dungeon.Plan) (BossesReport, error) { +func Bosses(p *navmesh.Planner, mmapsDir string, plan *dungeon.Plan, objectives string) (BossesReport, error) { if err := CheckName(plan.Name); err != nil { return BossesReport{}, fmt.Errorf("plan file: %w", err) } - rep := BossesReport{Plan: plan.Name, Map: plan.Map, Status: plan.Status, Avoid: len(plan.Avoid)} + rep := BossesReport{Plan: plan.Name, Map: plan.Map, Status: plan.Status, Avoid: len(plan.Avoid), + Objectives: objectives, EmptyGroups: emptyGroups(plan)} + var objs []objective + switch objectives { + case ObjectivesPlan: + objs = planObjectives(plan) + case ObjectivesBosses: + objs, rep.Unplanned = bossObjectives(plan) + default: + return rep, fmt.Errorf("unknown objectives %q (want %s or %s)", objectives, ObjectivesPlan, ObjectivesBosses) + } scratch := p.Scratch() defer scratch.Close() scratch.SetAvoid(plan.Map, dungeon.NavBoxes(plan.Avoid)) cur := vec(plan.Entrance) // where the party stands anchor := vec(plan.Entrance) // the previous objective: where the plan's journey stands - lost := "" // why the party is not on the plan's journey: the last failed leg - for i := range plan.Legs { - l := &plan.Legs[i] - obj, kind := legObjective(l) - if kind == "none" { - rep.Legs = append(rep.Legs, BossLeg{Leg: i, Name: l.Name, From: point(cur), To: obj, Objective: kind, Status: "no_objective"}) + lost := "" // why the party is not on the plan's journey: the last failed objective + for _, o := range objs { + last := o.legs[len(o.legs)-1] + if o.kind == "none" { + rep.Legs = append(rep.Legs, BossLeg{Leg: last, Name: plan.Legs[last].Name, From: point(cur), To: o.at, Objective: o.kind, Status: "no_objective"}) continue } - bl, end, err := planBossLeg(scratch, plan, i, cur, kind) + bl, end, err := planBossLeg(scratch, plan, o, cur, objectives == ObjectivesBosses) if err != nil { return rep, err } @@ -258,7 +331,7 @@ func Bosses(p *navmesh.Planner, mmapsDir string, plan *dungeon.Plan) (BossesRepo bl.NonComparable = true bl.NonComparableWhy = "planned from " + lost + ", not from the previous objective" if anchor != cur { - b, _, err := planBossLeg(scratch, plan, i, anchor, kind) + b, _, err := planBossLeg(scratch, plan, o, anchor, objectives == ObjectivesBosses) if err != nil { return rep, err } @@ -268,10 +341,10 @@ func Bosses(p *navmesh.Planner, mmapsDir string, plan *dungeon.Plan) (BossesRepo if bl.Status == string(navmesh.Complete) { cur, lost = end, "" } else { - lost = fmt.Sprintf("where leg %d (%s) ended: %s, %.1f yd short of its objective", i, l.Name, fmtPos(end), dist3(end, vec(obj))) + lost = fmt.Sprintf("where leg %d (%s) ended: %s, %.1f yd short of its objective", last, bl.Name, fmtPos(end), dist3(end, vec(o.at))) cur = end } - anchor = vec(obj) + anchor = vec(o.at) rep.PlanMs += bl.PlanMs rep.Legs = append(rep.Legs, bl) } @@ -280,85 +353,92 @@ func Bosses(p *navmesh.Planner, mmapsDir string, plan *dungeon.Plan) (BossesRepo return rep, err } -// planBossLeg plans one leg from a start to its objective and measures the result: a complete -// path gets the corridor metrics; a failed one gets the stored breaks it ran into. end is where -// the party stands afterwards. -func planBossLeg(sc *navmesh.Planner, plan *dungeon.Plan, i int, from Vec3, kind string) (BossLeg, Vec3, error) { - l := &plan.Legs[i] - obj, _ := legObjective(l) - bl := BossLeg{Leg: i, Name: l.Name, From: point(from), To: obj, Objective: kind} +// planBossLeg plans one objective from a start and measures the result: a complete path gets +// the corridor metrics of every stored leg the objective covers; a failed one gets the stored +// breaks it ran into. end is where the party stands afterwards. +func planBossLeg(sc *navmesh.Planner, plan *dungeon.Plan, o objective, from Vec3, folded bool) (BossLeg, Vec3, error) { + last := o.legs[len(o.legs)-1] + names := make([]string, len(o.legs)) + for i, li := range o.legs { + names[i] = plan.Legs[li].Name + } + bl := BossLeg{Leg: last, Name: strings.Join(names, "+"), From: point(from), To: o.at, Objective: o.kind} + if folded { + bl.Legs = o.legs + } t0 := time.Now() - path, err := sc.Plan(plan.Map, from, vec(obj)) + path, err := sc.Plan(plan.Map, from, vec(o.at)) if err != nil { - return bl, from, fmt.Errorf("plan %s leg %d (%s): %w", plan.Name, i, l.Name, err) + return bl, from, fmt.Errorf("plan %s leg %d (%s): %w", plan.Name, last, bl.Name, err) } bl.PlanMs = float64(time.Since(t0).Microseconds()) / 1000 bl.Status = string(path.Status) - bl.Live = runOf(path, vec(obj)) + bl.Live = runOf(path, vec(o.at)) bl.Reason = firstLine(path.Reason) end := pathEndOr(path, from) if path.Status == navmesh.Complete { - measureBossLeg(&bl, l, path, obj) + measureBossLeg(&bl, plan, o, path, folded) return bl, end, nil } - bl.Breaks = breaksOf(l, path.Points, end, path.Status == navmesh.Partial) - bl.BreakHits, err = breakHits(sc, plan.Map, l, from, end, vec(obj)) + for _, li := range o.legs { + for _, b := range breaksOf(&plan.Legs[li], path.Points, end, path.Status == navmesh.Partial) { + if folded { + b.Leg = li + } + bl.Breaks = append(bl.Breaks, b) + } + } + bl.BreakHits, err = breakHits(sc, plan.Map, plan, o.legs, from, end, vec(o.at)) if err != nil { - return bl, end, fmt.Errorf("plan %s leg %d (%s): %w", plan.Name, i, l.Name, err) + return bl, end, fmt.Errorf("plan %s leg %d (%s): %w", plan.Name, last, bl.Name, err) } return bl, end, nil } -// measureBossLeg fills the corridor metrics of a leg whose live path completed: the packs the -// path passes, the hand points it makes redundant, the floor jumps, the breaks it passes. -func measureBossLeg(bl *BossLeg, l *dungeon.Leg, path navmesh.Path, obj dungeon.Point) { +// measureBossLeg fills the corridor metrics of an objective whose live path completed, over +// every stored leg it covers: the packs the path passes, the hand points it makes redundant, the +// floor jumps, the breaks it passes. +func measureBossLeg(bl *BossLeg, plan *dungeon.Plan, o objective, path navmesh.Path, folded bool) { route := path.Points - bl.Packs = packsOnRoute(l, route) - for i := range bl.Packs { - if bl.Packs[i].OnPath { - bl.PacksOnPath++ - } - } - bl.Hand = handPoints(l, route, obj) - for i := range bl.Hand { - if bl.Hand[i].Redundant { - bl.HandRedundant++ - } - } - bl.ZJumps = ZJumps(PathSamples(path)) - bl.Breaks = breaksOf(l, route, Vec3{}, false) -} - -// packsOnRoute says, for each pull group, whether its centre lies within the group's pack -// radius of the route (horizontally, and no more than CorridorMaxDZYd above or below it). The -// centre is the one the driver pulls at: the positions' centroid, or a hold's spot. -func packsOnRoute(l *dungeon.Leg, route []Vec3) []PullOnPath { - var out []PullOnPath - for gi := range l.PullGroups { - g := &l.PullGroups[gi] - radius := g.Radius - if radius <= 0 { - radius = DefaultPackRadius + for _, li := range o.legs { + l := &plan.Legs[li] + for gi := range l.PullGroups { + pg, ok := GroupOnRoute(gi, &l.PullGroups[gi], route) + if !ok { + continue + } + if folded { + pg.Leg = li + } + bl.Packs = append(bl.Packs, pg) + if pg.OnPath { + bl.PacksOnPath++ + } } - pg := PullOnPath{Group: gi, RadiusYd: float64(radius), Entries: g.Entries} - centre := centroid(g.Positions) - if g.Hold != nil { - centre = g.Hold.Spot + for _, h := range handPoints(l, route, o.at) { + if folded { + h.Leg = li + } + bl.Hand = append(bl.Hand, h) + if h.Redundant { + bl.HandRedundant++ + } } - if len(g.Positions) > 0 || g.Hold != nil { - near := NearestPolyline(route, vec(centre)) - pg.NearYd, pg.DZYd = near.Dist2D, near.DZ - pg.OnPath = near.Dist3D >= 0 && near.Dist2D <= float64(radius) && near.DZ <= CorridorMaxDZYd - pg.InCorridor = OnCorridor(near, float64(radius)) + for _, b := range breaksOf(l, route, Vec3{}, false) { + if folded { + b.Leg = li + } + bl.Breaks = append(bl.Breaks, b) } - out = append(out, pg) } - return out + bl.ZJumps = ZJumps(PathSamples(path)) } // handPoints lists the leg's stored hand points (via, to, approach, boss approach, stand) and // how far the live route passes from each. A break waypoint is not one (it is the break, listed -// on its own) and neither is the objective itself. +// on its own) and neither is the objective itself: in the plan-objectives mode a to point that +// is the leg's objective is left out; in the boss-only mode no to point is an objective, so +// every one counts. func handPoints(l *dungeon.Leg, route []Vec3, obj dungeon.Point) []HandPoint { var out []HandPoint for j, w := range legWalk(l) { @@ -380,32 +460,52 @@ func handPoints(l *dungeon.Leg, route []Vec3, obj dungeon.Point) []HandPoint { return out } -// breakHits lists the stored breaks a failed live plan ran into. A break hop (a via that is a -// direct step, a gm_cross landing or a check_skip) is named when -// - it bridges: the mesh reaches the hop's foot (the previous via, else the leg's start) from -// the start and the objective from the hop's landing, so the missing link is that hop; or +// hopRec is one stored via of a covered leg, with the stored point the party stands on before it. +type hopRec struct { + leg, via int + w dungeon.Waypoint + foot Vec3 +} + +// breakHits lists the stored breaks a failed live plan ran into, over the stored legs the +// objective covers. A break hop (a via that is a direct step, a gm_cross landing or a +// check_skip) is named when +// - it bridges: the mesh reaches the hop's foot (the stored point before it: the previous via, +// else the last stored point of the leg before, else the start) from the start and the +// objective from the hop's landing, so the missing link is that hop; or // - it lies within BreakHitYd of where the live path ended, or of the line from there to the // objective (the break sits at the end of the reachable ground). // // The exact via before a named hop is the hop's foot and is named with it. -func breakHits(sc *navmesh.Planner, mapID uint32, l *dungeon.Leg, start, end, obj Vec3) ([]BreakHit, error) { +func breakHits(sc *navmesh.Planner, mapID uint32, plan *dungeon.Plan, legs []int, start, end, obj Vec3) ([]BreakHit, error) { + var recs []hopRec + prevLast := start + for _, li := range legs { + l := &plan.Legs[li] + for vi := range l.Via { + foot := prevLast + if vi > 0 { + foot = vec(l.Via[vi-1].Point) + } + recs = append(recs, hopRec{leg: li, via: vi, w: l.Via[vi], foot: foot}) + } + if walk := legWalk(l); len(walk) > 0 { + prevLast = walk[len(walk)-1].at + } + } var out []BreakHit line := []Vec3{end, obj} - named := map[int]bool{} - for vi := range l.Via { - w := l.Via[vi] + named := map[[2]int]bool{} + for _, r := range recs { + w := r.w kind := breakKind(w) if kind == "" || (!w.Direct && !w.GMCross && w.CheckSkip == "") { continue } at := vec(w.Point) - foot := start - if vi > 0 { - foot = vec(l.Via[vi-1].Point) - } - h := BreakHit{Via: vi, Kind: kind, At: w.Point, EndYd: dist3(end, at), LineYd: NearestPolyline(line, at).Dist3D} + h := BreakHit{Leg: r.leg, Via: r.via, Kind: kind, At: w.Point, EndYd: dist3(end, at), LineYd: NearestPolyline(line, at).Dist3D} var why []string - toFoot, err := sc.Plan(mapID, start, foot) + toFoot, err := sc.Plan(mapID, start, r.foot) if err != nil { return nil, err } @@ -416,7 +516,7 @@ func breakHits(sc *navmesh.Planner, mapID uint32, l *dungeon.Leg, start, end, ob } if fromLanding.Status == navmesh.Complete { h.Bridges = true - why = append(why, fmt.Sprintf("the mesh reaches its foot %s from the start and the objective from its landing", fmtPos(foot))) + why = append(why, fmt.Sprintf("the mesh reaches its foot %s from the start and the objective from its landing", fmtPos(r.foot))) } } if h.EndYd <= BreakHitYd || h.LineYd <= BreakHitYd { @@ -426,18 +526,22 @@ func breakHits(sc *navmesh.Planner, mapID uint32, l *dungeon.Leg, start, end, ob continue } h.Why = strings.Join(why, "; ") - named[vi] = true + named[[2]int{r.leg, r.via}] = true out = append(out, h) } // The exact foot before a named hop. - for vi := range l.Via { - if vi+1 < len(l.Via) && named[vi+1] && l.Via[vi].Exact && !named[vi] { - w := l.Via[vi] - at := vec(w.Point) - out = append(out, BreakHit{Via: vi, Kind: breakKind(w), At: w.Point, EndYd: dist3(end, at), - LineYd: NearestPolyline(line, at).Dist3D, Why: fmt.Sprintf("the exact foot of via %d's hop", vi+1)}) + for i, r := range recs { + if i+1 < len(recs) && recs[i+1].leg == r.leg && named[[2]int{r.leg, r.via + 1}] && r.w.Exact && !named[[2]int{r.leg, r.via}] { + at := vec(r.w.Point) + out = append(out, BreakHit{Leg: r.leg, Via: r.via, Kind: breakKind(r.w), At: r.w.Point, EndYd: dist3(end, at), + LineYd: NearestPolyline(line, at).Dist3D, Why: fmt.Sprintf("the exact foot of via %d's hop", r.via+1)}) } } - sort.Slice(out, func(i, j int) bool { return out[i].Via < out[j].Via }) + sort.Slice(out, func(i, j int) bool { + if out[i].Leg != out[j].Leg { + return out[i].Leg < out[j].Leg + } + return out[i].Via < out[j].Via + }) return out, nil } diff --git a/internal/routeoracle/bosses_cgo_test.go b/internal/routeoracle/bosses_cgo_test.go index be51e9b4..00b59ac5 100644 --- a/internal/routeoracle/bosses_cgo_test.go +++ b/internal/routeoracle/bosses_cgo_test.go @@ -3,6 +3,7 @@ package routeoracle import ( + "math" "strings" "testing" @@ -47,7 +48,7 @@ const gapPlan = `{"version":1,"name":"gap","map":0,"entrance":{"x":12,"y":12,"z" {"name":"onward","to":{"x":30,"y":12,"z":0}, "pull_groups":[{"entries":[104],"positions":[{"x":30,"y":12,"z":0}]}]}]}` -func bossesFixture(t *testing.T) BossesReport { +func bossesFixtureParts(t *testing.T) (*navmesh.Planner, string, *dungeon.Plan) { t.Helper() dir := navfixture.WriteDir(t, 0, gapMesh()) p := navmesh.NewPlanner(dir) @@ -56,7 +57,13 @@ func bossesFixture(t *testing.T) BossesReport { if err != nil { t.Fatalf("Parse: %v", err) } - rep, err := Bosses(p, dir, plan) + return p, dir, plan +} + +func bossesFixture(t *testing.T) BossesReport { + t.Helper() + p, dir, plan := bossesFixtureParts(t) + rep, err := Bosses(p, dir, plan, ObjectivesPlan) if err != nil { t.Fatalf("Bosses: %v", err) } @@ -196,7 +203,7 @@ func TestBossesCorridorMetrics(t *testing.T) { func TestBossesRefusesABadName(t *testing.T) { p := navmesh.NewPlanner("") defer p.Close() - if _, err := Bosses(p, "", &dungeon.Plan{Name: "../escape"}); err == nil || !strings.Contains(err.Error(), "not a plan name") { + if _, err := Bosses(p, "", &dungeon.Plan{Name: "../escape"}, ObjectivesPlan); err == nil || !strings.Contains(err.Error(), "not a plan name") { t.Errorf("err %v", err) } } @@ -221,7 +228,7 @@ func TestBossesNamesTheBridgingBreak(t *testing.T) { if err != nil { t.Fatalf("Parse: %v", err) } - rep, err := Bosses(p, dir, dp) + rep, err := Bosses(p, dir, dp, ObjectivesPlan) if err != nil { t.Fatalf("Bosses: %v", err) } @@ -251,3 +258,171 @@ func TestBossesNamesTheBridgingBreak(t *testing.T) { t.Errorf("leg 2: %+v", v) } } + +// Boss-only objectives: the chain is entrance -> boss 1 -> boss 2. The gap plan's legs 0 (hall), +// 1 (stairs, a boss) and 2 (serpentis, a boss) give two objectives; the hall is folded into the +// stairs row, its to point (46, 12) is a hand point and NOT an objective, and the legs after the +// last boss (3, 4) are not planned. +func TestBossOnlyChainFoldsBosslessLegs(t *testing.T) { + p, dir, plan := bossesFixtureParts(t) + rep, err := Bosses(p, dir, plan, ObjectivesBosses) + if err != nil { + t.Fatalf("Bosses: %v", err) + } + if rep.Objectives != ObjectivesBosses || len(rep.Legs) != 2 || len(rep.Unplanned) != 2 || rep.Unplanned[0] != 3 || rep.Unplanned[1] != 4 { + t.Fatalf("objectives %s legs %d unplanned %v", rep.Objectives, len(rep.Legs), rep.Unplanned) + } + a := rep.Legs[0] + if a.Name != "hall+stairs" || a.Leg != 1 || len(a.Legs) != 2 || a.Objective != "boss" || a.To.Y != 24 { + t.Errorf("row 0: %+v", a) + } + if a.From.X != 12 || a.From.Y != 12 || a.Status != "complete" { + t.Errorf("row 0 starts %v status %s", a.From, a.Status) + } + // The hall's to point (46, 12) and the stairs' to (48, 20) are hand points here (neither is + // an objective); the hall's far approach too. In the plan mode the hall's to IS its objective. + var to, sawHallTo int + for _, h := range a.Hand { + if h.Kind == "to" { + to++ + if h.Leg == 0 && h.At.X == 46 { + sawHallTo++ + } + } + } + if to != 2 || sawHallTo != 1 { + t.Errorf("row 0 to points among the hand points: %d (hall's: %d), hand %+v", to, sawHallTo, a.Hand) + } + if len(a.Packs) != 2 || a.Packs[0].Leg != 0 || a.Packs[1].Leg != 0 { + t.Errorf("row 0 packs %+v, want the hall's two groups", a.Packs) + } + plain, err := Bosses(p, dir, plan, ObjectivesPlan) + if err != nil { + t.Fatal(err) + } + for _, h := range plain.Legs[0].Hand { + if h.Kind == "to" { + t.Errorf("plan mode counted the hall's objective to point as a hand point: %+v", h) + } + } + if plain.Legs[0].Objective != "to" { + t.Errorf("plan mode leg 0 objective %s", plain.Legs[0].Objective) + } + // Row 1: serpentis fails at the gap, names the break (stored leg 2, via 2). + f := rep.Legs[1] + if !f.Failed() || f.Leg != 2 || len(f.BreakHits) != 1 || f.BreakHits[0].Leg != 2 || f.BreakHits[0].Via != 2 { + t.Errorf("row 1: status %s hits %+v", f.Status, f.BreakHits) + } + tot := rep.Totals() + if tot.Complete != 1 || tot.Failed != 1 || tot.Sum.Packs != 2 || tot.Sum.PacksOn != 2 { + t.Errorf("totals %+v", tot) + } + if s := rep.Summary(); !contains(s, "2 boss objectives: 1 complete, 1 failed") || !contains(s, "2 legs after the last boss not planned") { + t.Errorf("summary %q", s) + } +} + +func contains(s, sub string) bool { return strings.Contains(s, sub) } + +// Boss-only chain after a failure: the boss after the failed one is non-comparable and bridged +// from the failed boss's position, as in the plan mode. +func TestBossOnlyContinuationAfterAFailedBoss(t *testing.T) { + plan := `{"version":1,"name":"island","map":0,"entrance":{"x":12,"y":12,"z":0},"legs":[ + {"name":"crossing","to":{"x":60,"y":60,"z":0}, + "via":[{"x":48,"y":20,"z":0},{"x":102,"y":102,"z":0,"check_skip":"the island"}], + "boss":{"entry":300,"position":{"x":110,"y":110,"z":0}}}, + {"name":"island hall","to":{"x":104,"y":104,"z":0},"boss":{"entry":301,"position":{"x":116,"y":116,"z":0}}}]}` + polys := append(navfixture.LCorridor(0), navfixture.Square(100, 100, 120, 120, 0)) + dir := navfixture.WriteDir(t, 0, polys) + p := navmesh.NewPlanner(dir) + t.Cleanup(p.Close) + dp, err := dungeon.Parse("island.json", []byte(plan)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + rep, err := Bosses(p, dir, dp, ObjectivesBosses) + if err != nil { + t.Fatal(err) + } + if len(rep.Legs) != 2 || !rep.Legs[0].Failed() || len(rep.Legs[0].BreakHits) != 1 { + t.Fatalf("legs %+v", rep.Legs) + } + n := rep.Legs[1] + if !n.NonComparable || n.Bridged == nil || n.Bridged.Status != "complete" || n.Bridged.From != rep.Legs[0].To { + t.Errorf("row 1: %+v", n) + } + if tot := rep.Totals(); tot.Complete != 0 || tot.Failed != 1 || tot.Continuations != 1 || tot.Sum.LiveYd != 0 { + t.Errorf("totals %+v", tot) + } + if _, err := Bosses(p, dir, dp, "boss"); err == nil { + t.Error("an unknown objectives value was accepted") + } +} + +// Both modes judge a pack by the same definition: a group's position within its radius plus 18 +// yd of the live route. Two groups of radius 5 sit 22 and 24 yd off the corridor: on (<= 23) and +// off (> 23), in the walk mode and in the bosses mode alike. +func TestPackDefinitionIsTheSameInBothModes(t *testing.T) { + plan := `{"version":1,"name":"packs","map":0,"entrance":{"x":12,"y":12,"z":0},"legs":[ + {"name":"hall","to":{"x":46,"y":12,"z":0},"pull_groups":[ + {"entries":[1],"radius":5,"positions":[{"x":30,"y":34,"z":0}]}, + {"entries":[2],"radius":5,"positions":[{"x":30,"y":36,"z":0}]}]}]}` + dir := navfixture.WriteDir(t, 0, navfixture.LCorridor(0)) + p := navmesh.NewPlanner(dir) + t.Cleanup(p.Close) + dp, err := dungeon.Parse("packs.json", []byte(plan)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + walk, err := Oracle(p, dir, dp) + if err != nil { + t.Fatal(err) + } + live, err := Bosses(p, dir, dp, ObjectivesPlan) + if err != nil { + t.Fatal(err) + } + w, b := walk.Legs[0].Pulls, live.Legs[0].Packs + if len(w) != 2 || len(b) != 2 { + t.Fatalf("walk %d packs, bosses %d", len(w), len(b)) + } + for i, want := range []bool{true, false} { + if w[i].OnPath != want || b[i].OnPath != want { + t.Errorf("group %d: walk %v bosses %v, want %v (near %.1f / %.1f yd)", i, w[i].OnPath, b[i].OnPath, want, w[i].NearYd, b[i].NearYd) + } + } +} + +// A pull group with no position and no hold has nothing to walk to: the walk leaves it out +// instead of walking to the NaN centroid of nothing, and neither mode counts it in a pack total. +func TestEmptyPullGroupIsSkippedAndCounted(t *testing.T) { + plan := &dungeon.Plan{Name: "empty", Entrance: dungeon.Point{X: 12, Y: 12}, Legs: []dungeon.Leg{{ + Name: "hall", To: dungeon.Point{X: 46, Y: 12}, + PullGroups: []dungeon.PullGroup{{Entries: []uint32{1}}, {Entries: []uint32{2}, Positions: []dungeon.Point{{X: 44, Y: 12}}}}, + }}} + dir := navfixture.WriteDir(t, 0, navfixture.LCorridor(0)) + p := navmesh.NewPlanner(dir) + t.Cleanup(p.Close) + walk, err := Oracle(p, dir, plan) + if err != nil { + t.Fatalf("Oracle: %v", err) + } + if walk.EmptyGroups != 1 || len(walk.Legs[0].Pulls) != 1 || walk.Legs[0].Pulls[0].Group != 1 || walk.Legs[0].Status != "complete" { + t.Errorf("walk: empty %d pulls %+v status %s", walk.EmptyGroups, walk.Legs[0].Pulls, walk.Legs[0].Status) + } + for _, s := range walk.Legs[0].Segments { + if math.IsNaN(float64(s.At.X)) || s.Kind == "pull" && s.Group == 0 { + t.Errorf("a hop to the empty group: %+v", s) + } + } + live, err := Bosses(p, dir, plan, ObjectivesPlan) + if err != nil { + t.Fatalf("Bosses: %v", err) + } + if live.EmptyGroups != 1 || len(live.Legs[0].Packs) != 1 || live.Legs[0].Packs[0].Group != 1 { + t.Errorf("bosses: empty %d packs %+v", live.EmptyGroups, live.Legs[0].Packs) + } + if !contains(walk.Summary(), "1 empty pull groups skipped") || !contains(live.Summary(), "1 empty pull groups skipped") { + t.Errorf("summaries %q / %q", walk.Summary(), live.Summary()) + } +} diff --git a/internal/routeoracle/bosses_test.go b/internal/routeoracle/bosses_test.go new file mode 100644 index 00000000..a047bfc1 --- /dev/null +++ b/internal/routeoracle/bosses_test.go @@ -0,0 +1,55 @@ +package routeoracle + +import ( + "testing" + + "github.com/pjerra/turtle-agent/internal/dungeon" +) + +// The pack-on-route definition both modes share, at its edges: a position counts when it is +// within the group's radius plus CorridorSlackYd (18) of the route horizontally and no more than +// CorridorMaxDZYd (8) above or below it. Each case sits just either side of an edge, so a +// threshold moved by a yard (the slack dropped, doubled, or the radius left out) fails. +func TestGroupOnRouteBoundaries(t *testing.T) { + route := []Vec3{{0, 0, 0}, {100, 0, 0}} + pos := func(y, z float32) []dungeon.Point { return []dungeon.Point{{X: 50, Y: y, Z: z}} } + cases := []struct { + name string + radius float32 + at []dungeon.Point + on bool + }{ + {"radius 10: 27.9 yd out is on", 10, pos(27.9, 0), true}, + {"radius 10: 28.1 yd out is off", 10, pos(28.1, 0), false}, + {"default radius 25: 42.9 yd out is on", 0, pos(42.9, 0), true}, + {"default radius 25: 43.1 yd out is off", 0, pos(43.1, 0), false}, + {"radius 10: 7.9 yd above is on", 10, pos(0, 7.9), true}, + {"radius 10: 8.1 yd above is off", 10, pos(0, 8.1), false}, + {"radius 10: 8.1 yd below is off", 10, pos(0, -8.1), false}, + {"any one position is enough", 10, []dungeon.Point{{X: 50, Y: 90, Z: 0}, {X: 20, Y: 27.9, Z: 0}}, true}, + {"none of two positions", 10, []dungeon.Point{{X: 50, Y: 90, Z: 0}, {X: 20, Y: 28.1, Z: 0}}, false}, + } + for _, c := range cases { + g := &dungeon.PullGroup{Radius: c.radius, Positions: c.at} + got, ok := GroupOnRoute(0, g, route) + if !ok || got.OnPath != c.on { + t.Errorf("%s: ok %v on %v (near %.1f dz %.1f), want %v", c.name, ok, got.OnPath, got.NearYd, got.DZYd, c.on) + } + } +} + +// A hold-only group is judged by its spot; a group with neither a position nor a hold is empty: +// no result, so it is in no denominator. +func TestGroupOnRouteHoldAndEmptyGroups(t *testing.T) { + route := []Vec3{{0, 0, 0}, {100, 0, 0}} + hold := &dungeon.PullGroup{Radius: 10, Hold: &dungeon.Hold{Spot: dungeon.Point{X: 50, Y: 27.9}}} + if got, ok := GroupOnRoute(2, hold, route); !ok || !got.OnPath || got.Group != 2 { + t.Errorf("hold-only: %+v ok %v", got, ok) + } + if _, ok := GroupOnRoute(0, &dungeon.PullGroup{Entries: []uint32{1}}, route); ok { + t.Error("an empty group has a pack result") + } + if n := emptyGroups(&dungeon.Plan{Legs: []dungeon.Leg{{PullGroups: []dungeon.PullGroup{{}, *hold}}, {PullGroups: []dungeon.PullGroup{{}}}}}); n != 2 { + t.Errorf("empty groups %d, want 2", n) + } +} diff --git a/internal/routeoracle/oracle.go b/internal/routeoracle/oracle.go index 2a7ed982..91fa6585 100644 --- a/internal/routeoracle/oracle.go +++ b/internal/routeoracle/oracle.go @@ -83,7 +83,8 @@ type StoredPoint struct { // BreakTouched is a break waypoint of a leg (a via with exact/direct/gm_cross/check_skip) // and whether the leg's live route touched it. type BreakTouched struct { - Via int `json:"via"` // the waypoint's index in the leg's via, from 0 + Via int `json:"via"` // the waypoint's index in the leg's via, from 0 + Leg int `json:"leg,omitempty"` // boss-only mode: the stored leg it belongs to Kind string `json:"kind"` At dungeon.Point `json:"at"` Touched bool `json:"touched"` @@ -93,15 +94,68 @@ type BreakTouched struct { // PullOnPath is a pull group and whether the leg's live route's corridor holds one of its // positions. type PullOnPath struct { - Group int `json:"group"` - OnPath bool `json:"on_path"` - // InCorridor (bosses mode): the centre is within the radius plus CorridorSlackYd of the - // route, the walk mode's own on_path measure. - InCorridor bool `json:"in_corridor,omitempty"` - NearYd float64 `json:"near_yd"` // horizontal distance of the nearest position to the route - DZYd float64 `json:"dz_yd"` - RadiusYd float64 `json:"radius_yd"` - Entries []uint32 `json:"entries"` + Group int `json:"group"` + Leg int `json:"leg,omitempty"` // boss-only mode: the stored leg the group belongs to (absent: leg 0, or not a folded row) + OnPath bool `json:"on_path"` + NearYd float64 `json:"near_yd"` // horizontal distance of the nearest position to the route + DZYd float64 `json:"dz_yd"` + RadiusYd float64 `json:"radius_yd"` + Entries []uint32 `json:"entries"` +} + +// PackPositions is the positions a pull group is judged by: its mob positions, else a hold's +// spot. A group with neither has nothing to pull or to walk to: it is EMPTY, left out of the +// legs' walk and out of every pack count (the reports count them). +func PackPositions(g *dungeon.PullGroup) []dungeon.Point { + if len(g.Positions) > 0 { + return g.Positions + } + if g.Hold != nil { + return []dungeon.Point{g.Hold.Spot} + } + return nil +} + +// GroupOnRoute is the one definition of "a pack is on the route" both modes use: one of the +// group's positions lies within the group's radius plus CorridorSlackYd of the route +// horizontally and no more than CorridorMaxDZYd above or below it. ok is false for an empty +// group. +func GroupOnRoute(gi int, g *dungeon.PullGroup, route []Vec3) (pg PullOnPath, ok bool) { + pos := PackPositions(g) + if len(pos) == 0 { + return PullOnPath{}, false + } + radius := g.Radius + if radius <= 0 { + radius = DefaultPackRadius + } + pg = PullOnPath{Group: gi, RadiusYd: float64(radius), Entries: g.Entries} + if len(route) == 0 { + return pg, true + } + for i := range pos { + near := NearestPolyline(route, vec(pos[i])) + if !pg.OnPath || near.Dist2D < pg.NearYd { + pg.NearYd, pg.DZYd = near.Dist2D, near.DZ + } + if OnCorridor(near, float64(radius)) { + pg.OnPath = true + } + } + return pg, true +} + +// emptyGroups counts the plan's pull groups with no position and no hold. +func emptyGroups(plan *dungeon.Plan) int { + n := 0 + for li := range plan.Legs { + for gi := range plan.Legs[li].PullGroups { + if len(PackPositions(&plan.Legs[li].PullGroups[gi])) == 0 { + n++ + } + } + } + return n } // LegReport is one leg's comparison of the stored route and the live route. @@ -152,8 +206,11 @@ type Report struct { Status string `json:"status,omitempty"` // the plan's own status line Avoid int `json:"avoid_boxes"` Legs []LegReport `json:"legs"` - PlanMs float64 `json:"plan_ms"` // the legs' plans together - Tiles []TileFile `json:"tiles"` + // EmptyGroups: pull groups with no position and no hold, skipped everywhere (not walked to, + // not in a pack count). + EmptyGroups int `json:"empty_groups,omitempty"` + PlanMs float64 `json:"plan_ms"` // the legs' plans together + Tiles []TileFile `json:"tiles"` } // Summary is the plan's one summary line. The aggregates are over the COMPLETE legs only: a @@ -192,8 +249,12 @@ func (r Report) Summary() string { if complete > 0 { mean = ratio / float64(complete) } - return fmt.Sprintf("%s: map %d, %d legs: %d complete, %d failed, %d indeterminate; mean ratio %.2f%s; plan %.0f ms", - r.Plan, r.Map, n, complete, failed, indet, mean, packsOn, r.PlanMs) + empty := "" + if r.EmptyGroups > 0 { + empty = fmt.Sprintf("; %d empty pull groups skipped", r.EmptyGroups) + } + return fmt.Sprintf("%s: map %d, %d legs: %d complete, %d failed, %d indeterminate; mean ratio %.2f%s%s; plan %.0f ms", + r.Plan, r.Map, n, complete, failed, indet, mean, packsOn, empty, r.PlanMs) } // Oracle builds the plan's report. The walk it plans is the driver's own, leg by leg: the @@ -214,7 +275,7 @@ func Oracle(p *navmesh.Planner, mmapsDir string, plan *dungeon.Plan) (Report, er if err := CheckName(plan.Name); err != nil { return Report{}, fmt.Errorf("plan file: %w", err) } - rep := Report{Plan: plan.Name, Map: plan.Map, Status: plan.Status, Avoid: len(plan.Avoid)} + rep := Report{Plan: plan.Name, Map: plan.Map, Status: plan.Status, Avoid: len(plan.Avoid), EmptyGroups: emptyGroups(plan)} scratch := p.Scratch() defer scratch.Close() scratch.SetAvoid(plan.Map, dungeon.NavBoxes(plan.Avoid)) @@ -558,28 +619,15 @@ func pullGroupsOf(l *dungeon.Leg, paths []navmesh.Path, segs []Segment) []PullOn var out []PullOnPath for gi := range l.PullGroups { g := &l.PullGroups[gi] - radius := g.Radius - if radius <= 0 { - radius = DefaultPackRadius - } var route []Vec3 for i := range segs { if segs[i].Group != gi && len(paths[i].Points) > 0 { route = append(route, paths[i].Points...) } } - pg := PullOnPath{Group: gi, RadiusYd: float64(radius), Entries: g.Entries} - for i := range g.Positions { - if len(route) == 0 { - continue - } - near := NearestPolyline(route, vec(g.Positions[i])) - if !pg.OnPath || near.Dist2D < pg.NearYd { - pg.NearYd, pg.DZYd = near.Dist2D, near.DZ - } - if OnCorridor(near, float64(radius)) { - pg.OnPath = true - } + pg, ok := GroupOnRoute(gi, g, route) + if !ok { + continue // an empty group: not in the pack counts } out = append(out, pg) } @@ -699,6 +747,9 @@ func legWalk(l *dungeon.Leg) []storedSpec { } for gi := range l.PullGroups { g := &l.PullGroups[gi] + if len(PackPositions(g)) == 0 { + continue // an empty group has nothing to pull: no walk to it (the centroid of nothing is NaN) + } for i := range g.Approach { add(g.Approach[i], "approach", gi) } From a2a3632ab2ba5880576a0d76854af911701123ec Mon Sep 17 00:00:00 2001 From: pjerra <71784785+pjerra@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:59:24 +0200 Subject: [PATCH 6/6] route-oracle: NearYd is the minimum over a group's positions; folded 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. --- cmd/turtle-agent/route_oracle_bosses_text.go | 14 +++-- internal/routeoracle/bosses.go | 12 +++-- internal/routeoracle/bosses_cgo_test.go | 56 +++++++++++++++++++- internal/routeoracle/bosses_test.go | 21 ++++++++ internal/routeoracle/oracle.go | 7 +-- 5 files changed, 97 insertions(+), 13 deletions(-) diff --git a/cmd/turtle-agent/route_oracle_bosses_text.go b/cmd/turtle-agent/route_oracle_bosses_text.go index 1beeb86b..f63e9ae6 100644 --- a/cmd/turtle-agent/route_oracle_bosses_text.go +++ b/cmd/turtle-agent/route_oracle_bosses_text.go @@ -91,18 +91,18 @@ func bossesText(rep routeoracle.BossesReport) ([]byte, error) { for _, g := range l.Packs { if !g.OnPath { fmt.Fprintf(w, "leg %d (%s): pack group %d of stored leg %d (entries %v) nearest position %.1f yd (dz %.1f) from the live path, radius %.0f yd\n", - l.Leg, l.Name, g.Group, g.Leg, g.Entries, g.NearYd, g.DZYd, g.RadiusYd) + l.Leg, l.Name, g.Group, legOf(g.Leg), g.Entries, g.NearYd, g.DZYd, g.RadiusYd) } } for _, h := range l.Hand { if !h.Redundant { fmt.Fprintf(w, "leg %d (%s): hand %s (stored leg %d, seq %d) at %.1f,%.1f,%.1f is %.1f yd from the live path\n", - l.Leg, l.Name, h.Kind, h.Leg, h.Seq, h.At.X, h.At.Y, h.At.Z, h.NearYd) + l.Leg, l.Name, h.Kind, legOf(h.Leg), h.Seq, h.At.X, h.At.Y, h.At.Z, h.NearYd) } } for _, b := range l.Breaks { fmt.Fprintf(w, "leg %d (%s): stored break (stored leg %d) via %d (%s) at %.1f,%.1f,%.1f: live path passes it %v\n", - l.Leg, l.Name, b.Leg, b.Via, b.Kind, b.At.X, b.At.Y, b.At.Z, b.Touched) + l.Leg, l.Name, legOf(b.Leg), b.Via, b.Kind, b.At.X, b.At.Y, b.At.Z, b.Touched) } for _, j := range l.ZJumps { fmt.Fprintf(w, "leg %d (%s): z jump %.1f yd at %.1f,%.1f,%.1f\n", l.Leg, l.Name, j.DropYd, j.At[0], j.At[1], j.At[2]) @@ -129,3 +129,11 @@ func bridgedOf(l *routeoracle.BossLeg) string { } return fmt.Sprintf("%s, %.0f yd short", l.Bridged.Status, l.Bridged.Live.EndGapYd) } + +// legOf is a folded row's stored leg index (0 when the field is absent). +func legOf(p *int) int { + if p == nil { + return 0 + } + return *p +} diff --git a/internal/routeoracle/bosses.go b/internal/routeoracle/bosses.go index c6d0539e..b325f556 100644 --- a/internal/routeoracle/bosses.go +++ b/internal/routeoracle/bosses.go @@ -109,7 +109,7 @@ type BreakHit struct { // stand that is not a break and not the objective) and how far the live path passes from it. type HandPoint struct { Seq int `json:"seq"` - Leg int `json:"leg,omitempty"` // boss-only mode: the stored leg + Leg *int `json:"leg,omitempty"` // boss-only mode: the stored leg (a pointer: leg 0 is written) Kind string `json:"kind"` At dungeon.Point `json:"at"` NearYd float64 `json:"near_yd"` @@ -383,7 +383,7 @@ func planBossLeg(sc *navmesh.Planner, plan *dungeon.Plan, o objective, from Vec3 for _, li := range o.legs { for _, b := range breaksOf(&plan.Legs[li], path.Points, end, path.Status == navmesh.Partial) { if folded { - b.Leg = li + b.Leg = intPtr(li) } bl.Breaks = append(bl.Breaks, b) } @@ -408,7 +408,7 @@ func measureBossLeg(bl *BossLeg, plan *dungeon.Plan, o objective, path navmesh.P continue } if folded { - pg.Leg = li + pg.Leg = intPtr(li) } bl.Packs = append(bl.Packs, pg) if pg.OnPath { @@ -417,7 +417,7 @@ func measureBossLeg(bl *BossLeg, plan *dungeon.Plan, o objective, path navmesh.P } for _, h := range handPoints(l, route, o.at) { if folded { - h.Leg = li + h.Leg = intPtr(li) } bl.Hand = append(bl.Hand, h) if h.Redundant { @@ -426,7 +426,7 @@ func measureBossLeg(bl *BossLeg, plan *dungeon.Plan, o objective, path navmesh.P } for _, b := range breaksOf(l, route, Vec3{}, false) { if folded { - b.Leg = li + b.Leg = intPtr(li) } bl.Breaks = append(bl.Breaks, b) } @@ -545,3 +545,5 @@ func breakHits(sc *navmesh.Planner, mapID uint32, plan *dungeon.Plan, legs []int }) return out, nil } + +func intPtr(v int) *int { return &v } diff --git a/internal/routeoracle/bosses_cgo_test.go b/internal/routeoracle/bosses_cgo_test.go index 00b59ac5..e758ff60 100644 --- a/internal/routeoracle/bosses_cgo_test.go +++ b/internal/routeoracle/bosses_cgo_test.go @@ -3,6 +3,7 @@ package routeoracle import ( + "encoding/json" "math" "strings" "testing" @@ -285,7 +286,7 @@ func TestBossOnlyChainFoldsBosslessLegs(t *testing.T) { for _, h := range a.Hand { if h.Kind == "to" { to++ - if h.Leg == 0 && h.At.X == 46 { + if h.Leg != nil && *h.Leg == 0 && h.At.X == 46 { sawHallTo++ } } @@ -293,7 +294,7 @@ func TestBossOnlyChainFoldsBosslessLegs(t *testing.T) { if to != 2 || sawHallTo != 1 { t.Errorf("row 0 to points among the hand points: %d (hall's: %d), hand %+v", to, sawHallTo, a.Hand) } - if len(a.Packs) != 2 || a.Packs[0].Leg != 0 || a.Packs[1].Leg != 0 { + if len(a.Packs) != 2 || a.Packs[0].Leg == nil || *a.Packs[0].Leg != 0 || a.Packs[1].Leg == nil || *a.Packs[1].Leg != 0 { t.Errorf("row 0 packs %+v, want the hall's two groups", a.Packs) } plain, err := Bosses(p, dir, plan, ObjectivesPlan) @@ -426,3 +427,54 @@ func TestEmptyPullGroupIsSkippedAndCounted(t *testing.T) { t.Errorf("summaries %q / %q", walk.Summary(), live.Summary()) } } + +// A folded boss-only row says which stored leg each pack, hand point and break belongs to, and +// writes the index even for leg 0 (a pointer, not an omitted zero). Leg 1 here is the NON-zero +// case: dropping the assignment leaves the field nil and fails. +func TestBossOnlyRowNamesTheStoredLegOfEachItem(t *testing.T) { + plan := `{"version":1,"name":"fold","map":0,"entrance":{"x":12,"y":12,"z":0},"legs":[ + {"name":"a","to":{"x":30,"y":12,"z":0},"pull_groups":[{"entries":[1],"positions":[{"x":30,"y":14,"z":0}]}]}, + {"name":"b","to":{"x":46,"y":16,"z":0},"via":[{"x":40,"y":12,"z":0,"check_skip":"fixture"}], + "pull_groups":[{"entries":[2],"positions":[{"x":46,"y":14,"z":0}]}], + "boss":{"entry":9,"position":{"x":48,"y":24,"z":0}}}]}` + dir := navfixture.WriteDir(t, 0, navfixture.LCorridor(0)) + p := navmesh.NewPlanner(dir) + t.Cleanup(p.Close) + dp, err := dungeon.Parse("fold.json", []byte(plan)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + rep, err := Bosses(p, dir, dp, ObjectivesBosses) + if err != nil { + t.Fatal(err) + } + if len(rep.Legs) != 1 || rep.Legs[0].Status != "complete" || len(rep.Legs[0].Legs) != 2 { + t.Fatalf("rows %+v", rep.Legs) + } + row := rep.Legs[0] + got := func(p *int) int { + if p == nil { + return -1 + } + return *p + } + if len(row.Packs) != 2 || got(row.Packs[0].Leg) != 0 || got(row.Packs[1].Leg) != 1 { + t.Errorf("pack legs: %+v", row.Packs) + } + var toLegs []int + for _, h := range row.Hand { + if h.Kind == "to" { + toLegs = append(toLegs, got(h.Leg)) + } + } + if len(toLegs) != 2 || toLegs[0] != 0 || toLegs[1] != 1 { + t.Errorf("to points' legs %v, want [0 1]", toLegs) + } + if len(row.Breaks) != 1 || got(row.Breaks[0].Leg) != 1 { + t.Errorf("break legs: %+v", row.Breaks) + } + b, err := json.Marshal(row.Packs) + if err != nil || !strings.Contains(string(b), `"leg":0`) || !strings.Contains(string(b), `"leg":1`) { + t.Errorf("packs json %s (err %v): leg 0 must be written too", b, err) + } +} diff --git a/internal/routeoracle/bosses_test.go b/internal/routeoracle/bosses_test.go index a047bfc1..95e8c350 100644 --- a/internal/routeoracle/bosses_test.go +++ b/internal/routeoracle/bosses_test.go @@ -1,6 +1,7 @@ package routeoracle import ( + "math" "testing" "github.com/pjerra/turtle-agent/internal/dungeon" @@ -53,3 +54,23 @@ func TestGroupOnRouteHoldAndEmptyGroups(t *testing.T) { t.Errorf("empty groups %d, want 2", n) } } + +// NearYd is the nearest of a group's positions, not the last one examined: with an off-path +// group whose nearest position comes first it still reports the nearest. +func TestGroupNearYdIsTheMinimumOverAllPositions(t *testing.T) { + route := []Vec3{{0, 0, 0}, {100, 0, 0}} + g := &dungeon.PullGroup{Radius: 10, Positions: []dungeon.Point{{X: 50, Y: 30, Z: 0}, {X: 50, Y: 90, Z: 0}}} + got, ok := GroupOnRoute(0, g, route) + if !ok || got.OnPath || math.Abs(got.NearYd-30) > 0.01 { + t.Errorf("off-path group: %+v ok %v, want near 30.0 (not the last position's 90.0)", got, ok) + } + // Same answer whatever the order, and when a later position puts the group on the path. + g2 := &dungeon.PullGroup{Radius: 10, Positions: []dungeon.Point{{X: 50, Y: 90, Z: 0}, {X: 50, Y: 30, Z: 0}}} + if got, _ := GroupOnRoute(0, g2, route); math.Abs(got.NearYd-30) > 0.01 { + t.Errorf("reversed: near %.1f", got.NearYd) + } + g3 := &dungeon.PullGroup{Radius: 10, Positions: []dungeon.Point{{X: 50, Y: 20, Z: 0}, {X: 50, Y: 60, Z: 0}, {X: 50, Y: 5, Z: 0}}} + if got, _ := GroupOnRoute(0, g3, route); !got.OnPath || math.Abs(got.NearYd-5) > 0.01 { + t.Errorf("on-path group: %+v, want on and near 5.0", got) + } +} diff --git a/internal/routeoracle/oracle.go b/internal/routeoracle/oracle.go index 91fa6585..9af80c52 100644 --- a/internal/routeoracle/oracle.go +++ b/internal/routeoracle/oracle.go @@ -84,7 +84,7 @@ type StoredPoint struct { // and whether the leg's live route touched it. type BreakTouched struct { Via int `json:"via"` // the waypoint's index in the leg's via, from 0 - Leg int `json:"leg,omitempty"` // boss-only mode: the stored leg it belongs to + Leg *int `json:"leg,omitempty"` // boss-only mode: the stored leg it belongs to (a pointer: leg 0 is written) Kind string `json:"kind"` At dungeon.Point `json:"at"` Touched bool `json:"touched"` @@ -95,7 +95,7 @@ type BreakTouched struct { // positions. type PullOnPath struct { Group int `json:"group"` - Leg int `json:"leg,omitempty"` // boss-only mode: the stored leg the group belongs to (absent: leg 0, or not a folded row) + Leg *int `json:"leg,omitempty"` // boss-only mode: the stored leg the group belongs to (a pointer: leg 0 is written; absent outside a folded row) OnPath bool `json:"on_path"` NearYd float64 `json:"near_yd"` // horizontal distance of the nearest position to the route DZYd float64 `json:"dz_yd"` @@ -135,7 +135,8 @@ func GroupOnRoute(gi int, g *dungeon.PullGroup, route []Vec3) (pg PullOnPath, ok } for i := range pos { near := NearestPolyline(route, vec(pos[i])) - if !pg.OnPath || near.Dist2D < pg.NearYd { + // NearYd is the minimum over ALL positions, whether or not the group is on the path yet. + if i == 0 || near.Dist2D < pg.NearYd { pg.NearYd, pg.DZYd = near.Dist2D, near.DZ } if OnCorridor(near, float64(radius)) {