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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3582,7 +3582,8 @@ most sections; `GET /api/heat/<plan>` 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 <K>`):
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
Expand Down
11 changes: 9 additions & 2 deletions internal/dashboard/card.go
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"
}
Expand Down Expand Up @@ -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:]
Expand All @@ -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
}

Expand Down
12 changes: 11 additions & 1 deletion internal/dashboard/digest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
b.left = int(e.Left)
}
return
case "leg":
var e struct {
Leg int `json:"leg"`
Expand Down Expand Up @@ -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{}}
Expand Down
36 changes: 28 additions & 8 deletions internal/dashboard/history.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,17 +253,19 @@ 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"`
Path [][3]float64 `json:"path,omitempty"` // [x, y, seconds since the run began] of the lead
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" 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.
// 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 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 {
h.mu.Lock()
defer h.mu.Unlock()
Expand Down Expand Up @@ -295,19 +297,20 @@ 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(f, d, bestF, best)) {
best, bestF = d, f
}
}

if best == nil {
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 = confirmedClear(bestF, best)
}
for _, p := range best.Paths {
if p.Lead {
Expand All @@ -321,6 +324,23 @@ func (h *History) Ghost(plan, exclude string, planLegs int) []byte {
return h.keepGhost(key, ans)
}

// 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
}
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}
Expand Down
16 changes: 16 additions & 0 deletions internal/dashboard/leader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))})
Expand Down
122 changes: 122 additions & 0 deletions internal/dashboard/paths_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,128 @@ 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 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 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`)
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++ {
Expand Down
3 changes: 2 additions & 1 deletion internal/dashboard/static/paths.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----
Expand Down
1 change: 1 addition & 0 deletions internal/dashboard/testdata/paths_check.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading