From 052bfb553059587006e067fae71740ad59cb2c1f Mon Sep 17 00:00:00 2001 From: pjerra <71784785+pjerra@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:02:43 +0200 Subject: [PATCH] observe: count one wipe while a member may revive; emit the wipe trace event The observe watch counted a wipe on the rising edge of all-dead and re-armed the latch as soon as one member was alive, so a bot revived in place for one poll and dead again the next double counted a collapse (r2-19z19 step 26: one collapse of the tank was counted twice, 3 wipes for 2 collapses). The latch now re-arms only once fewer than 3 members are dead (the module's kWipeDead), and every counted wipe goes to the trace as a "wipe" event with "observe": true, so the tracecheck deaths oracle sees it (stepFailedRecover never runs in observe mode, the oracle read 0 wipes). --- README.md | 6 ++-- internal/dungeon/observe.go | 28 +++++++++++++-- internal/dungeon/observe_test.go | 58 ++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5ede367d..9226afdb 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/dungeon/observe.go b/internal/dungeon/observe.go index 05611267..1691563d 100644 --- a/internal/dungeon/observe.go +++ b/internal/dungeon/observe.go @@ -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 { @@ -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 { @@ -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 == "" { diff --git a/internal/dungeon/observe_test.go b/internal/dungeon/observe_test.go index dba61fbf..f28e5c06 100644 --- a/internal/dungeon/observe_test.go +++ b/internal/dungeon/observe_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "path/filepath" "slices" "strings" "testing" @@ -12,6 +13,9 @@ import ( "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. @@ -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()...)