Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <entrance_trigger>`. The module answers `TBM:ACTION_PENDING|cmd enter|party|<budget s>|<trigger>` and one final line:
`.bot cmd enter <entrance_trigger>`. 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|<budget s>|<trigger>` and one final line:
`TBM:ACTION_ACK|cmd enter|party|<n>|entered` (the party is inside; the run waits for the lead bot on the plan's map) or
`TBM:ACTION_ERR|cmd enter|<code>|<Bot>: <msg> [(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
Expand Down
10 changes: 9 additions & 1 deletion internal/dungeon/observe.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
86 changes: 86 additions & 0 deletions internal/dungeon/observe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"testing"
"time"

"github.com/pjerra/turtle-agent/internal/packet"
"github.com/pjerra/turtle-agent/internal/tbm"
)

Expand Down Expand Up @@ -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 = ""
Expand Down
1 change: 1 addition & 0 deletions internal/dungeon/remote_cmdenter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
43 changes: 43 additions & 0 deletions internal/dungeon/remote_cmdenter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
9 changes: 8 additions & 1 deletion internal/dungeon/remote_enter.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"math"
"slices"
"strings"
"time"
Expand Down Expand Up @@ -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)
}
Expand Down
Loading