From 4413622cc9f72fc14aa50f0b75fbf38152b3517c Mon Sep 17 00:00:00 2001 From: pjerra <71784785+pjerra@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:40:17 +0200 Subject: [PATCH 1/4] Parse run leader left-packs field; a run with packs left is no clear --- README.md | 3 +- internal/dashboard/card.go | 11 ++++-- internal/dashboard/digest.go | 12 ++++++- internal/dashboard/history.go | 14 ++++---- internal/dashboard/leader_test.go | 16 +++++++++ internal/dashboard/paths_test.go | 25 ++++++++++++++ internal/dashboard/static/paths.js | 3 +- internal/dashboard/testdata/paths_check.js | 1 + internal/dungeon/observe.go | 10 +++--- internal/dungeon/observe_test.go | 26 ++++++++++++++- internal/tbm/run.go | 39 +++++++++++++++------- internal/tbm/run_test.go | 27 +++++++++++++++ 12 files changed, 159 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index a3f20c13..d1b92644 100644 --- a/README.md +++ b/README.md @@ -3582,7 +3582,8 @@ most sections; `GET /api/heat/` the death places of the latest 20 runs. Bo at most 600 files, 6 folders deep, listed at most every 90 s, parsed once per change and cached (28 digests). What the data lacks is left out and the legend says so: the feed has **no creature level** (rings are 20 yd), **no name of who put a crowd control on** (the cyan line needs a `cmd cc` order of the commander), **no heal spell target** (a healer that targets a friend is taken to heal it), and patrols are only drawn when the plan has them. Runs that started at a later -section (`from_leg`) are not counted as a clear of the whole dungeon. Botwatch files are not read: they carry no plan name. +section (`from_leg`) are not counted as a clear of the whole dungeon, nor are runs whose run leader reported packs left alive (`TBM:RUN ... |left `): +the ghost names the count ("furthest earlier run (4 sections), 2 packs left") and a run's card shows it ("Run leader: finished all 41 steps, 2 packs left"). Botwatch files are not read: they carry no plan name. **Limits.** The map shows what `bot_view` reports: the module's perception of the party, so a creature none of the bots sees is not there. A run on a plan the dashboard cannot find or load shows the reason in place of the map. A card of an agent that diff --git a/internal/dashboard/card.go b/internal/dashboard/card.go index 49896ac8..cd2e87f9 100644 --- a/internal/dashboard/card.go +++ b/internal/dashboard/card.go @@ -136,6 +136,7 @@ type LeaderInfo struct { Kind string `json:"kind,omitempty"` Phase string `json:"phase,omitempty"` Note string `json:"note,omitempty"` + Left int `json:"left,omitempty"` // packs the run leader left alive Step int `json:"step"` Steps int `json:"steps"` // Text is the status in plain words: "Run leader: Sentinel · pull 7/41 · pulling". @@ -322,6 +323,7 @@ type rawReport struct { Kind string `json:"kind"` Phase string `json:"phase"` Note string `json:"note"` + Left int `json:"left,omitempty"` Lines int `json:"lines"` } `json:"observe"` PlanReloads []struct { @@ -510,7 +512,7 @@ func fillRun(c *Card, rep *rawReport, ds rawDungeon, in Inputs) { c.Counters.OrdersSent, c.Counters.OrdersRefused, c.Counters.OrdersUnanswered = r.OrdersSent, r.OrdersRefused, r.OrdersUnanswered c.Counters.Unreachable = r.Unreachable if o := r.Observe; o != nil && o.Lines > 0 { - c.Run.Leader = leaderInfo(tbm.RunStatus{State: o.State, Step: o.Step, Steps: o.Steps, Leg: o.Leg, Kind: o.Kind, Phase: o.Phase, Note: o.Note}) + c.Run.Leader = leaderInfo(tbm.RunStatus{State: o.State, Step: o.Step, Steps: o.Steps, Leg: o.Leg, Kind: o.Kind, Phase: o.Phase, Note: o.Note, Left: o.Left}) if c.Run.State == "running" { c.Run.Phase = "leader" } @@ -541,7 +543,7 @@ func fillRun(c *Card, rep *rawReport, ds rawDungeon, in Inputs) { // leaderInfo is the card's view of a run leader status, with its plain-words line. func leaderInfo(st tbm.RunStatus) *LeaderInfo { - li := &LeaderInfo{State: st.State, Leg: st.Leg, Kind: st.Kind, Phase: st.Phase, Note: st.Note, Step: st.Step, Steps: st.Steps} + li := &LeaderInfo{State: st.State, Leg: st.Leg, Kind: st.Kind, Phase: st.Phase, Note: st.Note, Left: st.Left, Step: st.Step, Steps: st.Steps} text := st.Text() if text != "" { text = strings.ToUpper(text[:1]) + text[1:] @@ -563,6 +565,11 @@ func leaderInfo(st tbm.RunStatus) *LeaderInfo { default: li.Text = "Run leader: " + text } + if st.Left == 1 { + li.Text += ", 1 pack left" + } else if st.Left > 1 { + li.Text += fmt.Sprintf(", %d packs left", st.Left) + } return li } diff --git a/internal/dashboard/digest.go b/internal/dashboard/digest.go index 9d5f8803..4bb3ffa4 100644 --- a/internal/dashboard/digest.go +++ b/internal/dashboard/digest.go @@ -109,6 +109,7 @@ type RunDigest struct { Ended bool `json:"ended"` Reason string `json:"reason,omitempty"` LegsDone int `json:"legs_done"` + Left int `json:"left,omitempty"` // packs the run leader left alive Legs []DLeg `json:"legs"` Paths []DPath `json:"paths"` Stalls []DStall `json:"stalls"` @@ -140,6 +141,7 @@ type digestBuilder struct { ended bool reason string endSeconds float64 + left int // packs the run leader left alive, from the newest run_leader trace event legs []DLeg stalls []DStall refusals, deaths []DMark @@ -391,6 +393,14 @@ var refusalCodes = map[string]bool{"unreachable": true, "off-mesh": true, "no-lo func (b *digestBuilder) event(ev string, t int64, line []byte) { ts := b.sec(t) switch ev { + case "run_leader": + var e struct { + Left float64 `json:"left"` + } + if json.Unmarshal(line, &e) == nil && e.Left > 0 { + b.left = int(e.Left) + } + return case "leg": var e struct { Leg int `json:"leg"` @@ -571,7 +581,7 @@ func (b *digestBuilder) event(ev string, t int64, line []byte) { // Snapshot is the digest so far. minutes > 0 keeps only the last minutes of every walk (the stalls, marks and the rest are not cut). func (b *digestBuilder) Snapshot(minutes int) *RunDigest { - d := &RunDigest{Run: b.run, Plan: b.plan, Map: b.mapID, Mode: b.mode, Lead: b.lead, StartUnix: b.startUnix, Ended: b.ended, Reason: b.reason, + d := &RunDigest{Run: b.run, Plan: b.plan, Map: b.mapID, Mode: b.mode, Lead: b.lead, StartUnix: b.startUnix, Ended: b.ended, Reason: b.reason, Left: b.left, Seconds: round1(float64(b.lastT) / 1000), Legs: append([]DLeg{}, b.legs...), Stalls: append([]DStall{}, b.stalls...), Refusals: append([]DMark{}, b.refusals...), Deaths: append([]DMark{}, b.deaths...), Rests: append([]DMark{}, b.rests...), Gathers: append([]DMark{}, b.gathers...), Pulls: append([]DPull{}, b.pulls...), Objects: append([]DObject{}, b.objects...), Paths: []DPath{}} diff --git a/internal/dashboard/history.go b/internal/dashboard/history.go index f772d8ad..7659068c 100644 --- a/internal/dashboard/history.go +++ b/internal/dashboard/history.go @@ -253,7 +253,8 @@ type GhostAnswer struct { Run string `json:"run,omitempty"` Folder string `json:"folder,omitempty"` Seconds float64 `json:"seconds,omitempty"` - Complete bool `json:"complete,omitempty"` // the run cleared the whole dungeon + Complete bool `json:"complete,omitempty"` // the run cleared the whole dungeon (done with no packs left) + Left int `json:"left,omitempty"` // packs the run leader left alive LegsDone int `json:"legs_done,omitempty"` PlanLegs int `json:"plan_legs,omitempty"` // the plan's sections, when the page said Legs []DLeg `json:"legs,omitempty"` @@ -262,8 +263,9 @@ type GhostAnswer struct { } // Ghost picks the best earlier run of plan, not counting the run called exclude: the one that got through the most sections, the quicker -// of equals. A run that ended "done" and started at the first section counts as a clear of the whole dungeon, or, when planLegs says how -// many sections the plan has, a run with that many done. The answer is cached until the folders are listed again. +// of equals. A run that ended "done", started at the first section and left no packs alive counts as a clear of the whole dungeon, +// or, when planLegs says how many sections the plan has, a run with that many done and none left. The answer is cached until the +// folders are listed again. func (h *History) Ghost(plan, exclude string, planLegs int) []byte { h.mu.Lock() defer h.mu.Unlock() @@ -303,11 +305,11 @@ func (h *History) Ghost(plan, exclude string, planLegs int) []byte { ans.Why = "no earlier run of " + plan + " in the tests folder" return h.keepGhost(key, ans) } - ans.Found, ans.Run, ans.Folder, ans.Seconds, ans.LegsDone, ans.Legs = true, best.Run, bestF.folder, best.Seconds, best.LegsDone, best.Legs + ans.Found, ans.Run, ans.Folder, ans.Seconds, ans.LegsDone, ans.Left, ans.Legs = true, best.Run, bestF.folder, best.Seconds, best.LegsDone, best.Left, best.Legs if planLegs > 0 { - ans.Complete = best.LegsDone >= planLegs + ans.Complete = best.LegsDone >= planLegs && best.Left == 0 } else { - ans.Complete = bestF.reason == "done" && bestF.fromLeg == 0 + ans.Complete = bestF.reason == "done" && bestF.fromLeg == 0 && best.Left == 0 } for _, p := range best.Paths { if p.Lead { diff --git a/internal/dashboard/leader_test.go b/internal/dashboard/leader_test.go index 391f4e84..96cfcefb 100644 --- a/internal/dashboard/leader_test.go +++ b/internal/dashboard/leader_test.go @@ -44,6 +44,22 @@ func TestCardShowsTheRunLeader(t *testing.T) { } } +// A run leader that left packs alive says so on the card. +func TestCardShowsPacksLeftByTheRunLeader(t *testing.T) { + d := observedDungeon("done", 41, "archaedas", "boss", "pull", "plan done", "done") + d = strings.Replace(d, `"lines":9}`, `"left":2,"lines":9}`, 1) + c := BuildCard(Inputs{Agent: Agent{ID: "1"}, Now: t0, Status: json.RawMessage(statusJSON), Dungeon: json.RawMessage(d)}) + l := c.Run.Leader + if l == nil || l.Left != 2 || l.Text != "Run leader: finished all 41 steps, 2 packs left" { + t.Fatalf("%+v", l) + } + d = strings.Replace(observedDungeon("running", 7, "sentinel", "pull", "pull", "pulling 4851", "running"), `"lines":9}`, `"left":1,"lines":9}`, 1) + c = BuildCard(Inputs{Agent: Agent{ID: "1"}, Now: t0, Status: json.RawMessage(statusJSON), Dungeon: json.RawMessage(d)}) + if l = c.Run.Leader; l == nil || l.Text != "Run leader: Sentinel · pull 7/41 · pulling, 1 pack left" { + t.Fatalf("%+v", l) + } +} + // A driven run's card has no run leader. func TestCardWithoutRunLeader(t *testing.T) { c := BuildCard(Inputs{Agent: Agent{ID: "1"}, Now: t0, Status: json.RawMessage(statusJSON), Dungeon: json.RawMessage(dungeonJSON("leg 0 (one): walk to 1 2 3", 0))}) diff --git a/internal/dashboard/paths_test.go b/internal/dashboard/paths_test.go index eb351476..e78e1cf5 100644 --- a/internal/dashboard/paths_test.go +++ b/internal/dashboard/paths_test.go @@ -382,6 +382,31 @@ func TestHistoryGhostPartialRunIsNoClear(t *testing.T) { } } +func TestHistoryGhostRunWithPacksLeftIsNoClear(t *testing.T) { + dir := t.TempDir() + w := fullRun("r-left", "zf", 100, 600, 4, "done", nil) + w.event(599000, "run_leader", `"mode":"observe","state":"done","step":41,"steps":41,"leg_name":"archaedas","kind":"boss","phase":"pull","note":"plan done","left":2`) + writeRun(t, dir, "g", w) + h := NewHistory(nil, dir) + var g GhostAnswer + if err := json.Unmarshal(h.Ghost("zf", "", 0), &g); err != nil { + t.Fatal(err) + } + if !g.Found || g.Complete || g.Left != 2 || g.LegsDone != 4 { + t.Fatalf("a finished run with 2 packs left is not a clear: %+v", g) + } + clean := fullRun("r-clean", "zf", 200, 700, 4, "done", nil) + writeRun(t, dir, "h", clean) + h = NewHistory(nil, dir) + g = GhostAnswer{} + if err := json.Unmarshal(h.Ghost("zf", "r-left", 0), &g); err != nil { // without the run that left packs behind + t.Fatal(err) + } + if !g.Found || !g.Complete || g.Left != 0 || g.Run != "r-clean" { + t.Fatalf("a finished run with no packs left is a clear: %+v", g) + } +} + func TestHistoryHeatBoundedToLatestRuns(t *testing.T) { dir := t.TempDir() for i := 0; i < HeatRuns+5; i++ { diff --git a/internal/dashboard/static/paths.js b/internal/dashboard/static/paths.js index 514455ba..b96a08ae 100644 --- a/internal/dashboard/static/paths.js +++ b/internal/dashboard/static/paths.js @@ -276,7 +276,8 @@ // The words about the ghost for the hover box / legend: "best earlier run: cleared in 41:07 (folder)". function ghostDescribe(g) { if (!g || !g.found) return ""; - return (g.complete ? "fastest clear: " : "furthest earlier run (" + g.legs_done + (g.plan_legs ? " of " + g.plan_legs : "") + " sections): ") + clockMin(g.seconds) + (g.folder ? " (" + g.folder + ")" : ""); + const left = g.left > 0 ? ", " + g.left + " pack" + (g.left === 1 ? "" : "s") + " left" : ""; + return (g.complete ? "fastest clear: " : "furthest earlier run (" + g.legs_done + (g.plan_legs ? " of " + g.plan_legs : "") + " sections): ") + clockMin(g.seconds) + left + (g.folder ? " (" + g.folder + ")" : ""); } // ---- stalls and refusals ---- diff --git a/internal/dashboard/testdata/paths_check.js b/internal/dashboard/testdata/paths_check.js index 19536691..0350afad 100644 --- a/internal/dashboard/testdata/paths_check.js +++ b/internal/dashboard/testdata/paths_check.js @@ -159,6 +159,7 @@ assert.strictEqual(P.clockMin(0), "0:00"); assert.strictEqual(P.clockMin(2467), assert.ok(/fastest clear: 41:07 \(f1\)/.test(P.ghostDescribe({ found: true, complete: true, seconds: 2467, folder: "f1" }))); assert.ok(/furthest earlier run \(3 sections\)/.test(P.ghostDescribe({ found: true, complete: false, legs_done: 3, seconds: 90 }))); assert.ok(/furthest earlier run \(3 of 14 sections\): 1:30/.test(P.ghostDescribe({ found: true, complete: false, legs_done: 3, plan_legs: 14, seconds: 90 }))); +assert.ok(/furthest earlier run \(4 sections\): 10:00, 2 packs left \(g\)/.test(P.ghostDescribe({ found: true, complete: false, legs_done: 4, seconds: 600, left: 2, folder: "g" }))); assert.strictEqual(P.ghostDescribe({ found: false }), ""); // ---- stalls, refusals, heat diff --git a/internal/dungeon/observe.go b/internal/dungeon/observe.go index 0b7456ea..321a0758 100644 --- a/internal/dungeon/observe.go +++ b/internal/dungeon/observe.go @@ -129,6 +129,7 @@ type RunChange struct { Kind string `json:"kind,omitempty"` Phase string `json:"phase,omitempty"` Note string `json:"note,omitempty"` + Left int `json:"left,omitempty"` // packs the run leader left alive } // ObserveReport is the report's part for an observed run (Report.Remote.Observe). @@ -143,6 +144,7 @@ type ObserveReport struct { Kind string `json:"kind,omitempty"` Phase string `json:"phase,omitempty"` Note string `json:"note,omitempty"` + Left int `json:"left,omitempty"` // packs the run leader left alive // Text is the status in plain words ("sentinel · pull 7/41 · pulling"). Text string `json:"text,omitempty"` // Started: `.bot run start` was acknowledged (or an own run of the plan was adopted). @@ -769,10 +771,10 @@ func (d *RemoteDriver) takeStatus(st tbm.RunStatus) { if st.State == tbm.RunDone { o.serverEnd = true } - ch := RunChange{At: now, State: st.State, Step: st.Step, Steps: st.Steps, Leg: st.Leg, Kind: st.Kind, Phase: st.Phase, Note: st.Note} + ch := RunChange{At: now, State: st.State, Step: st.Step, Steps: st.Steps, Leg: st.Leg, Kind: st.Kind, Phase: st.Phase, Note: st.Note, Left: st.Left} d.remote(func(r *RemoteReport) { ob := r.Observe - ob.State, ob.Step, ob.Steps, ob.Leg, ob.Kind, ob.Phase, ob.Note, ob.Text = st.State, st.Step, st.Steps, st.Leg, st.Kind, st.Phase, st.Note, st.Text() + ob.State, ob.Step, ob.Steps, ob.Leg, ob.Kind, ob.Phase, ob.Note, ob.Left, ob.Text = st.State, st.Step, st.Steps, st.Leg, st.Kind, st.Phase, st.Note, st.Left, st.Text() ob.Lines++ if st.State == tbm.RunPaused && (!had || prev.State != tbm.RunPaused) { ob.Pauses++ @@ -813,7 +815,7 @@ func (d *RemoteDriver) takeStatus(st tbm.RunStatus) { } d.rep.setCurrent(label) d.wd.progress("run leader: " + st.Text()) - data := map[string]any{"mode": "observe", "state": st.State, "step": st.Step, "steps": st.Steps, "leg_name": st.Leg, "kind": st.Kind, "phase": st.Phase, "note": st.Note} + data := map[string]any{"mode": "observe", "state": st.State, "step": st.Step, "steps": st.Steps, "leg_name": st.Leg, "kind": st.Kind, "phase": st.Phase, "note": st.Note, "left": st.Left} d.tr.Event("run_leader", data) // The legs: a new leg name ends the one before it and starts the next, so the report has the per-leg times. if li := d.legOf(st.Leg); li >= 0 && li != d.curLeg && st.State != tbm.RunNone { @@ -826,7 +828,7 @@ func (d *RemoteDriver) takeStatus(st tbm.RunStatus) { d.emit(events.KindDungeonLeg, fmt.Sprintf("leg %d %s: start", li, name), map[string]any{"leg": li, "name": name, "phase": "start", "mode": "observe"}) } if !had || prev.Step != st.Step || prev.Kind != st.Kind { - d.emit(events.KindDungeonStep, "run leader: "+st.Text(), map[string]any{"leg": d.curLeg, "step": st.Step, "steps": st.Steps, "action": st.Kind, "ok": true, "mode": "observe", "state": st.State, "phase": st.Phase, "note": st.Note}) + d.emit(events.KindDungeonStep, "run leader: "+st.Text(), map[string]any{"leg": d.curLeg, "step": st.Step, "steps": st.Steps, "action": st.Kind, "ok": true, "mode": "observe", "state": st.State, "phase": st.Phase, "note": st.Note, "left": st.Left}) } } diff --git a/internal/dungeon/observe_test.go b/internal/dungeon/observe_test.go index 87850ab6..f7f75ffb 100644 --- a/internal/dungeon/observe_test.go +++ b/internal/dungeon/observe_test.go @@ -67,7 +67,11 @@ func runLine(st tbm.RunStatus) string { } return s } - return fmt.Sprintf("TBM:RUN|%s|%s|%d/%d|%s|%s|%s|%s", d(st.Plan), st.State, st.Step, st.Steps, d(st.Leg), d(st.Kind), d(st.Phase), d(st.Note)) + s := fmt.Sprintf("TBM:RUN|%s|%s|%d/%d|%s|%s|%s|%s", d(st.Plan), st.State, st.Step, st.Steps, d(st.Leg), d(st.Kind), d(st.Phase), d(st.Note)) + if st.Left > 0 { + s += fmt.Sprintf("|left %d", st.Left) + } + return s } func (l *fakeLeader) emit(st tbm.RunStatus) { @@ -267,6 +271,26 @@ func TestObserveWatchesARunToTheEnd(t *testing.T) { } } +// A done line with packs left behind keeps the count: the note is bare and the report carries Left. +func TestObserveKeepsThePacksLeftCount(t *testing.T) { + done := rs("done", 5, 5, "second", "move", "walk", "plan done") + done.Left = 2 + g := newFakeLeader(t, + leaderStep{0, rs("running", 1, 5, "first", "move", "walk", "walking")}, + leaderStep{s(10), done}) + 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) + } + ob := rep.Remote.Observe + if ob.Left != 2 || ob.Note != "plan done" || ob.State != "done" { + t.Fatalf("observe %+v", ob) + } + if last := ob.Changes[len(ob.Changes)-1]; last.Left != 2 || last.Note != "plan done" { + t.Fatalf("last change %+v", last) + } +} + func TestObservePollsWhenNothingIsPushed(t *testing.T) { g := newFakeLeader(t, happyScript()...) g.pushOff = true diff --git a/internal/tbm/run.go b/internal/tbm/run.go index f38f2786..88295d55 100644 --- a/internal/tbm/run.go +++ b/internal/tbm/run.go @@ -7,11 +7,12 @@ import ( ) // The run leader (TortoiseBots docs/guides/dungeon-run-leader.md, `.bot run`): a bound party clears a dungeon plan by itself, and -// the commander watches its status line: +// the commander watches its status line. When packs were left alive the run leader appends an optional trailing field: // -// TBM:RUN|||/|||| -// TBM:RUN|uldaman|running|7/41|baelog|pull|pull|pulling 4851 -// TBM:RUN|-|none|0/0|-|-|-|no run +// TBM:RUN|||/||||[|left ] +// +// The field is present only when K > 0; the parser takes `|left ` only as the LAST field matching exactly `left `. +// Old lines without it parse exactly as before. // // The module sends it whenever the step, phase or note changes, and on start, status, pause, resume, skip and stop. @@ -35,15 +36,17 @@ type RunStatus struct { State string `json:"state"` // running, paused, done or none Step int `json:"step"` // counts from 1; 0 before the first Steps int `json:"steps"` - Leg string `json:"leg"` // the leg's name, or its number; empty when the module sent "-" - Kind string `json:"kind"` // the step kind; empty for "-" - Phase string `json:"phase"` // begin, gather, walk, pull, task, rest or wait; empty for "-" - Note string `json:"note"` // what it does, or why it paused; empty for "-" + Leg string `json:"leg"` // the leg's name, or its number; empty when the module sent "-" + Kind string `json:"kind"` // the step kind; empty for "-" + Phase string `json:"phase"` // begin, gather, walk, pull, task, rest or wait; empty for "-" + Note string `json:"note"` // what it does, or why it paused; empty for "-" + Left int `json:"left,omitempty"` // packs the run leader left alive; only the trailing `left ` field sets it Line string `json:"line,omitempty"` } -// ParseRunLine reads a TBM:RUN line. The note is the last field and may hold anything but a line break; a line with fewer than -// eight fields or a step that is not "n/m" is not a status line. +// ParseRunLine reads a TBM:RUN line. The note may hold anything but a line break; a trailing field matching exactly +// `left ` is the packs-left count and is stripped from the note. A line with fewer than eight fields or a step +// that is not "n/m" is not a status line. func ParseRunLine(line string) (RunStatus, bool) { rest, ok := strings.CutPrefix(strings.TrimSpace(line), RunPrefix) if !ok { @@ -68,12 +71,24 @@ func ParseRunLine(line string) (RunStatus, bool) { } return s } - return RunStatus{Plan: dash(f[0]), State: f[1], Step: step, Steps: steps, Leg: dash(f[3]), Kind: dash(f[4]), Phase: dash(f[5]), Note: dash(f[6]), Line: strings.TrimSpace(line)}, true + // The note holds no '|' of its own, but the parser still takes `|left ` only as the LAST field matching exactly + // `left `: anything else stays note text, so old lines parse exactly as before. + note, left := dash(f[6]), 0 + if i := strings.LastIndexByte(f[6], '|'); i >= 0 { + if tail := f[6][i+1:]; strings.HasPrefix(tail, "left ") { + if digits := strings.TrimPrefix(tail, "left "); digits != "" { + if n, err := strconv.Atoi(digits); err == nil && n > 0 && strconv.Itoa(n) == digits { + left, note = n, dash(f[6][:i]) + } + } + } + } + return RunStatus{Plan: dash(f[0]), State: f[1], Step: step, Steps: steps, Leg: dash(f[3]), Kind: dash(f[4]), Phase: dash(f[5]), Note: note, Left: left, Line: strings.TrimSpace(line)}, true } // Same reports whether two statuses say the same thing (the line may differ in nothing else). func (s RunStatus) Same(o RunStatus) bool { - return s.Plan == o.Plan && s.State == o.State && s.Step == o.Step && s.Steps == o.Steps && s.Leg == o.Leg && s.Kind == o.Kind && s.Phase == o.Phase && s.Note == o.Note + return s.Plan == o.Plan && s.State == o.State && s.Step == o.Step && s.Steps == o.Steps && s.Leg == o.Leg && s.Kind == o.Kind && s.Phase == o.Phase && s.Note == o.Note && s.Left == o.Left } // Text is the status in a few plain words for a report or a dashboard: "Sentinel room · pull 7/41 · pulling". diff --git a/internal/tbm/run_test.go b/internal/tbm/run_test.go index c13b9ad5..cd1088f8 100644 --- a/internal/tbm/run_test.go +++ b/internal/tbm/run_test.go @@ -39,6 +39,33 @@ func TestParseRunLine(t *testing.T) { } } +func TestParseRunLineLeftPacks(t *testing.T) { + st, ok := ParseRunLine("TBM:RUN|uldaman|done|41/41|archaedas|boss|pull|plan done|left 2") + if !ok || st.Left != 2 || st.Note != "plan done" { + t.Fatalf("%+v %v", st, ok) + } + st, ok = ParseRunLine("TBM:RUN|uldaman|running|7/41|baelog|pull|pull|pulling 4851") + if !ok || st.Left != 0 || st.Note != "pulling 4851" { + t.Fatalf("no field: %+v %v", st, ok) + } + // a note that merely mentions left text is not the trailing field + for _, line := range []string{ + "TBM:RUN|uldaman|paused|12/41|baelog|boss|begin|left alone: regroup", + "TBM:RUN|uldaman|paused|12/41|baelog|boss|begin|3 left: regroup|left 0", + "TBM:RUN|uldaman|done|41/41|archaedas|boss|pull|plan done|left x", + "TBM:RUN|uldaman|done|41/41|archaedas|boss|pull|plan done|leftover", + } { + st, ok = ParseRunLine(line) + if !ok || st.Left != 0 { + t.Errorf("%q: Left %d, ok %v", line, st.Left, ok) + } + } + st, ok = ParseRunLine("TBM:RUN|uldaman|done|41/41|archaedas|boss|pull|plan done|left 2|left 3") + if !ok || st.Left != 3 { + t.Fatalf("last field wins: %+v %v", st, ok) + } +} + func TestRunCommandsPassTheCheck(t *testing.T) { for _, c := range []string{"run start uldaman", "run start", "run pause", "run resume", "run skip", "run stop", "run status", "run list"} { if err := CheckCommand(c); err != nil { From 5f9a2fc4ffb5f6cd9036d0dd187697794dc676da Mon Sep 17 00:00:00 2001 From: pjerra <71784785+pjerra@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:46:46 +0200 Subject: [PATCH 2/4] Reset packs-left on every run_leader event --- internal/dashboard/digest.go | 2 +- internal/dashboard/paths_test.go | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/internal/dashboard/digest.go b/internal/dashboard/digest.go index 4bb3ffa4..c8604027 100644 --- a/internal/dashboard/digest.go +++ b/internal/dashboard/digest.go @@ -397,7 +397,7 @@ func (b *digestBuilder) event(ev string, t int64, line []byte) { var e struct { Left float64 `json:"left"` } - if json.Unmarshal(line, &e) == nil && e.Left > 0 { + if json.Unmarshal(line, &e) == nil { b.left = int(e.Left) } return diff --git a/internal/dashboard/paths_test.go b/internal/dashboard/paths_test.go index e78e1cf5..1da4a01d 100644 --- a/internal/dashboard/paths_test.go +++ b/internal/dashboard/paths_test.go @@ -407,6 +407,25 @@ func TestHistoryGhostRunWithPacksLeftIsNoClear(t *testing.T) { } } +func TestHistoryGhostLaterRunLeaderWithoutLeftClears(t *testing.T) { + w := fullRun("r-stale", "zf", 100, 600, 4, "done", nil) + w.event(598000, "run_leader", `"mode":"observe","state":"done","step":41,"steps":41,"leg_name":"archaedas","kind":"boss","phase":"pull","note":"plan done","left":2`) + w.event(599000, "run_leader", `"mode":"observe","state":"done","step":41,"steps":41,"leg_name":"archaedas","kind":"boss","phase":"pull","note":"plan done"`) + if d := digestOf(w).Snapshot(0); d.Left != 0 { + t.Fatalf("a later run_leader without left resets the count: %+v", d) + } + dir := t.TempDir() + writeRun(t, dir, "g", w) + h := NewHistory(nil, dir) + var g GhostAnswer + if err := json.Unmarshal(h.Ghost("zf", "", 0), &g); err != nil { + t.Fatal(err) + } + if !g.Found || !g.Complete || g.Left != 0 { + t.Fatalf("a done run whose last report left no packs is a clear: %+v", g) + } +} + func TestHistoryHeatBoundedToLatestRuns(t *testing.T) { dir := t.TempDir() for i := 0; i < HeatRuns+5; i++ { From 29a5cdfc342685d0beabecd845e96dfee55d367f Mon Sep 17 00:00:00 2001 From: pjerra <71784785+pjerra@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:54:23 +0200 Subject: [PATCH 3/4] Prefer clean clear in ghost tiebreak --- internal/dashboard/history.go | 16 +++++++++-- internal/dashboard/paths_test.go | 49 ++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/internal/dashboard/history.go b/internal/dashboard/history.go index 7659068c..5f0b12df 100644 --- a/internal/dashboard/history.go +++ b/internal/dashboard/history.go @@ -262,8 +262,8 @@ type GhostAnswer struct { Runs int `json:"runs"` // runs of the plan looked at } -// Ghost picks the best earlier run of plan, not counting the run called exclude: the one that got through the most sections, the quicker -// of equals. A run that ended "done", started at the first section and left no packs alive counts as a clear of the whole dungeon, +// Ghost picks the best earlier run of plan, not counting the run called exclude: the one that got through the most sections, and of +// equals a clean clear (no packs left) before a run with packs left, then the quicker. A run that ended "done", started at the first section and left no packs alive counts as a clear of the whole dungeon, // or, when planLegs says how many sections the plan has, a run with that many done and none left. The answer is cached until the // folders are listed again. func (h *History) Ghost(plan, exclude string, planLegs int) []byte { @@ -297,10 +297,11 @@ func (h *History) Ghost(plan, exclude string, planLegs int) []byte { if d == nil { continue } - if best == nil || d.LegsDone > best.LegsDone || (d.LegsDone == best.LegsDone && d.Seconds < best.Seconds) { + if best == nil || d.LegsDone > best.LegsDone || (d.LegsDone == best.LegsDone && ghostBetter(d, best)) { best, bestF = d, f } } + if best == nil { ans.Why = "no earlier run of " + plan + " in the tests folder" return h.keepGhost(key, ans) @@ -323,6 +324,15 @@ func (h *History) Ghost(plan, exclude string, planLegs int) []byte { return h.keepGhost(key, ans) } +// ghostBetter says whether d is the better ghost of two runs that finished the same sections: a clean clear (Left == 0) beats a run with packs left, and of two alike runs the quicker wins. +func ghostBetter(d, best *RunDigest) bool { + dClean, bestClean := d.Left == 0, best.Left == 0 + if dClean != bestClean { + return dClean + } + return d.Seconds < best.Seconds +} + func (h *History) keepGhost(key string, ans GhostAnswer) []byte { b, _ := json.Marshal(ans) h.ghosts[key] = cachedJSON{scan: h.scanned, body: b} diff --git a/internal/dashboard/paths_test.go b/internal/dashboard/paths_test.go index 1da4a01d..210e641a 100644 --- a/internal/dashboard/paths_test.go +++ b/internal/dashboard/paths_test.go @@ -407,6 +407,55 @@ func TestHistoryGhostRunWithPacksLeftIsNoClear(t *testing.T) { } } +func TestHistoryGhostPrefersCleanClearOverFasterDirty(t *testing.T) { + dir := t.TempDir() + // dirty run is faster (600 s) with the same LegsDone; clean run is slower (700 s). The clean one must win. + dirty := fullRun("r-dirty", "zf", 100, 600, 4, "done", nil) + dirty.event(599000, "run_leader", `"mode":"observe","state":"done","step":41,"steps":41,"leg_name":"archaedas","kind":"boss","phase":"pull","note":"plan done","left":2`) + writeRun(t, dir, "g", dirty) + writeRun(t, dir, "h", fullRun("r-clean", "zf", 200, 700, 4, "done", nil)) + h := NewHistory(nil, dir) + var g GhostAnswer + if err := json.Unmarshal(h.Ghost("zf", "", 0), &g); err != nil { + t.Fatal(err) + } + if !g.Found || g.Run != "r-clean" || !g.Complete || g.Left != 0 || g.LegsDone != 4 { + t.Fatalf("clean slower run beats dirty faster one: %+v", g) + } +} + +func TestHistoryGhostTwoCleanRunsFasterWins(t *testing.T) { + dir := t.TempDir() + writeRun(t, dir, "g", fullRun("r-slow", "zf", 100, 700, 4, "done", nil)) + writeRun(t, dir, "h", fullRun("r-fast", "zf", 200, 600, 4, "done", nil)) + h := NewHistory(nil, dir) + var g GhostAnswer + if err := json.Unmarshal(h.Ghost("zf", "", 0), &g); err != nil { + t.Fatal(err) + } + if !g.Found || g.Run != "r-fast" || !g.Complete || g.Left != 0 { + t.Fatalf("faster of two clean runs wins: %+v", g) + } +} + +func TestHistoryGhostOnlyDirtyRunsFasterWins(t *testing.T) { + dir := t.TempDir() + slow := fullRun("r-dirty-slow", "zf", 100, 700, 4, "done", nil) + slow.event(699000, "run_leader", `"mode":"observe","state":"done","step":41,"steps":41,"leg_name":"archaedas","kind":"boss","phase":"pull","note":"plan done","left":1`) + writeRun(t, dir, "g", slow) + fast := fullRun("r-dirty-fast", "zf", 200, 600, 4, "done", nil) + fast.event(599000, "run_leader", `"mode":"observe","state":"done","step":41,"steps":41,"leg_name":"archaedas","kind":"boss","phase":"pull","note":"plan done","left":2`) + writeRun(t, dir, "h", fast) + h := NewHistory(nil, dir) + var g GhostAnswer + if err := json.Unmarshal(h.Ghost("zf", "", 0), &g); err != nil { + t.Fatal(err) + } + if !g.Found || g.Run != "r-dirty-fast" || g.Complete || g.Left != 2 { + t.Fatalf("faster of two dirty runs wins and stays incomplete: %+v", g) + } +} + func TestHistoryGhostLaterRunLeaderWithoutLeftClears(t *testing.T) { w := fullRun("r-stale", "zf", 100, 600, 4, "done", nil) w.event(598000, "run_leader", `"mode":"observe","state":"done","step":41,"steps":41,"leg_name":"archaedas","kind":"boss","phase":"pull","note":"plan done","left":2`) From 323aafb617d23745824cc686901d53d4c758dc0a Mon Sep 17 00:00:00 2001 From: pjerra <71784785+pjerra@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:01:29 +0200 Subject: [PATCH 4/4] Ghost tie-break needs a confirmed clean clear, not just Left 0 --- internal/dashboard/history.go | 20 ++++++++++++++------ internal/dashboard/paths_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/internal/dashboard/history.go b/internal/dashboard/history.go index 5f0b12df..4877eb0d 100644 --- a/internal/dashboard/history.go +++ b/internal/dashboard/history.go @@ -263,7 +263,7 @@ type GhostAnswer struct { } // Ghost picks the best earlier run of plan, not counting the run called exclude: the one that got through the most sections, and of -// equals a clean clear (no packs left) before a run with packs left, then the quicker. A run that ended "done", started at the first section and left no packs alive counts as a clear of the whole dungeon, +// equals a confirmed clean clear before any other run, then the quicker. A run that ended "done", started at the first section and left no packs alive counts as a clear of the whole dungeon, // or, when planLegs says how many sections the plan has, a run with that many done and none left. The answer is cached until the // folders are listed again. func (h *History) Ghost(plan, exclude string, planLegs int) []byte { @@ -297,7 +297,7 @@ func (h *History) Ghost(plan, exclude string, planLegs int) []byte { if d == nil { continue } - if best == nil || d.LegsDone > best.LegsDone || (d.LegsDone == best.LegsDone && ghostBetter(d, best)) { + if best == nil || d.LegsDone > best.LegsDone || (d.LegsDone == best.LegsDone && ghostBetter(f, d, bestF, best)) { best, bestF = d, f } } @@ -310,7 +310,7 @@ func (h *History) Ghost(plan, exclude string, planLegs int) []byte { if planLegs > 0 { ans.Complete = best.LegsDone >= planLegs && best.Left == 0 } else { - ans.Complete = bestF.reason == "done" && bestF.fromLeg == 0 && best.Left == 0 + ans.Complete = confirmedClear(bestF, best) } for _, p := range best.Paths { if p.Lead { @@ -324,9 +324,17 @@ func (h *History) Ghost(plan, exclude string, planLegs int) []byte { return h.keepGhost(key, ans) } -// ghostBetter says whether d is the better ghost of two runs that finished the same sections: a clean clear (Left == 0) beats a run with packs left, and of two alike runs the quicker wins. -func ghostBetter(d, best *RunDigest) bool { - dClean, bestClean := d.Left == 0, best.Left == 0 +// confirmedClear is a run that cleared the whole dungeon for sure: it ended +// "done", started at the first section and left no packs alive. An +// interrupted run never reported a final count, so its Left of 0 proves +// nothing. Complete and the ghost tie-break share this predicate. +func confirmedClear(f runFile, d *RunDigest) bool { + return f.reason == "done" && f.fromLeg == 0 && d.Left == 0 +} + +// ghostBetter says whether d is the better ghost of two runs that finished the same sections: a confirmed clean clear beats any other run, and of two alike runs the quicker wins. +func ghostBetter(df runFile, d *RunDigest, bestF runFile, best *RunDigest) bool { + dClean, bestClean := confirmedClear(df, d), confirmedClear(bestF, best) if dClean != bestClean { return dClean } diff --git a/internal/dashboard/paths_test.go b/internal/dashboard/paths_test.go index 210e641a..9a473414 100644 --- a/internal/dashboard/paths_test.go +++ b/internal/dashboard/paths_test.go @@ -456,6 +456,35 @@ func TestHistoryGhostOnlyDirtyRunsFasterWins(t *testing.T) { } } +func TestHistoryGhostAbortedLeftZeroIsNoClear(t *testing.T) { + dir := t.TempDir() + // An interrupted run never reported a final count, so its Left of 0 must + // not beat a finished run with packs left on the clean-clear rule: the + // quicker of the two wins. + writeRun(t, dir, "g", fullRun("r-aborted", "zf", 100, 700, 4, "failed", nil)) + dirty := fullRun("r-dirty", "zf", 200, 600, 4, "done", nil) + dirty.event(599000, "run_leader", `"mode":"observe","state":"done","step":41,"steps":41,"leg_name":"archaedas","kind":"boss","phase":"pull","note":"plan done","left":2`) + writeRun(t, dir, "h", dirty) + h := NewHistory(nil, dir) + var g GhostAnswer + if err := json.Unmarshal(h.Ghost("zf", "", 0), &g); err != nil { + t.Fatal(err) + } + if !g.Found || g.Run != "r-dirty" || g.Complete || g.Left != 2 || g.LegsDone != 4 { + t.Fatalf("an aborted Left-0 run must not beat a faster finished run with packs left: %+v", g) + } + // A confirmed clean clear beats both even when slower. + writeRun(t, dir, "i", fullRun("r-clean", "zf", 300, 800, 4, "done", nil)) + h = NewHistory(nil, dir) + g = GhostAnswer{} + if err := json.Unmarshal(h.Ghost("zf", "", 0), &g); err != nil { + t.Fatal(err) + } + if !g.Found || g.Run != "r-clean" || !g.Complete || g.Left != 0 || g.LegsDone != 4 { + t.Fatalf("a done clean run beats the aborted and dirty ones: %+v", g) + } +} + func TestHistoryGhostLaterRunLeaderWithoutLeftClears(t *testing.T) { w := fullRun("r-stale", "zf", 100, 600, 4, "done", nil) w.event(598000, "run_leader", `"mode":"observe","state":"done","step":41,"steps":41,"leg_name":"archaedas","kind":"boss","phase":"pull","note":"plan done","left":2`)