Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9ca6522
plan-export: Sergeant Bly in the run leader's rows: a boss hold done …
pjerra Oct 21, 2026
c73d750
Test: Validate refuses an encounter flag on a units hold even with no…
pjerra Oct 21, 2026
6727412
plan-export: use_object with via_item is a use_on row; --with-optiona…
pjerra Oct 21, 2026
e8329d1
Zul'Farrak: the gong's fail_text says what a second ring does (a new …
pjerra Oct 21, 2026
a93caba
plan-export: a gossip step may set dead_is_done (row flag 512); set o…
pjerra Oct 22, 2026
74bbea2
Zul'Farrak: Gahz'rilla's interruptible casts as boss tactics (Freeze …
Oct 26, 2026
051848d
Zul'Farrak Gahz'rilla leg: two approach stands so the run leader pull…
Oct 26, 2026
6b87b1c
Merge prep/zf-gahz: the Zul'Farrak plan TortoiseBots runs (Bly units …
pjerra Nov 2, 2026
0858295
Merge remote-tracking branch 'origin/dev' into feat/ta243-zf-bosses
pjerra Nov 2, 2026
1bd182d
Zul'Farrak: Zel'jeb wing and Farraki Arena legs (units hold, gossip 6…
pjerra Nov 2, 2026
59e476a
Zul'Farrak: the bly hold ends when its gossip NPC was dead
pjerra Nov 2, 2026
2e24079
README: a module without the dead-gossip flag loads the hold and igno…
pjerra Nov 2, 2026
038a192
skip_if_dead_of_gossip exports flag 32768: 16384 is TortoiseBots kPla…
pjerra Nov 2, 2026
b04b465
Zul'Farrak: the Farraki Arena units hold goes back to its gossip when…
pjerra Nov 2, 2026
d8a3040
plan-export: a cut hop keeps off the plan's leave spawns
pjerra Nov 2, 2026
2f8583e
plan-export: a cut hop's clearance is measured along its segments, no…
pjerra Nov 2, 2026
1f06bbf
Merge remote-tracking branch 'origin/dev' into feat/ta243-zf-bosses
pjerra Nov 2, 2026
7cdced0
Zul'Farrak: farraki_arena and zeljeb are skip_export opt-in legs
pjerra Nov 2, 2026
03e4c6c
planexport: number exported legs from one map (with-legs, with-optional)
pjerra Nov 2, 2026
53f4fe5
route aggro: pin zulfarrak at 66 (#391: export reaches Bly and Ukorz)
pjerra Nov 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 32 additions & 16 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion cmd/turtle-agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ func usage(w io.Writer) {
turtle-agent dashboard [--listen 127.0.0.1:8790] [--agents auto|<rpc,...>] [--tests-dir DIR] [--traces-dir DIR] [--plans-dir DIR[,DIR]] [--edit --mmaps-dir DIR]
turtle-agent dashboard-replay <recording-dir|file.jsonl> [--to host:port] [--speed N] [--max-gap D] [--loop] [--dry-run] [--i-know] | convert <api-dump-dir> [--out <dir>]
turtle-agent minimap-extract --client DIR --out DIR [--maps 70,209|all-instances] [--mmaps DIR] [--list]
turtle-agent plan-export [--dir dungeons] [--allow-draft] [--drop-companions-loot] [--carrier-chests] [--skip-legs NAME,...] [--mmaps DIR] [--rev SHA] [--stamp YYYYMMDDHHMMSS] [--force] --out FILE|DIR (<plan>... | --all-live)
turtle-agent plan-export [--dir dungeons] [--allow-draft] [--drop-companions-loot] [--carrier-chests] [--skip-legs NAME,...] [--with-optional NAME,...] [--with-legs NAME,...] [--mmaps DIR] [--rev SHA] [--stamp YYYYMMDDHHMMSS] [--force] --out FILE|DIR (<plan>... | --all-live)
turtle-agent plan-check [--dir dungeons] --mmaps DIR [--from-leg N [--from-pull-group N]] [--json] <plan>...
turtle-agent path-check --mmaps DIR --map N x y z x y z [--json]
turtle-agent patrol-record <slot-dir> --map M --area x,y,r [--z Z] --minutes N [--sample-ms 1000] [--out FILE] [--spawns DIR] [--mmaps DIR] [--entries e1,e2]
Expand Down
6 changes: 3 additions & 3 deletions cmd/turtle-agent/observe_start.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,13 @@ func observeStartFrom(plan *dungeon.Plan, leg int, group *int, step int, planner
if err != nil {
return dungeon.PartialStart{}, err
}
rowLeg, _ := planexport.LegNumber(plan, leg)
rowLeg, _ := planexport.LegNumber(planexport.ExportedLegs(plan, planexport.Options{}), leg)
return dungeon.PartialStart{Words: "from step " + strconv.Itoa(n), Step: n, Leg: leg, LegNumber: rowLeg, ExpectLeg: plan.Legs[leg].Name, Point: at, HavePoint: ok}, nil
default:
if leg < 0 || leg >= len(plan.Legs) {
return dungeon.PartialStart{}, fmt.Errorf("from_leg %d: the plan has legs %v (numbers 0-%d)", leg, legNames(plan), len(plan.Legs)-1)
}
rowLeg, ok := planexport.LegNumber(plan, leg)
rowLeg, ok := planexport.LegNumber(planexport.ExportedLegs(plan, planexport.Options{}), leg)
if !ok {
return dungeon.PartialStart{}, fmt.Errorf("from_leg %d: leg %q is optional (it needs item %d) and the exporter leaves optional legs out of the run leader's rows; start at a later leg, or pass observe.from_step", leg, plan.Legs[leg].Name, plan.Legs[leg].Optional.Item)
}
Expand Down Expand Up @@ -245,7 +245,7 @@ func observeStepInfo(plan *dungeon.Plan, planner *navmesh.Planner, serverSteps,
at, have = dungeon.Point{}, false
}
name := ""
if pi, ok := planexport.PlanLeg(plan, s.Leg); ok {
if pi, ok := planexport.PlanLeg(r.LegMap(plan), s.Leg); ok {
name = plan.Legs[pi].Name
}
if !haveLeg {
Expand Down
16 changes: 14 additions & 2 deletions cmd/turtle-agent/plan_export_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import (
"github.com/pjerra/turtle-agent/internal/planexport"
)

const planExportUsage = "usage: turtle-agent plan-export [--dir dungeons] [--allow-draft] [--drop-companions-loot] [--carrier-chests] [--gm-cross-direct] [--approach-hops] [--skip-legs NAME,...] [--mmaps DIR] [--rev SHA] [--stamp YYYYMMDDHHMMSS] [--force] --out FILE|DIR (<plan>... | --all-live)"
const planExportUsage = "usage: turtle-agent plan-export [--dir dungeons] [--allow-draft] [--drop-companions-loot] [--carrier-chests] [--gm-cross-direct] [--approach-hops] [--skip-legs NAME,...] [--with-optional NAME,...] [--with-legs NAME,...] [--mmaps DIR] [--rev SHA] [--stamp YYYYMMDDHHMMSS] [--force] --out FILE|DIR (<plan>... | --all-live)"

// migrationName is what a TortoiseBots world migration is called (data/sql/world/).
var migrationName = regexp.MustCompile(`^\d{14}_world\.sql$`)
Expand All @@ -41,7 +41,9 @@ func planExportCmd(args []string, stdout, stderr io.Writer) int {
carrierChests := fs.Bool("carrier-chests", false, "with --drop-companions-loot, export such a leg's loot_object steps as carrier rows: a move to its stand, the loot (gone-is-done flag 256), and — when the leg has leftover_entries — their sweep pull at the point; the bots' looting still has to be turned off for the leg in the setup, or a module's trapped-chest rule has to skip them; flag 256 needs a TortoiseBots module with the gone-is-done step flag: an older module ignores it and pauses on a despawned grave after a wipe (then a .bot run skip skips the row)")
gmCrossDirect := fs.Bool("gm-cross-direct", false, "export a gm_cross waypoint as a move_direct (a straight walk to the landing point) instead of refusing the plan; only for a gap that is a drop the line crosses, and not tried live")
approach := fs.Bool("approach-hops", false, "with --mmaps, also cut the hop to a pull, boss or hold that is more than 50 yd from the anchor before it into move rows along the navmesh path (the run leader's own hops are at most 8 of 40 yd)")
skipLegs := fs.String("skip-legs", "", "leg names (comma list) left out of the rows, each with a warning; the later legs close up (Zul'Farrak's bly: a boss that turns hostile after his gossip, which the run leader cannot do). An unknown name is refused")
skipLegs := fs.String("skip-legs", "", "leg names (comma list) left out of the rows, each with a warning; the later legs close up. An unknown name is refused")
withOptional := fs.String("with-optional", "", "optional leg names (comma list) written into the rows anyway (Zul'Farrak's gahzrilla), each with a warning: the item the leg needs is the operator's to give to the lead bot before .bot run start (a GM .additem); a name that is no optional leg is refused")
withLegs := fs.String("with-legs", "", "skip_export leg names (comma list) written into the rows anyway (Zul'Farrak's farraki_arena, zeljeb), each with a warning; a name that is no skip_export leg is refused")
force := fs.Bool("force", false, "let --out replace an existing file (never edit a migration that is applied)")
if err := fs.Parse(args); err != nil {
return exitUsage
Expand Down Expand Up @@ -111,6 +113,16 @@ func planExportCmd(args []string, stdout, stderr io.Writer) int {
opt.SkipLegs = append(opt.SkipLegs, n)
}
}
for _, n := range strings.Split(*withOptional, ",") {
if n = strings.TrimSpace(n); n != "" {
opt.WithOptional = append(opt.WithOptional, n)
}
}
for _, n := range strings.Split(*withLegs, ",") {
if n = strings.TrimSpace(n); n != "" {
opt.WithLegs = append(opt.WithLegs, n)
}
}
var naive *navmesh.Planner
if *mmaps != "" {
planner = navmesh.NewPlanner(*mmaps)
Expand Down
67 changes: 66 additions & 1 deletion cmd/turtle-agent/plan_export_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ func TestPlanExportSkipLegs(t *testing.T) {
if !strings.Contains(string(data), "'gossip', 7607,") || !strings.Contains(string(data), "'ukorz'") || strings.Contains(string(data), "'bly'") {
t.Fatal("the rows lack the Weegli gossip or Ukorz, or still have Bly")
}
if !strings.Contains(errs, "leg 16 (bly): left out of the export by the exporter's skip-legs option") {
if !strings.Contains(errs, "leg 18 (bly): left out of the export by the exporter's skip-legs option") {
t.Fatalf("stderr %q", errs)
}
code, _, errs = runExport("--dir", "../../dungeons", "--drop-companions-loot", "--skip-legs", "bli", "--stamp", "20261021005901", "--out", dir, "zulfarrak")
Expand Down Expand Up @@ -194,3 +194,68 @@ func TestPlanExportCarrierChests(t *testing.T) {
t.Fatalf("%d grave loot rows, want 31", n)
}
}

// --with-optional writes a named optional leg into the rows (the shipped Zul'Farrak plan's gahzrilla: the gong as a use_on row) with the
// warning that the Mallet is the operator's to give; a name that is no optional leg is refused before anything is written.
func TestPlanExportWithOptional(t *testing.T) {
dir := t.TempDir()
code, out, errs := runExport("--dir", "../../dungeons", "--drop-companions-loot", "--with-optional", "gahzrilla", "--stamp", "20261021180000", "--rev", "abc12345", "--out", dir, "zulfarrak")
if code != exitOK {
t.Fatalf("exit %d: %s", code, errs)
}
if !strings.Contains(out, "zulfarrak: map 209, 18 legs") || strings.Contains(out, "CUT") {
t.Fatalf("output %q", out)
}
data, err := os.ReadFile(filepath.Join(dir, "20261021180000_world.sql"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "'use_on', 141832, 1650.9, 1171.9, 10.9,") || !strings.Contains(string(data), "'gahzrilla'") {
t.Fatal("the rows lack the gong's use_on or the gahzrilla leg")
}
if !strings.Contains(errs, "leg 11 (gahzrilla): optional, in the rows by --with-optional: give Mallet of Zul'Farrak (item 9240) to the lead bot") {
t.Fatalf("stderr %q", errs)
}
code, _, errs = runExport("--dir", "../../dungeons", "--drop-companions-loot", "--with-optional", "courtyard", "--stamp", "20261021180001", "--out", dir, "zulfarrak")
if code == exitOK || !strings.Contains(errs, `with-optional names "courtyard", which is no optional leg`) {
t.Fatalf("a leg that is not optional: %d %q", code, errs)
}
if _, err := os.Stat(filepath.Join(dir, "20261021180001_world.sql")); err == nil {
t.Fatal("a file was written for a refused export")
}
}

// --with-legs writes named skip_export legs into the rows (the shipped Zul'Farrak plan's farraki_arena and zeljeb), and a name that is no
// skip_export leg is refused before anything is written.
func TestPlanExportWithLegs(t *testing.T) {
dir := t.TempDir()
code, out, errs := runExport("--dir", "../../dungeons", "--drop-companions-loot", "--with-legs", "farraki_arena,zeljeb", "--stamp", "20261021180002", "--rev", "abc12345", "--out", dir, "zulfarrak")
if code != exitOK {
t.Fatalf("exit %d: %s", code, errs)
}
if !strings.Contains(out, "zulfarrak: map 209, 19 legs") || strings.Contains(out, "CUT") {
t.Fatalf("output %q", out)
}
data, err := os.ReadFile(filepath.Join(dir, "20261021180002_world.sql"))
if err != nil {
t.Fatal(err)
}
for _, leg := range []string{"'farraki_arena'", "'zeljeb'"} {
if !strings.Contains(string(data), leg) {
t.Fatalf("the rows lack the %s leg", leg)
}
}
if !strings.Contains(string(data), "62498") {
t.Fatal("the rows lack the arena gossip")
}
if !strings.Contains(errs, "leg 8 (farraki_arena): skip_export, in the rows by --with-legs") || !strings.Contains(errs, "leg 10 (zeljeb): skip_export, in the rows by --with-legs") {
t.Fatalf("stderr %q", errs)
}
code, _, errs = runExport("--dir", "../../dungeons", "--drop-companions-loot", "--with-legs", "courtyard", "--stamp", "20261021180003", "--out", dir, "zulfarrak")
if code == exitOK || !strings.Contains(errs, `with-legs names "courtyard", which is no skip_export leg`) {
t.Fatalf("a leg that is not skip_export: %d %q", code, errs)
}
if _, err := os.Stat(filepath.Join(dir, "20261021180003_world.sql")); err == nil {
t.Fatal("a file was written for a refused export")
}
}
10 changes: 4 additions & 6 deletions cmd/turtle-agent/stage_check_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ func TestStageCheckZulfarrakGahzrillaRefused(t *testing.T) {
text := out.String()
for _, want := range []string{
"cannot be staged in a fresh instance",
"the boss of leg 8 velratha_b (entry 7795)",
"proposal: earlier_leg leg 8 velratha_b",
"the boss of leg 9 velratha_b (entry 7795)",
"proposal: earlier_leg leg 9 velratha_b",
} {
if !strings.Contains(text, want) {
t.Errorf("no %q in %q", want, text)
Expand Down Expand Up @@ -120,22 +120,20 @@ func stageFirstRowNote(t *testing.T, plan *dungeon.Plan, leg int) (string, bool)
p := navmesh.NewPlanner(t.TempDir())
defer p.Close()
_, _, note := stageFirstRow(plan, leg, p, 0)
if !strings.Contains(note, "is optional") {
if !strings.Contains(note, "is skipped by default") {
return note, false
}
return note, true
}

// 30. A plan with an optional leg: stageFirstRow falls back with the
// optional note.
// skipped-by-default note.
func TestStageFirstRowFallsBackForAnOptionalLeg(t *testing.T) {
plan := zulfarrakOptionalPlan(t)
if _, ok := stageFirstRowNote(t, plan, optionalLegIndex(plan)); !ok {
t.Fatal("want the optional fallback")
}
}

// 8. A missing leg is refused as invalid params (instead of staging leg 0).
func TestStageRunRefusesAMissingLeg(t *testing.T) {
r := &dungeonRunner{dir: "../../dungeons"}
_, err := stageRun(context.Background(), nil, r, nil, stageParams{Name: "zulfarrak"})
Expand Down
6 changes: 3 additions & 3 deletions cmd/turtle-agent/stage_methods.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,10 @@ func stageResultJSON(rep dungeon.StageReport, plan string) stageResult {
// observePlanRows): the first row of the leg in the server's rows when they
// can be reproduced, else the leg's first plan point with a note saying why.
func stageFirstRow(plan *dungeon.Plan, leg int, planner *navmesh.Planner, serverSteps int) (dungeon.Point, bool, string) {
if plan.Legs[leg].Optional != nil {
return dungeon.Point{}, false, fmt.Sprintf("leg %s is optional: the exporter of this build leaves it out of the rows; the leg's first plan point is used", plan.Legs[leg].Name)
if plan.Legs[leg].Optional != nil || plan.Legs[leg].SkipExport {
return dungeon.Point{}, false, fmt.Sprintf("leg %s is skipped by default (optional or skip_export): the exporter of this build leaves it out of the rows; the leg's first plan point is used", plan.Legs[leg].Name)
}
rowLeg, ok := planexport.LegNumber(plan, leg)
rowLeg, ok := planexport.LegNumber(planexport.ExportedLegs(plan, planexport.Options{}), leg)
if !ok {
return dungeon.Point{}, false, fmt.Sprintf("leg %s has no rows here; the leg's first plan point is used", plan.Legs[leg].Name)
}
Expand Down
2 changes: 1 addition & 1 deletion docs/PLANS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ positions are the creatures' own, so one up to 4 yd off the floor or 2.5 yd to t
| 189 | `scarlet_cathedral`: Scarlet Monastery: Cathedral | 37-42 | 3 | draft | yes | chapel door (GO_CHAPPEL_DOOR) before Mograine and Whitemane; Mograine and Whitemane are one event: Whitemane resurrects Mograine |
| 70 | `uldaman`: Uldaman | 41-45 | 14 | live-checked | yes | Keystone (needs the Staff of Prehistoria, made from the Gni'kiv Medallion and the Shaft of Tsol) opens the Seal of Khaz'Mul for Ironaya; Altar of the Keepers and Altar of Archaedas: ritual of three bots |
| 815 | `gilneas_city`: Gilneas City | 42-47 | 7 | draft | yes | Harlow family chest (GO_HARLOW_FAMILY_CHEST) after the Regent-Lord and Regent-Lady; Genn Greymane (61418) is script-driven (genn_greymane): his role (final fight or event) is unverified |
| 209 | `zulfarrak`: Zul'Farrak | 44-48 | 18 | live-checked | yes | Zum'rah: area trigger 962 makes him hostile; stairs event with Sergeant Bly's crew, Nekrum and Sezz'ziz (its phase is instance data 1 = 7; the plan waits on Nekrum seen, no phase step yet); Weegli's gossip (or Bly's) has him blow the End Door for Ukorz and Ruuzlu; Gahz'rilla: the optional gahzrilla leg (Gong with the Mallet of Zul'Farrak, 9240; it drops from Qiaga the Keeper 7996 outside the instance and 9241 must be used to make 9240, so no bot run can obtain it; skipped without give_items) |
| 209 | `zulfarrak`: Zul'Farrak | 44-48 | 20 | live-checked | yes | Zum'rah: area trigger 962 makes him hostile; stairs event with Sergeant Bly's crew, Nekrum and Sezz'ziz (its phase is instance data 1 = 7; the plan waits on Nekrum seen, no phase step yet); Weegli's gossip (or Bly's) has him blow the End Door for Ukorz and Ruuzlu; Gahz'rilla: the optional gahzrilla leg (Gong with the Mallet of Zul'Farrak, 9240; it drops from Qiaga the Keeper 7996 outside the instance and 9241 must be used to make 9240, so no bot run can obtain it; skipped without give_items); Farraki Arena: Razjal's gossip (option 0) starts Kath'zen, Juthza, Razjal and the scorpid intermission; the run leader holds the arena until all are dead (units hold; skip_export: left out unless --with-legs farraki_arena names it); Zel'jeb the Ancient: Turtle wing north of Velratha; 90 % less damage from all but Fire (skip_export: left out unless --with-legs zeljeb names it) |
| 349 | `maraudon_purple`: Maraudon: Purple | 44-49 | 6 | draft | yes | Celebras the Cursed's Scepter of Celebras event is optional; Vylestem vines (GO_VYLESTEM_VINE, GO_HEALED_CELEBRIAN_VINE) round Celebras' room |
| 349 | `maraudon_orange`: Maraudon: Orange | 45-50 | 6 | draft | yes | Celebras the Cursed's Scepter of Celebras event is optional and not on this route; Princess Theradras: the party enters her cave by the inner passage (join of the orange and purple routes unverified) |
| 109 | `sunken_temple`: The Temple of Atal'Hakkar | 47-52 | 6 | draft | yes | six Atal'ai statues (GO_ATALAI_STATUE_1-6) lit in order summon Atal'alarion; the six troll mini-bosses (5712-5717) are the statues' guards; four Eternal Flames (GO_ETERNAL_FLAME_1-4) summon the Avatar of Hakkar (8443, no spawn row); Jammal'an's barrier (GO_JAMMALAN_BARRIER) and the Hakkar doors (GO_HAKKAR_DOOR_1-2); Shade of Eranikus needs the Essence of Eranikus path and the Altar of Hakkar |
Expand Down
Loading