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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1683,8 +1683,10 @@ 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.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
counts the watched party's wipes: one rising edge of "every bot dead" in the feed is one wipe, the latch re-arms only once fewer than
3 members are dead (a single member revived in place for a poll, dead again the next, does not count a second wipe), and each counted
wipe also goes to the trace as a `wipe` event with `"observe": true`, so the tracecheck deaths oracle sees it too (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
28 changes: 25 additions & 3 deletions internal/dungeon/observe.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ const (
observeGone = 20 * time.Second
// MaxRunChanges bounds the changes kept in the report.
MaxRunChanges = 300
// wipeDeadReArm is the dead-member count below which the wipe latch re-arms (TortoiseBots DungeonRunPolicy.kWipeDead, 3): a party
// that is all dead, one member alive for a poll, all dead again, is one collapse, not two.
wipeDeadReArm = 3
)

func (s ObserveSpec) withDefaults(plan *Plan) ObserveSpec {
Expand Down Expand Up @@ -344,13 +347,20 @@ func (d *RemoteDriver) observe(ctx context.Context) (err error) {
}
d.resendViewIfSilent(ctx)
// 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.
// result of countDeaths is an edge, not a level: without the wiped flag three polls of a dead party would count three wipes. The
// latch re-arms only once fewer than wipeDeadReArm members are dead (the module's kWipeDead): a single in-place revive (one member
// alive for a poll, dead again the next) re-arms a plain "not all dead" check and double counts the collapse. The "wipe" event goes
// to the trace, so the tracecheck deaths oracle sees the wipes of an observed run too (stepFailedRecover never runs in observe mode).
all := d.countDeaths()
if all && !o.wiped {
d.rep.update(func(r *Report) { r.Wipes++ })
d.tr.Event("wipe", map[string]any{"leg": d.curLeg, "error": "the whole party is dead", "observe": true})
}
if all {
o.wiped = true
} else if deadCount(d.fr) < wipeDeadReArm {
o.wiped = false
}
o.wiped = all
d.serveRunRequests(ctx)
d.readRunLines()
if d.clk.Now().Sub(o.lastPoll) >= spec.Poll {
Expand Down Expand Up @@ -740,6 +750,18 @@ func (d *RemoteDriver) takeStatus(st tbm.RunStatus) {
}
}

// deadCount is how many members of the frame's party are dead (the latch of the wipe count compares it with wipeDeadReArm; tbm.ViewFrame
// counts it nowhere: AllDead is the all-rounded edge, not the level).
func deadCount(fr tbm.ViewFrame) int {
n := 0
for _, p := range fr.Party {
if p.Dead {
n++
}
}
return n
}

// legOf finds the plan leg a status line names (by name, or by number when the module had no name); -1 when none.
func (d *RemoteDriver) legOf(name string) int {
if name == "" {
Expand Down
58 changes: 58 additions & 0 deletions internal/dungeon/observe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@ import (
"encoding/json"
"errors"
"fmt"
"path/filepath"
"slices"
"strings"
"testing"
"time"

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

// leaderStep is a status the fake run leader reaches after `at` of running time.
Expand Down Expand Up @@ -333,6 +337,60 @@ func TestObserveCountsAWipeOnItsRisingEdge(t *testing.T) {
}
}

// A single member alive does not re-arm the wipe latch: all dead, one member alive (4 of 5), all dead again within the latch, then a
// real regroup (nobody dead), then all dead again: two collapses, so Wipes == 2 and exactly 2 trace "wipe" events, and the tracecheck
// deaths oracle reads them ("%d wipes" in its detail: stepFailedRecover never runs in an observed run).
func TestObserveWipeLatchReArmsBelowThreeDead(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"})
// the dead count of the tick, alive when negative
deadOf := map[int]int{1: 5, 2: 4, 3: 5, 4: 0, 5: 5}
base := g.onTick
ticks := 0
g.onTick = func(fr *fakeRemote) {
base(fr)
if !g.running {
return
}
ticks++
n := deadOf[ticks]
for i, p := range fr.party {
if dead := i < n; dead {
p.Dead, p.HP, p.State = true, 0, "d"
} else {
p.Dead, p.HP, p.State = false, p.MaxHP, "n"
}
}
}
dir := t.TempDir()
rep, err := runRemote(t, g, observePlan(), observeOpts(ObserveSpec{Poll: s(3)}), func(c *Config) { c.Trace = tracerun.Config{On: true, Dir: dir, RunID: "r-wipe-hyst"} })
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))
}
f, err := trace.Read(filepath.Join(dir, "r-wipe-hyst.jsonl"))
if err != nil {
t.Fatal(err)
}
wipes := 0
for _, e := range f.Events {
if e.Ev == "wipe" {
wipes++
}
}
if wipes != 2 {
t.Fatalf("%d wipe events in the trace (want 2)", wipes)
}
for _, c := range tracecheck.Score(f, tracecheck.Defaults()) {
if c.ID == tracecheck.IDDeaths && !strings.Contains(c.Detail, ", 2 wipes") {
t.Fatalf("the deaths oracle does not say 2 wipes: %+v", c)
}
}
}

// A party with one bot alive is no wipe: four dead of five count nothing.
func TestObserveCountsNoWipeWhileABotLives(t *testing.T) {
g := newFakeLeader(t, wipeScript()...)
Expand Down
Loading