diff --git a/README.md b/README.md index 677411ea..a0b73f37 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 [--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]... ... @@ -855,6 +856,27 @@ 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, 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`. + +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). + +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 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. + +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/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_bosses_text.go b/cmd/turtle-agent/route_oracle_bosses_text.go new file mode 100644 index 00000000..f63e9ae6 --- /dev/null +++ b/cmd/turtle-agent/route_oracle_bosses_text.go @@ -0,0 +1,139 @@ +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 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 +} + +// 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, 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: 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] + 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 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, 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, 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, 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]) + } + } + 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) +} + +// 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/cmd/turtle-agent/route_oracle_cmd.go b/cmd/turtle-agent/route_oracle_cmd.go new file mode 100644 index 00000000..39e84c0b --- /dev/null +++ b/cmd/turtle-agent/route_oracle_cmd.go @@ -0,0 +1,290 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "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 [--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 +// 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"} + +// 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 (-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, ", ")+")") + 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 *mode != modeWalk && *mode != modeBosses { + 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) + return exitUsage + } + *out = outDir + 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 + } + // 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() + var summary string + var tiles []routeoracle.TileFile + if *mode == modeBosses { + rep, err := routeoracle.Bosses(planner, *mmaps, p, *objectives) + 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", 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) + } + } + if failed { + return exitRouteOracle + } + return exitOK +} + +// 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 + } + 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. +func writeReport(rep any, txt []byte, dir, name string) error { + b, err := json.MarshalIndent(rep, "", " ") + if err != nil { + return err + } + 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 +// 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 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 { + 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.Reason != "" { + fmt.Fprintf(w, "leg %d (%s): %s: %s\n", lr.Leg, lr.Name, lr.Status, lr.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 { + 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 && 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) + } + } + 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..ebf118b3 --- /dev/null +++ b/cmd/turtle-agent/route_oracle_cmd_test.go @@ -0,0 +1,224 @@ +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 +// 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) + } + } +} + +// 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)) + } + } +} + +// 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) + } +} + +// --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/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) + } +} diff --git a/internal/routeoracle/bosses.go b/internal/routeoracle/bosses.go new file mode 100644 index 00000000..b325f556 --- /dev/null +++ b/internal/routeoracle/bosses.go @@ -0,0 +1,549 @@ +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 +// 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, 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 +) + +// 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 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 { + 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 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, 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 (a pointer: leg 0 is written) + 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"` + 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 +// 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 int + Hand, HandRedundant int + ZJumps int + LiveYd float64 +} + +func (s *Sums) add(l *BossLeg) { + s.Packs += len(l.Packs) + s.PacksOn += l.PacksOnPath + 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() + unit := "legs" + if r.Objectives == ObjectivesBosses { + unit = "boss objectives" + } + if t.Legs == 0 { + return fmt.Sprintf("%s: map %d, no %s", r.Plan, r.Map, unit) + } + 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%%)", 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)) + } + 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) + } + if r.EmptyGroups > 0 { + s += fmt.Sprintf("; %d empty pull groups skipped", r.EmptyGroups) + } + return s +} + +// 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. +// +// 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, 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), + 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 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, o, cur, objectives == ObjectivesBosses) + 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, o, anchor, objectives == ObjectivesBosses) + 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", last, bl.Name, fmtPos(end), dist3(end, vec(o.at))) + cur = end + } + anchor = vec(o.at) + 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 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(o.at)) + if err != nil { + 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(o.at)) + bl.Reason = firstLine(path.Reason) + end := pathEndOr(path, from) + if path.Status == navmesh.Complete { + measureBossLeg(&bl, plan, o, path, folded) + return bl, end, nil + } + for _, li := range o.legs { + for _, b := range breaksOf(&plan.Legs[li], path.Points, end, path.Status == navmesh.Partial) { + if folded { + b.Leg = intPtr(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, last, bl.Name, err) + } + return bl, end, nil +} + +// 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 + 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 = intPtr(li) + } + bl.Packs = append(bl.Packs, pg) + if pg.OnPath { + bl.PacksOnPath++ + } + } + for _, h := range handPoints(l, route, o.at) { + if folded { + h.Leg = intPtr(li) + } + bl.Hand = append(bl.Hand, h) + if h.Redundant { + bl.HandRedundant++ + } + } + for _, b := range breaksOf(l, route, Vec3{}, false) { + if folded { + b.Leg = intPtr(li) + } + bl.Breaks = append(bl.Breaks, b) + } + } + 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: 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) { + 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 +} + +// 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, 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[[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) + 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, r.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(r.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[[2]int{r.leg, r.via}] = true + out = append(out, h) + } + // The exact foot before a named hop. + 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 { + if out[i].Leg != out[j].Leg { + return out[i].Leg < out[j].Leg + } + return out[i].Via < out[j].Via + }) + 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 new file mode 100644 index 00000000..e758ff60 --- /dev/null +++ b/internal/routeoracle/bosses_cgo_test.go @@ -0,0 +1,480 @@ +//go:build cgo + +package routeoracle + +import ( + "encoding/json" + "math" + "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 bossesFixtureParts(t *testing.T) (*navmesh.Planner, string, *dungeon.Plan) { + 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) + } + 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) + } + 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"}, ObjectivesPlan); 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, ObjectivesPlan) + 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) + } +} + +// 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 != nil && *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 == 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) + 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()) + } +} + +// 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 new file mode 100644 index 00000000..95e8c350 --- /dev/null +++ b/internal/routeoracle/bosses_test.go @@ -0,0 +1,76 @@ +package routeoracle + +import ( + "math" + "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) + } +} + +// 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/metrics.go b/internal/routeoracle/metrics.go new file mode 100644 index 00000000..6a9bb03e --- /dev/null +++ b/internal/routeoracle/metrics.go @@ -0,0 +1,159 @@ +// 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} + } + } + 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])} + 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..9af80c52 --- /dev/null +++ b/internal/routeoracle/oracle.go @@ -0,0 +1,822 @@ +package routeoracle + +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" +) + +// 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 walk's goal + LiveLenYd float64 `json:"live_len_yd"` + // 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"` +} + +// 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 walk points, from 1 + Kind string `json:"kind"` + At dungeon.Point `json:"at"` + NearYd float64 `json:"near_yd"` + // 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 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 (a pointer: leg 0 is written) + 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 leg's live route's corridor holds one of its +// positions. +type PullOnPath struct { + Group int `json:"group"` + 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"` + 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])) + // 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)) { + 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. +type LegReport struct { + Leg int `json:"leg"` + Name string `json:"name,omitempty"` + 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 stored for the leg). + Objective string `json:"objective"` + // 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. +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"` + // 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 +// 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, failed, indet, other int + var ratio, on, packs float64 + for i := range r.Legs { + switch r.Legs[i].Status { + case "complete": + complete++ + case "indeterminate": + indet++ + case string(navmesh.Partial), string(navmesh.NoPath), "no_objective": + failed++ + default: + other++ + } + if r.Legs[i].Status == "complete" { + 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 complete > 0 { + mean = ratio / float64(complete) + } + 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 +// 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). +// +// 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), EmptyGroups: emptyGroups(plan)} + scratch := p.Scratch() + defer scratch.Close() + scratch.SetAvoid(plan.Map, dungeon.NavBoxes(plan.Avoid)) + + 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: point(cur)} + obj, kind := legObjective(l) + lr.To = obj + lr.Objective = kind + + 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 + } + + 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)} + } + 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 + } + } + 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 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) + rep.Tiles = t + if err != nil { + return rep, err + } + return rep, nil +} + +// 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 + } + lr.ZJumps = ZJumps(samples) + lr.Breaks = breaksOf(l, route, failingEnd, failingPartial) + lr.Pulls = pullGroupsOf(l, paths, lr.Segments) +} + +// 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 + } + 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 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] + } + 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 +} + +// 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] + kind := breakKind(w) + if kind == "" { + continue + } + b := BreakTouched{Via: vi, Kind: kind, At: w.Point} + if len(route) > 0 { + near := NearestPolyline(route, vec(w.Point)) + if near.Dist3D <= BreakNearYd { + b.Touched = true + b.Why = fmt.Sprintf("the route passes %.1f yd from it", near.Dist3D) + } + } + 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) + } + 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") + } + return strings.Join(parts, ",") +} + +// 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] + var route []Vec3 + for i := range segs { + if segs[i].Group != gi && len(paths[i].Points) > 0 { + route = append(route, paths[i].Points...) + } + } + pg, ok := GroupOnRoute(gi, g, route) + if !ok { + continue // an empty group: not in the pack counts + } + out = append(out, pg) + } + 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) { + 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 stand point. +func lastStepPoint(steps []dungeon.Step) (dungeon.Point, bool) { + for i := len(steps) - 1; i >= 0; i-- { + if p, ok := stepPoint(steps[i]); ok { + return p, true + } + } + return dungeon.Point{}, false +} + +// 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 + group int + via *dungeon.Waypoint +} + +// 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, group int) *storedSpec { + out = append(out, storedSpec{at: vec(p), kind: kind, group: group}) + return &out[len(out)-1] + } + 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", -1) + } + 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) + } + if g.Hold != nil { + add(g.Hold.Spot, "hold", gi) + continue + } + 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 i := range l.Boss.Approach { + add(l.Boss.Approach[i], "boss_approach", -1) + } + add(l.Boss.Position, "boss", -1) + } + for i := range l.AfterBoss { + if p, ok := stepPoint(l.AfterBoss[i]); ok { + add(p, "stand", -1) + } + } + 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 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 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 +// 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("tile %s: %w", 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..e0f4f9b9 --- /dev/null +++ b/internal/routeoracle/oracle_cgo_test.go @@ -0,0 +1,286 @@ +//go:build cgo + +package routeoracle + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/pjerra/turtle-agent/internal/dungeon" + "github.com/pjerra/turtle-agent/internal/navmesh" + "github.com/pjerra/turtle-agent/internal/navmesh/navfixture" +) + +// 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}]}]}]}` + +// 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() + polys := navfixture.LCorridor(0) + polys = append(polys, extra...) + dir := navfixture.WriteDir(t, 0, polys) + p := navmesh.NewPlanner(dir) + t.Cleanup(p.Close) + 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, dp +} + +// 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 len(rep.Legs) != 4 { + t.Fatalf("legs: %d", len(rep.Legs)) + } + // 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]) + } + } + // 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 strings.Join(kinds, ",") != "to,approach,pull,boss" { + t.Errorf("leg 1 hop kinds %v", kinds) + } + 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 br.StoredMeanYd > 1 { + t.Errorf("leg 1 stored mean %.1f yd, want ~0 (the route visits every stored point)", br.StoredMeanYd) + } + // 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 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) + } + // 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) + } + if !strings.Contains(af.Reason, "leg 2 (voidpull) failed") { + t.Errorf("leg 3 reason %q", af.Reason) + } + // 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) + } +} + +// 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) + } + 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}, + {"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 route 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 := 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 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, _, _ := oracleFixture(t, walkPlan) + 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 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. + 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 := breaksOf(&l, route, Vec3{10, 0, 0}, true) + 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 := oracleFixture(t, walkPlan) + 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)) + } +} 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) + } +}