From 653729f25b529e126ac746e7482c0e023f984006 Mon Sep 17 00:00:00 2001 From: pjerra <71784785+pjerra@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:49:21 +0200 Subject: [PATCH 1/3] observe: count a wipe once, on the rising edge of all-dead countDeaths()' result was dropped in the watch loop, so report.wipes stayed 0 for a whole observed run. An edge flag keeps three polls of the same dead party at one wipe and lets a party that came back alive be wiped again. --- internal/dungeon/observe.go | 10 +++- internal/dungeon/observe_test.go | 86 ++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/internal/dungeon/observe.go b/internal/dungeon/observe.go index 1962839b..05611267 100644 --- a/internal/dungeon/observe.go +++ b/internal/dungeon/observe.go @@ -171,6 +171,7 @@ type observeState struct { baseN uint64 // lines read before the start: older ones are another run's lastPoll time.Time serverEnd bool // the module said the run is done: no stop is needed + wiped bool // the feed showed the whole party dead: one rising edge of it is one wipe mu sync.Mutex reqs []string // pause, resume, skip requests from the RPC @@ -342,7 +343,14 @@ func (d *RemoteDriver) observe(ctx context.Context) (err error) { return err } d.resendViewIfSilent(ctx) - d.countDeaths() + // The wipe count of an observed run: the run leader owns the run (it stops and recovers on its own), the watch only counts. The + // result of countDeaths is an edge, not a level: without the wiped flag three polls of a dead party would count three wipes, and + // the party coming back alive clears it for the next one. + all := d.countDeaths() + if all && !o.wiped { + d.rep.update(func(r *Report) { r.Wipes++ }) + } + o.wiped = all d.serveRunRequests(ctx) d.readRunLines() if d.clk.Now().Sub(o.lastPoll) >= spec.Poll { diff --git a/internal/dungeon/observe_test.go b/internal/dungeon/observe_test.go index 4b7dbfce..dba61fbf 100644 --- a/internal/dungeon/observe_test.go +++ b/internal/dungeon/observe_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/pjerra/turtle-agent/internal/packet" "github.com/pjerra/turtle-agent/internal/tbm" ) @@ -276,6 +277,91 @@ func TestObservePollsWhenNothingIsPushed(t *testing.T) { } } +// wipeParty kills the whole fake party in place (or brings it back): the next frame the feed publishes shows it. +func wipeParty(g *fakeRemote, dead bool) { + for _, p := range g.party { + p.Dead, p.HP, p.State = dead, 0, "d" + if !dead { + p.HP, p.State = p.MaxHP, "n" + } + } +} + +// wipeScript keeps the run going while the party dies and lives again: statuses every 5 s (no stall), done at 20 s. +func wipeScript() []leaderStep { + return []leaderStep{ + {0, rs("running", 1, 5, "first", "move", "walk", "walking to -1 2 3")}, + {s(5), rs("running", 2, 5, "first", "pull", "pull", "pulling 299")}, + {s(10), rs("running", 3, 5, "first", "boss", "pull", "pulling boss")}, + {s(20), rs("done", 5, 5, "first", "move", "walk", "plan done")}, + } +} + +// deathsOnTicks drives the party's deaths by the ticks of the watch (one sleep of cfg.Poll, one frame, one countDeaths each): +// first all dead for 3 polls, then alive for 1, then all dead again for 2, then alive to the end. +func deathsOnTicks(g *fakeLeader, deadTicks int, reviveAt int, deadAgainTo int) { + base := g.onTick + ticks := 0 + g.onTick = func(fr *fakeRemote) { + base(fr) + if !g.running { + return + } + ticks++ + switch { + case ticks <= deadTicks, ticks > reviveAt && ticks <= deadAgainTo: + wipeParty(fr, true) + default: + wipeParty(fr, false) + } + } +} + +// A wipe of an observed run is counted once, on the rising edge of "all dead": three polls of a dead party are one wipe, and the +// party coming back alive lets the next wipe count again. The run leader owns the run, so the watch neither stops nor revives. +func TestObserveCountsAWipeOnItsRisingEdge(t *testing.T) { + g := newFakeLeader(t, wipeScript()...) + g.party = append(g.party, &tbm.PartyRec{GUID: packet.GUID(0x2D), Name: "Loxy", HP: 1000, MaxHP: 1000, Power: 1000, MaxPower: 1000, PowerType: 0, State: "n"}, + &tbm.PartyRec{GUID: packet.GUID(0x2E), Name: "Grok", HP: 1000, MaxHP: 1000, Power: 1000, MaxPower: 1000, PowerType: 0, State: "n"}) + deathsOnTicks(g, 3, 4, 6) + rep, err := runRemote(t, g, observePlan(), observeOpts(ObserveSpec{}), nil) + if err != nil || rep.State != "done" || rep.Remote.Observe.Ended != "done" { + t.Fatalf("%v %s %s", err, rep.State, rep.Error) + } + if rep.Wipes != 2 { + t.Fatalf("wipes %d (want 2)\n%s", rep.Wipes, notesOf(rep)) + } +} + +// A party with one bot alive is no wipe: four dead of five count nothing. +func TestObserveCountsNoWipeWhileABotLives(t *testing.T) { + g := newFakeLeader(t, wipeScript()...) + g.party = append(g.party, &tbm.PartyRec{GUID: packet.GUID(0x2D), Name: "Loxy", HP: 1000, MaxHP: 1000, Power: 1000, MaxPower: 1000, PowerType: 0, State: "n"}, + &tbm.PartyRec{GUID: packet.GUID(0x2E), Name: "Grok", HP: 1000, MaxHP: 1000, Power: 1000, MaxPower: 1000, PowerType: 0, State: "n"}) + base := g.onTick + ticks := 0 + g.onTick = func(fr *fakeRemote) { + base(fr) + if !g.running { + return + } + ticks++ + for i, p := range fr.party { + p.Dead, p.State = i < 4, "n" + if i < 4 { + p.HP, p.State = 0, "d" + } + } + } + rep, err := runRemote(t, g, observePlan(), observeOpts(ObserveSpec{}), nil) + if err != nil || rep.State != "done" { + t.Fatalf("%v %s %s", err, rep.State, rep.Error) + } + if rep.Wipes != 0 { + t.Fatalf("wipes %d (want 0)\n%s", rep.Wipes, notesOf(rep)) + } +} + func TestObserveRefusesAServerWithoutTheRunLeaderBeforeHiring(t *testing.T) { g := newFakeLeader(t) g.listText = "" From fdd16a29e6d3d83f1f01110b96bb34b7c0c4674a Mon Sep 17 00:00:00 2001 From: pjerra <71784785+pjerra@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:49:21 +0200 Subject: [PATCH 2/3] enter: send cmd enter at a door above or below the walkable ground A portal in a cave or on a ledge stands off the ground the lead bot walks, so the 3D MoveArrive check never passed and the walk ran to the 4-minute ceiling without an order (stuck at the portal's x/y). With cmd enter the lead bot counts as at the door within 30 yd on the ground plane and 12 yd of height; the plain walk still needs the point. --- internal/dungeon/remote_cmdenter.go | 1 + internal/dungeon/remote_cmdenter_test.go | 43 ++++++++++++++++++++++++ internal/dungeon/remote_enter.go | 9 ++++- 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/internal/dungeon/remote_cmdenter.go b/internal/dungeon/remote_cmdenter.go index 3a724bfb..9e0f6814 100644 --- a/internal/dungeon/remote_cmdenter.go +++ b/internal/dungeon/remote_cmdenter.go @@ -40,6 +40,7 @@ const ( cmdEnterCombatWait = 45 * time.Second // an order refused in-combat waits for the fight to end this long cmdEnterMax = 4 * time.Minute // the whole of the walk and the order: past it the phase fails cmdEnterGather = 30 // yards from the portal at which a bot counts as at the door + cmdEnterDoorDZ = 12 // yards above or below the portal within which the lead bot still counts as at the door ) // CmdEnterReport is what the `cmd enter` order did, in the enter report. diff --git a/internal/dungeon/remote_cmdenter_test.go b/internal/dungeon/remote_cmdenter_test.go index 44a6c456..6b11661c 100644 --- a/internal/dungeon/remote_cmdenter_test.go +++ b/internal/dungeon/remote_cmdenter_test.go @@ -291,3 +291,46 @@ func TestRemoteEnterCmdCeilingDoesNotCountAFightOnTheWay(t *testing.T) { t.Fatalf("without the fight the ceiling holds: %v", err) } } + +// A door in a hill or a cave stands above the walkable ground: the module's own move never lifts a bot to the portal's Z (the fake +// clamps the lead bot back to the ground), so with `cmd enter` only the ground-plane rule (within cmdEnterGather of the portal, +// cmdEnterDoorDZ above or below it) gets the order out at the door; a 3D MoveArrive check would walk to the 4-minute ceiling instead. +func TestRemoteEnterCmdIsSentAtADoorAboveTheGround(t *testing.T) { + g := newEnterOrders(t, enterAck(3)) + g.plan.Portal = &Portal{Map: 0, Point: Point{304, 0, 9}} // 4 yd off on the ground plane, 9 yd up + base := g.fakeRemote.onTick + g.fakeRemote.onTick = func(fr *fakeRemote) { + base(fr) + fr.lead().Z = 0 // the bot walks on the ground: it never stands on the portal's point + } + rep, err := g.run(RemoteOptions{Enter: EnterCmd}) + if err != nil || rep.State != "done" { + t.Fatalf("%v %s\n%s", err, rep.State, notesOf(rep)) + } + if len(g.sent) != 1 || g.sent[0] != "cmd enter 924" { + t.Fatalf("the order never went out at the door: %v\n%s", g.sent, notesOf(rep)) + } +} + +// Without `cmd enter` nothing changes: the lead bot is taken in by the area trigger (the fake's stand-in for a bot walking into +// it) on the ground below the portal's point, and no enter order is sent. +func TestRemoteEnterWalkStillTakesATriggerAboveTheGround(t *testing.T) { + g := newEnterOrders(t, enterAck(3)) + g.plan.Portal = &Portal{Map: 0, Point: Point{304, 0, 9}} + g.portal = &Point{304, 0, 9} + base := g.fakeRemote.onTick + g.fakeRemote.onTick = func(fr *fakeRemote) { + base(fr) + fr.lead().Z = 0 + } + rep, err := g.run(RemoteOptions{Enter: EnterWalk}) + if err != nil || rep.State != "done" || rep.Remote.Enter.Method != EnterWalk { + t.Fatalf("%v %s %+v\n%s", err, rep.State, rep.Remote.Enter, notesOf(rep)) + } + if len(g.sent) != 0 || g.idx("order cmd enter") >= 0 { + t.Fatalf("an enter order went out without enterCmd: %v\n%s", g.sent, g.log) + } + if g.idx("portal") < 0 { + t.Fatalf("the trigger never took the lead bot in: %v\n%s", g.log, notesOf(rep)) + } +} diff --git a/internal/dungeon/remote_enter.go b/internal/dungeon/remote_enter.go index 56290de6..95f89684 100644 --- a/internal/dungeon/remote_enter.go +++ b/internal/dungeon/remote_enter.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "math" "slices" "strings" "time" @@ -453,7 +454,13 @@ func (d *RemoteDriver) walkToPortal(ctx context.Context, pt *Portal, began time. return fmt.Errorf("enter: the lead bot %s is on map %d; the portal is on map %d", l.Name, l.Map, pt.Map) } here := partyPoint(l) - if dist3(here, pt.Point) <= float64(d.opt.MoveArrive) { + // With `cmd enter` the lead bot is at the door once it stands within cmdEnterGather yards of the portal on the ground plane and no + // more than cmdEnterDoorDZ above or below it: the order takes the party near the door, not onto the portal's point, and a door in + // a cave or on a ledge stands above or below the walkable ground, so the 3D MoveArrive check would never pass and the walk would + // end at its ceiling without an order. Without `cmd enter` the lead bot must reach the point itself: the area trigger takes it in. + atDoor := dist3(here, pt.Point) <= float64(d.opt.MoveArrive) || + d.enterCmd && hdist(here, pt.Point) <= cmdEnterGather && math.Abs(float64(here.Z-pt.Point.Z)) <= cmdEnterDoorDZ + if atDoor { if d.enterCmd { return d.cmdEnterAtDoor(ctx, began) } From 1faf0c1e33edd7833fc6593f27c30ccadce2c9f8 Mon Sep 17 00:00:00 2001 From: pjerra <71784785+pjerra@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:49:22 +0200 Subject: [PATCH 3/3] docs: the observe wipe count and the cmd-enter door rule --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e6b8b4df..5ede367d 100644 --- a/README.md +++ b/README.md @@ -1682,7 +1682,9 @@ The server needs `AiPlayerbot.DungeonSelfClear = 1` and `AiPlayerbot.RemoteComma for a hand-sent run command. **Report.** `report.remote.mode` is `observe`; `report.remote.observe` has `plan`, the newest `state`, `step`, `steps`, `leg`, `kind`, `phase`, `note` and a -plain-words `text`, `started`, `lines`, `polls`, `pauses`, `controls`, `ended` and `changes[]` (the newest 300 status changes with their time). `report.legs[]` has one +plain-words `text`, `started`, `lines`, `polls`, `pauses`, `controls`, `ended` and `changes[]` (the newest 300 status changes with their time). `report.wipes` +counts the watched party's wipes: one rising edge of "every bot dead" in the feed is one wipe, and the party coming back alive lets the next one count +(the run leader stops and recovers on its own; the watch only counts). `report.legs[]` has one entry per leg the run leader entered, with its seconds. `report.current` is "run leader: sentinel · pull 7/41 · pulling". The orders counters stay at zero but for the end's `stay`. Not in observe mode: `tune`, `live_reload`, `cc`, `carrier`, `loot_restore`, `object_fallback`, `tank`, `healer`, `recover_wipe` and the start options that move or equip the party (`start_here`, `have_items`, `pulltest`, `give_items`) are refused, since the agent steers nothing; `from_leg`, `from_pull_group` and @@ -1910,7 +1912,9 @@ the first step in, and the agent stays **outside and ungrouped**. When a bot of bot that is not on the plan's map, 6 s apart, **by name**: a bot the feed does not list (it left the group, or the feed dropped it) is summoned too (the module answers `no-bot` for a bot outside the group, and the run fails saying so). - **cmd:** `cmd move` legs to the portal on the outer map as for the walk, then up to 8 s for the other bots to stand within 30 yd of it, then - `.bot cmd enter `. The module answers `TBM:ACTION_PENDING|cmd enter|party||` and one final line: + `.bot cmd enter `. With `cmd enter` the lead bot is at the door already when it stands within 30 yd of the portal on the + ground plane and no more than 12 yd above or below it: a door in a cave or on a ledge stands above or below the walkable ground, so the + 3D 5-yd arrive check of the plain walk would never pass. Without `cmd enter` the lead bot must reach the portal point itself. The module answers `TBM:ACTION_PENDING|cmd enter|party||` and one final line: `TBM:ACTION_ACK|cmd enter|party||entered` (the party is inside; the run waits for the lead bot on the plan's map) or `TBM:ACTION_ERR|cmd enter||: [(n of m got in)]`. The wait for the final line is the budget plus 8 s (the module ends the order with its own `timeout` about 6 s after the budget), at most 130 s (its worst case is 60 + 30 + 30 s and 6 s). Retries are bounded: `busy` 3, `cancelled` 2, `too-far` 2 (a wait for the