From 1012b659d99da77606bca47738c137d80bdbfa8d Mon Sep 17 00:00:00 2001 From: pjerra <71784785+pjerra@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:24:59 +0200 Subject: [PATCH 1/3] trace-score: add taunt latency, cc hold and pet threat to the spells check --- README.md | 2 +- cmd/turtle-agent/trace_cmd.go | 2 +- internal/tracecheck/spells.go | 51 ++++-- internal/tracecheck/spells_more.go | 254 +++++++++++++++++++++++++++++ internal/tracecheck/spells_test.go | 217 ++++++++++++++++++++++++ internal/tracecheck/tracecheck.go | 17 +- 6 files changed, 527 insertions(+), 16 deletions(-) create mode 100644 internal/tracecheck/spells_more.go diff --git a/README.md b/README.md index d2c453c9..6a4c23c4 100644 --- a/README.md +++ b/README.md @@ -1467,7 +1467,7 @@ the others look at what only the agent sees (orders, rest gates, walks, the feed | `gm_shortcuts` | the honest-run flag: the run used any GM shortcut or was set up with `setup` commands, so a player could not have done it | 0 | never | | `stuck` | a member that should be moving (the lead in a walk of the plan, or a member more than 10 yd from the lead in one; alive and out of combat) did not move 1 yd for this long; the detail lists where, for a per-dungeon stuck-spot map | 5 s, any stall | no walk of the plan, or no frames | | `facing` | a bot in combat stands (no movement) within 6 yd of its hostile target for longer than this while its `fa` is over 90 degrees (the server rule, without its casting case: the agent does not see casts) | 2 s | the header lacks the `facing` capability, or no frame has a bot in melee range of its target | -| `spells` | **report only, never fails**: what each class cast and did, as readings of 1 Hz samples, not facts. Cast episodes per class from `cs` (a run of frames with the same spell is `ceil(duration / base cast time)` casts, at most one per frame; a spell with no known cast time counts once per run, so that number is a lower bound), action mix, verdict letters and reasons, idle share, power at death, taunts, and two proximity/observation readings: **interrupts** (each interrupt action is matched to at most one enemy cast, from 1 s before its first frame to its last frame, and the interrupter's target must be the casting mob when both are known; an action in the frame right after the cast's last is counted apart, because an interrupt that worked and a kick that came too late look the same at 1 Hz; "confirmed cut short" is a cast seen for under 80 % of its cast time) and **crowd control** (an observation, not a success rate: some mob shows a new cc flag within 4 s of a single-target action; it is not tied to the caster or the spell, and a new flag on the caster's own target is counted apart). A verdict is *unknown* where that bot sent no verdict letter (`res`) within 10 s: it is left out of the idle share and not counted as executed, so an older or mixed trace says `unknown`/`not recorded` instead of guessing. `trace-score` prints the tables under the checks; `--json` has them as `spells` | warnings only: `idle_share` over 15 % of engaged mana-user frames with a verdict and no executed (`O`) action for 4 s; `interrupt_cover` under 30 % of enemy casts (from 5) with an interrupt action during or just after them (the favourable reading is held to the limit); `--set idle_share=20`, `--set interrupt_cover=20`. The crowd-control observation has no limit | no bot frame has a `cs` or an `act` | +| `spells` | **report only, never fails**: what each class cast and did, as readings of 1 Hz samples, not facts. Cast episodes per class from `cs` (a run of frames with the same spell is `ceil(duration / base cast time)` casts, at most one per frame; a spell with no known cast time counts once per run, so that number is a lower bound), action mix, verdict letters and reasons, idle share, power at death, taunts, and two proximity/observation readings: **interrupts** (each interrupt action is matched to at most one enemy cast, from 1 s before its first frame to its last frame, and the interrupter's target must be the casting mob when both are known; an action in the frame right after the cast's last is counted apart, because an interrupt that worked and a kick that came too late look the same at 1 Hz; "confirmed cut short" is a cast seen for under 80 % of its cast time) and **crowd control** (an observation, not a success rate: some mob shows a new cc flag within 4 s of a single-target action; it is not tied to the caster or the spell, and a new flag on the caster's own target is counted apart), **taunt latency** (seconds from a hostile's top victim becoming a party member that is not the tank to the tank's taunt or the hostile going back to the tank; the frames are the resolution, about 1 s), **cc hold** (a cc episode is a run of consecutive frames with the mob flagged: only an episode that starts within 4 s of a single-target cc action is joined, and a joined episode under 5 s that ends with the mob still in view and unflagged was broken; the party's targeting of the mark is not measured) and **pet threat** (the share of mob-frames whose top threat holder's guid starts `0xf140`). A verdict is *unknown* where that bot sent no verdict letter (`res`) within 10 s: it is left out of the idle share and not counted as executed, so an older or mixed trace says `unknown`/`not recorded` instead of guessing. `trace-score` prints the tables under the checks; `--json` has them as `spells` | warnings only: `idle_share` over 15 % of engaged mana-user frames with a verdict and no executed (`O`) action for 4 s; `interrupt_cover` under 30 % of enemy casts (from 5) with an interrupt action during or just after them (the favourable reading is held to the limit); `taunt_latency` over 3 s median (from 3 episodes); `cc_hold` under 50 % held (from 3 episodes); `pet_threat` over 5 % of mob-frames (from 30); `--set idle_share=20`, `--set interrupt_cover=20`, `--set taunt_latency=4`, `--set cc_hold=40`, `--set pet_threat=10`. The uncorrelated crowd-control flag observation has no limit | no bot frame has a `cs` or an `act` | Not verified live: the traces were built and tested against the fake game, the fake feed and hand-made traces only. Open: whether a group run's party list stays fresh in a long fight (`stale_party` is there to find out), the feed's facing values, and what the frame diff --git a/cmd/turtle-agent/trace_cmd.go b/cmd/turtle-agent/trace_cmd.go index 8010741e..1988f81c 100644 --- a/cmd/turtle-agent/trace_cmd.go +++ b/cmd/turtle-agent/trace_cmd.go @@ -33,7 +33,7 @@ func traceScoreCmd(args []string, stdout, stderr io.Writer) int { asJSON := fs.Bool("json", false, "print the verdicts as JSON (one object per trace)") summary := fs.Bool("summary", false, "write .summary.json beside each trace") var sets thresholdFlags - fs.Var(&sets, "set", "change a threshold: check=value (rest_share, stale_party, walk_stops, threat_off_tank, order_refusals, feed_stalls, stuck, facing, and the spell warnings idle_share, interrupt_cover)") + fs.Var(&sets, "set", "change a threshold: check=value (rest_share, stale_party, walk_stops, threat_off_tank, order_refusals, feed_stalls, stuck, facing, and the spell warnings idle_share, interrupt_cover, taunt_latency, cc_hold, pet_threat)") if err := fs.Parse(args); err != nil { fmt.Fprintln(stderr, traceScoreUsage) return exitUsage diff --git a/internal/tracecheck/spells.go b/internal/tracecheck/spells.go index df07c283..24122cf4 100644 --- a/internal/tracecheck/spells.go +++ b/internal/tracecheck/spells.go @@ -34,18 +34,21 @@ const ( // SpellReport is the report; Lines prints it. Fields are exported for --json. type SpellReport struct { - Frames int `json:"frames"` // frames with bots - CastsSeen bool `json:"casts_seen"` // any bot frame has a cs - ActsSeen bool `json:"acts_seen"` // any bot frame has an act - VerdictSeen bool `json:"verdict_seen"` // any bot frame has the verdict letter (res) - Classes []ClassSpell `json:"classes"` - Idle IdleShare `json:"idle"` - Deaths []DeathMana `json:"deaths,omitempty"` - DryBots int `json:"dry_bots"` // mana users that went under 10 % mana in combat - Interrupt InterruptUse `json:"interrupt"` - Taunts TauntUse `json:"taunts"` - CC CCUse `json:"cc"` - Warnings []string `json:"warnings,omitempty"` + Frames int `json:"frames"` // frames with bots + CastsSeen bool `json:"casts_seen"` // any bot frame has a cs + ActsSeen bool `json:"acts_seen"` // any bot frame has an act + VerdictSeen bool `json:"verdict_seen"` // any bot frame has the verdict letter (res) + Classes []ClassSpell `json:"classes"` + Idle IdleShare `json:"idle"` + Deaths []DeathMana `json:"deaths,omitempty"` + DryBots int `json:"dry_bots"` // mana users that went under 10 % mana in combat + Interrupt InterruptUse `json:"interrupt"` + Taunts TauntUse `json:"taunts"` + CC CCUse `json:"cc"` + TauntLatency TauntLatency `json:"taunt_latency"` + CCHold CCHold `json:"cc_hold"` + PetThreat PetThreat `json:"pet_threat"` + Warnings []string `json:"warnings,omitempty"` } // ClassSpell is one class's casts, action mix and verdicts. @@ -872,6 +875,10 @@ func Spells(f *trace.File, c Config) SpellReport { } r.CC.BySpell = topN(ccBySpell, 5) + r.TauntLatency = tauntLatency(f, acts, ivS) + r.CCHold = ccHold(f, ccs, ivS) + r.PetThreat = petThreat(f) + r.warn(c) return r } @@ -885,6 +892,15 @@ func (r *SpellReport) warn(c Config) { if r.Interrupt.Casts >= minCastsRate && r.Interrupt.PctWithAfter < c.InterruptCoverPct { r.Warnings = append(r.Warnings, fmt.Sprintf("interrupt_cover only %.1f %% of %d enemy casts have an interrupt action during or just after them (limit %g; proximity, not confirmed)", r.Interrupt.PctWithAfter, r.Interrupt.Casts, c.InterruptCoverPct)) } + if t := r.TauntLatency; t.Episodes >= minTauntEpisodes && t.MedianS > c.TauntLatencyS { + r.Warnings = append(r.Warnings, fmt.Sprintf("taunt_latency median %.1f s over %d episodes where a non-tank held a mob (limit %g; frames are samples, the resolution is about one frame)", t.MedianS, t.Episodes, c.TauntLatencyS)) + } + if h := r.CCHold; h.Joined >= minCCEpisodes && h.HeldPct < c.CCHoldPct { + r.Warnings = append(r.Warnings, fmt.Sprintf("cc_hold only %.1f %% of %d crowd-control episodes held %.0f s or more (limit %g %%; flags are not tied to the caster)", h.HeldPct, h.Joined, ccBrokenS, c.CCHoldPct)) + } + if p := r.PetThreat; p.MobFrames >= minPetMobFrames && p.Pct > c.PetThreatPct { + r.Warnings = append(r.Warnings, fmt.Sprintf("pet_threat %.1f %% of %d mob-frames have a pet on top threat (limit %g %%)", p.Pct, p.MobFrames, c.PetThreatPct)) + } } // castTimeMS is the base cast time of a spell in ms, 0 when unknown or instant (channels and unknown ids too). @@ -1041,6 +1057,17 @@ func (r SpellReport) Lines() []string { out = append(out, fmt.Sprintf(" crowd control (observation, not a success rate): %d actions not known to have failed (%s; %d with no verdict letter); a new cc flag on some mob within %.0f s after %d of %d (not tied to the caster or the spell); on the caster's own target %d of %d where it was in view", r.CC.Actions, joinCounts(r.CC.BySpell), r.CC.Unknown, ccWindowS, r.CC.FlagSeen, r.CC.WithMobs, r.CC.Attributed, r.CC.TargetSeen)) } + if t := r.TauntLatency; !t.Recorded { + out = append(out, " taunt latency: not measured (no tank named in the trace, or no mob victims)") + } else { + out = append(out, fmt.Sprintf(" taunt latency: %d episodes where a mob's top victim was a party member that is not the tank; answered by the tank's taunt %d, back on the tank without one %d, dropped open %d; median %.1f s, worst %.1f s (1 Hz samples)", t.Episodes, t.ByTaunt, t.Returned, t.Open, t.MedianS, t.WorstS)) + } + if h := r.CCHold; h.Joined == 0 { + out = append(out, " cc hold: no crowd-control episode joined to a single-target cc action; flags are not tied to the caster (1 Hz samples)") + } else { + out = append(out, fmt.Sprintf(" cc hold: %d joined episodes; %d broken under %.0f s; %.1f %% held; median length %.1f s; flags are not tied to the caster (1 Hz samples)", h.Joined, h.Broken, ccBrokenS, h.HeldPct, h.MedianS)) + } + out = append(out, fmt.Sprintf(" pet threat: a pet (guid 0xf140...) held the top threat in %d of %d mob-frames (%.1f %%) (1 Hz samples)", r.PetThreat.PetTop, r.PetThreat.MobFrames, r.PetThreat.Pct)) for _, w := range r.Warnings { out = append(out, " WARN "+w) } diff --git a/internal/tracecheck/spells_more.go b/internal/tracecheck/spells_more.go new file mode 100644 index 00000000..7d9e3ff7 --- /dev/null +++ b/internal/tracecheck/spells_more.go @@ -0,0 +1,254 @@ +package tracecheck + +import ( + "sort" + "strings" + + "github.com/pjerra/turtle-agent/internal/trace" +) + +// The three rate readings of the spells report: how fast a mob off the tank was answered, whether a crowd-control episode held, +// and whose threat was on top. All three are warnings only and need a minimum sample. + +const ( + minTauntEpisodes = 3 + minCCEpisodes = 3 + minPetMobFrames = 30 + ccBrokenS = 5.0 // a joined cc episode shorter than this, ended by the mob still being in view unflagged, was broken +) + +// TauntLatency is how long a hostile whose top victim was a party member that is not the tank took to be answered: by the tank's +// taunt, or by the hostile going back to the tank. The frames are samples, so a latency can be about one frame interval off. +type TauntLatency struct { + Recorded bool `json:"recorded"` // a tank is known and threat or targets are in the frames + Episodes int `json:"episodes"` + ByTaunt int `json:"answered_by_taunt"` + Returned int `json:"returned_without_taunt"` + Open int `json:"dropped_open"` // episodes cut off by the mob's death, leaving the frames, the tank's death or the end of the trace + MedianS float64 `json:"median_s"` + WorstS float64 `json:"worst_s"` +} + +// CCHold is the crowd-control episodes joined to a single-target cc action and whether they held. The flags are not tied to the +// caster: the feed does not say who a polymorph went to. +type CCHold struct { + Joined int `json:"joined_episodes"` + Broken int `json:"broken"` + HeldPct float64 `json:"held_pct"` + MedianS float64 `json:"median_length_s"` +} + +// PetThreat is the share of mob-frames whose top threat holder is a pet (guid prefix 0xf140). +type PetThreat struct { + MobFrames int `json:"mob_frames"` + PetTop int `json:"pet_top"` + Pct float64 `json:"pct"` +} + +// headerTank is the tank named in the header, else the first party member with the tank role ("" when neither says). +func headerTank(f *trace.File) string { + if m, ok := f.Member(f.Header.Tank); ok { + return m.G + } + for _, m := range f.Party { + if m.Role == "tank" { + return m.G + } + } + return "" +} + +func median(v []float64) float64 { + if len(v) == 0 { + return 0 + } + if len(v)%2 == 1 { + return v[len(v)/2] + } + return (v[len(v)/2-1] + v[len(v)/2]) / 2 +} + +// tauntLatency measures the time from "a hostile's top victim became a party member that is not the tank" to "the tank taunted, or +// the hostile went back to the tank". A victim that is not a party member (a pet, an unknown unit) neither opens nor closes an +// episode; a mob that dies, leaves the frames or the tank that dies cuts its episode off (Recorded and Open). +func tauntLatency(f *trace.File, acts []actEv, ivS float64) TauntLatency { + _ = ivS // the text of the reading carries the caveat; the frames are the resolution + var r TauntLatency + tank := headerTank(f) + if tank == "" { + return r + } + party := map[string]bool{} + for _, m := range f.Party { + party[m.G] = true + } + for _, fr := range f.Frames { + for _, b := range fr.Bots { + party[b.G] = true + } + } + var taunts []int64 + for _, a := range acts { + if cat, _ := CategoryOf(a.name); cat == CatTaunt && a.g == tank && a.plausible() { + taunts = append(taunts, a.t) + } + } + sort.Slice(taunts, func(i, j int) bool { return taunts[i] < taunts[j] }) + open := map[string]int64{} // guid -> the frame time the episode began + var lats []float64 + closeEp := func(g string, st, at int64) { + delete(open, g) + tt := int64(-1) + for _, t := range taunts { + if t >= st && t <= at { + tt = t + break + } + } + lat := float64(at-st) / 1000 + if tt >= 0 { + lat = float64(tt-st) / 1000 + r.ByTaunt++ + } else { + r.Returned++ + } + r.Episodes++ + lats = append(lats, lat) + } + for _, fr := range f.Frames { + tankAlive := false + for _, b := range fr.Bots { + if b.G == tank { + tankAlive = b.Alive + break + } + } + if !tankAlive { + r.Open += len(open) + open = map[string]int64{} + continue + } + inFrame := map[string]trace.Mob{} + for _, m := range fr.Mobs { + inFrame[m.G] = m + if m.HPPct <= 0 || !m.Combat { + continue + } + victim := m.Target + if len(m.Threat) > 0 { + victim = m.Threat[0].G + } + if victim == "" || !party[victim] { + continue + } + r.Recorded = true + st, isOpen := open[m.G] + switch { + case victim != tank && !isOpen: + open[m.G] = fr.T + case victim == tank && isOpen: + closeEp(m.G, st, fr.T) + } + } + for g := range open { + if m, ok := inFrame[g]; !ok || m.HPPct <= 0 || !m.Combat { + r.Open++ + delete(open, g) + } + } + } + r.Open += len(open) + if len(lats) > 0 { + sort.Float64s(lats) + r.MedianS = round1(median(lats)) + r.WorstS = round1(lats[len(lats)-1]) + } + return r +} + +// ccHold follows the cc flag of every mob: an episode is a run of consecutive frames with the mob present and flagged. Only the +// episodes that start within the cc window of a single-target cc action are joined (Joined); a joined episode under ccBrokenS +// that ends with the mob still in view and unflagged was broken, and every other one held. +func ccHold(f *trace.File, ccs []actEv, ivS float64) CCHold { + var r CCHold + type episode struct { + start, last int64 + } + open := map[string]*episode{} + var lens []float64 + end := func(g string, e *episode, unflagged bool) { + delete(open, g) + joined := false + for _, a := range ccs { + if e.start >= a.t && e.start <= a.t+int64(ccWindowS*1000) { + joined = true + break + } + } + if !joined { + return + } + length := float64(e.last-e.start)/1000 + ivS + r.Joined++ + if unflagged && length < ccBrokenS { + r.Broken++ + } + lens = append(lens, length) + } + for _, fr := range f.Frames { + present := map[string]bool{} + for _, m := range fr.Mobs { + present[m.G] = true + e := open[m.G] + switch { + case e == nil: + if m.CC && m.HPPct > 0 { + open[m.G] = &episode{start: fr.T, last: fr.T} + } + case !m.CC: + end(m.G, e, true) + case m.HPPct == 0: + end(m.G, e, false) // dead + default: + e.last = fr.T + } + } + for g, e := range open { + if !present[g] { + end(g, e, false) // gone from the frames: its end was not seen + } + } + } + for g, e := range open { + end(g, e, false) // the trace ended first + } + if r.Joined > 0 { + r.HeldPct = round1(float64(r.Joined-r.Broken) * 100 / float64(r.Joined)) + } + if len(lens) > 0 { + sort.Float64s(lens) + r.MedianS = round1(median(lens)) + } + return r +} + +// petThreat counts the mob-frames whose top threat holder is a pet. The trace writes guids lower case (0x%x), so the prefix test +// lower-cases the guid too. +func petThreat(f *trace.File) PetThreat { + var r PetThreat + for _, fr := range f.Frames { + for _, m := range fr.Mobs { + if m.HPPct <= 0 || !m.Combat || len(m.Threat) == 0 { + continue + } + r.MobFrames++ + if strings.HasPrefix(strings.ToLower(m.Threat[0].G), "0xf140") { + r.PetTop++ + } + } + } + if r.MobFrames > 0 { + r.Pct = round1(float64(r.PetTop) * 100 / float64(r.MobFrames)) + } + return r +} diff --git a/internal/tracecheck/spells_test.go b/internal/tracecheck/spells_test.go index c9dc6740..ae0e4c7d 100644 --- a/internal/tracecheck/spells_test.go +++ b/internal/tracecheck/spells_test.go @@ -636,3 +636,220 @@ func TestSpellsMixedVersionTrace(t *testing.T) { t.Fatalf("%+v", r.Idle) } } + +// tauntTrace: a hostile on the tank that is switched to the healer for [start, start+lat] of episode k (start 10+25k), with the +// tank taunting at start+lat when taunt is set. No tank is named in the header: the caller sets it. +func tauntTrace(lats []int, taunt bool) *build { + return spellTrace(200, func(s int) ([]trace.Bot, []trace.Mob) { + mob := trace.Mob{G: "0xf1", HPPct: 80, Combat: true, Target: gTank, Threat: []trace.Threat{{G: gTank, Pct: 100}}} + tank := sb(gTank, "sunder armor", "O") + for k, lat := range lats { + start := 10 + 25*k + if s >= start && s <= start+lat { + mob.Target = gHealer + mob.Threat = []trace.Threat{{G: gHealer, Pct: 100}} + } + if taunt && s == start+lat { + tank = sb(gTank, "taunt", "O") + } + } + return []trace.Bot{tank, sb(gHealer, "flash heal", "O")}, []trace.Mob{mob} + }) +} + +// The median time from a non-tank victim to the tank's taunt, or to the hostile going back to the tank. +func TestSpellsTauntLatencyMedian(t *testing.T) { + // Answered by a taunt: the latency is the frame time of the taunt. + b := tauntTrace([]int{2, 2, 2, 6}, true) + b.f.Header.Tank = "Tank" + r := Spells(b.f, Defaults()) + if t1 := r.TauntLatency; t1.Episodes != 4 || t1.ByTaunt != 4 || t1.MedianS != 2 || t1.WorstS != 6 { + t.Fatalf("%+v", t1) + } + for _, w := range r.Warnings { + if strings.Contains(w, "taunt_latency") { + t.Fatalf("median 2 s is within the limit: %q", w) + } + } + // A median over the limit warns, and the check still passes with the warning in its detail. + b = tauntTrace([]int{6, 7, 8, 2}, true) + b.f.Header.Tank = "Tank" + r = Spells(b.f, Defaults()) + if t1 := r.TauntLatency; t1.MedianS != 6.5 { + t.Fatalf("%+v", t1) + } + warned := false + for _, w := range r.Warnings { + if strings.Contains(w, "taunt_latency") { + warned = true + } + } + if !warned { + t.Fatalf("no warning: %q", r.Warnings) + } + c := get(t, Score(b.f, Defaults()), IDSpells) + if c.Verdict != trace.Pass || !strings.Contains(c.Detail, "taunt_latency") { + t.Fatalf("%+v", c) + } + // Returned to the tank without a taunt: the latency is the return frame's time minus the start (lat+1). + b = tauntTrace([]int{2, 2, 4, 4}, false) + b.f.Header.Tank = "Tank" + if t1 := Spells(b.f, Defaults()).TauntLatency; t1.Episodes != 4 || t1.ByTaunt != 0 || t1.Returned != 4 || t1.MedianS != 4 { + t.Fatalf("%+v", t1) + } + // Fewer than three episodes: no warning. + b = tauntTrace([]int{9, 9}, true) + b.f.Header.Tank = "Tank" + r = Spells(b.f, Defaults()) + if r.TauntLatency.Episodes != 2 || len(r.Warnings) != 0 { + t.Fatalf("%+v %q", r.TauntLatency, r.Warnings) + } + // No tank named anywhere: not measured. + b = tauntTrace([]int{2, 2, 2, 6}, true) + if t1 := Spells(b.f, Defaults()).TauntLatency; t1.Recorded || t1.Episodes != 0 { + t.Fatalf("%+v", t1) + } +} + +// The crowd-control hold: an episode is a run of consecutive frames with the mob flagged, joined to a single-target cc action. +func TestSpellsCCHoldBrokenEpisodes(t *testing.T) { + const polyEvery = 20 + ccTrace := func(lens []int, extra int, goneFrom int) *build { + return spellTrace(100, func(s int) ([]trace.Bot, []trace.Mob) { + m := sb(gMage, "frostbolt", "O") + if s >= 10 && s <= 70 && s%polyEvery == 10 { + m = sb(gMage, "polymorph", "O") + m.Target = "0xf1" + } + flagged := false + for k, n := range lens { + p := 10 + polyEvery*k + if s >= p+1 && s <= p+n { + flagged = true + } + } + if extra > 0 && s >= 85 && s < 85+extra { + flagged = true + } + var mobs []trace.Mob + if goneFrom < 0 || s < goneFrom { + mobs = append(mobs, trace.Mob{G: "0xf2", HPPct: 80, Combat: true, Target: gTank, CC: flagged}) + } + return []trace.Bot{m}, mobs + }) + } + // Four joined episodes, two of them shorter than 5 s: half held, no warning at exactly 50 %. + r := Spells(ccTrace([]int{10, 10, 2, 2}, 0, -1).f, Defaults()) + if h := r.CCHold; h.Joined != 4 || h.Broken != 2 || h.HeldPct != 50 { + t.Fatalf("%+v", h) + } + for _, w := range r.Warnings { + if strings.Contains(w, "cc_hold") { + t.Fatalf("50 %% is not under the limit: %q", w) + } + } + // Three of four broken warns. + r = Spells(ccTrace([]int{10, 2, 2, 2}, 0, -1).f, Defaults()) + if h := r.CCHold; h.Broken != 3 || h.HeldPct != 25 { + t.Fatalf("%+v", h) + } + warned := false + for _, w := range r.Warnings { + if strings.Contains(w, "cc_hold") { + warned = true + } + } + if !warned { + t.Fatalf("no warning: %q", r.Warnings) + } + // A flagged run with no cc action before it is not joined: left out. + r = Spells(ccTrace([]int{10, 10, 2, 2}, 2, -1).f, Defaults()) + if h := r.CCHold; h.Joined != 4 { + t.Fatalf("%+v", h) + } + // A mob that leaves the frames after a short joined episode was not seen unflagged: held, not broken. + r = Spells(ccTrace([]int{10, 10, 2, 2}, 0, 73).f, Defaults()) + if h := r.CCHold; h.Joined != 4 || h.Broken != 1 { + t.Fatalf("%+v", h) + } +} + +// The share of mob-frames whose top threat holder is a pet (guid prefix 0xf140). +func TestSpellsPetThreatShare(t *testing.T) { + const pet = "0xf140000000000009" + petTrace := func(secs int, petFrame func(s int) bool) *build { + return spellTrace(secs, func(s int) ([]trace.Bot, []trace.Mob) { + thr := []trace.Threat{{G: gTank, Pct: 100}} + if petFrame(s) { + thr = []trace.Threat{{G: pet, Pct: 100}} + } + return []trace.Bot{sb(gTank, "sunder armor", "O")}, []trace.Mob{{G: "0xf1", HPPct: 80, Combat: true, Target: gTank, Threat: thr}} + }) + } + r := Spells(petTrace(60, func(s int) bool { return s < 6 }).f, Defaults()) + if p := r.PetThreat; p.MobFrames != 60 || p.PetTop != 6 || p.Pct != 10 { + t.Fatalf("%+v", p) + } + warned := false + for _, w := range r.Warnings { + if strings.Contains(w, "pet_threat") { + warned = true + } + } + if !warned { + t.Fatalf("no warning: %q", r.Warnings) + } + r = Spells(petTrace(60, func(s int) bool { return s == 0 }).f, Defaults()) + if p := r.PetThreat; p.Pct != 1.7 || len(r.Warnings) != 0 { + t.Fatalf("%+v %q", p, r.Warnings) + } + // Half the frames, but under 30 mob-frames: no warning. + r = Spells(petTrace(20, func(s int) bool { return s < 10 }).f, Defaults()) + if p := r.PetThreat; p.MobFrames != 20 || p.Pct != 50 || len(r.Warnings) != 0 { + t.Fatalf("%+v %q", p, r.Warnings) + } + // A dead mob, one out of combat and one with no threat list are not mob-frames. + b := spellTrace(5, func(s int) ([]trace.Bot, []trace.Mob) { + return []trace.Bot{sb(gTank, "sunder armor", "O")}, []trace.Mob{ + {G: "0xf1", HPPct: 0, Combat: true, Threat: []trace.Threat{{G: pet, Pct: 100}}}, + {G: "0xf2", HPPct: 90, Combat: false, Threat: []trace.Threat{{G: pet, Pct: 100}}}, + {G: "0xf3", HPPct: 90, Combat: true}, + } + }) + if p := Spells(b.f, Defaults()).PetThreat; p.MobFrames != 0 || p.PetTop != 0 { + t.Fatalf("%+v", p) + } +} + +func TestSpellsNewLimitsAreSettable(t *testing.T) { + d := Defaults() + if d.TauntLatencyS != 3 || d.CCHoldPct != 50 || d.PetThreatPct != 5 { + t.Fatalf("%+v", d) + } + cfg := Defaults() + for id, field := range map[string]*float64{IDTauntLatency: &cfg.TauntLatencyS, IDCCHold: &cfg.CCHoldPct, IDPetThreat: &cfg.PetThreatPct} { + if err := cfg.Set(id, 7); err != nil || *field != 7 { + t.Errorf("%s: %v %g", id, err, *field) + } + } + err := (&Config{}).Set("bogus", 1) + if err == nil { + t.Fatal("bogus is no threshold") + } + for _, id := range []string{IDTauntLatency, IDCCHold, IDPetThreat} { + if !strings.Contains(err.Error(), id) { + t.Errorf("%q is not in %q", id, err) + } + } +} + +func TestSpellsLinesNameTheNewReadings(t *testing.T) { + b := tauntTrace([]int{6, 7, 8, 2}, true) + b.f.Header.Tank = "Tank" + text := strings.Join(Spells(b.f, Defaults()).Lines(), "\n") + for _, want := range []string{"taunt latency", "cc hold", "pet threat", "1 Hz"} { + if !strings.Contains(text, want) { + t.Errorf("%q is missing in\n%s", want, text) + } + } +} diff --git a/internal/tracecheck/tracecheck.go b/internal/tracecheck/tracecheck.go index a8edc7fb..b97fc75e 100644 --- a/internal/tracecheck/tracecheck.go +++ b/internal/tracecheck/tracecheck.go @@ -30,6 +30,9 @@ const ( // Thresholds of the spells check (its own id is IDSpells): they make warnings, never a fail. IDIdleShare = "idle_share" IDInterruptCover = "interrupt_cover" + IDTauntLatency = "taunt_latency" + IDCCHold = "cc_hold" + IDPetThreat = "pet_threat" maxEvidence = 8 minRunSeconds = 60 @@ -57,14 +60,18 @@ type Config struct { // The spells check only warns: these are the limits of its warnings. IdleSharePct float64 // percent of engaged mana-user frames without an executed action (a warning above this) InterruptCoverPct float64 // percent of enemy casts with an interrupt action near them (a warning below this; proximity, not a confirmed interrupt) + TauntLatencyS float64 // median seconds from a non-tank victim to a taunt or a return (a warning above this) + CCHoldPct float64 // percent of cc episodes that held (a warning below this) + PetThreatPct float64 // percent of mob-frames with a pet on top threat (a warning above this) } // Defaults are the thresholds of the first set of checks. func Defaults() Config { - return Config{RestShareMax: 40, StaleSeconds: 60, StopsPer100Max: 4, ThreatOffTankS: 4, RefusalRateMax: 20, FeedGapMax: 10, StuckSeconds: 5, FacingHoldS: 2, IdleSharePct: 15, InterruptCoverPct: 30} + return Config{RestShareMax: 40, StaleSeconds: 60, StopsPer100Max: 4, ThreatOffTankS: 4, RefusalRateMax: 20, FeedGapMax: 10, StuckSeconds: 5, FacingHoldS: 2, IdleSharePct: 15, InterruptCoverPct: 30, + TauntLatencyS: 3, CCHoldPct: 50, PetThreatPct: 5} } -var thresholdIDs = []string{IDRestShare, IDStaleParty, IDWalkStops, IDThreat, IDRefusals, IDFeedStalls, IDStuck, IDFacing, IDIdleShare, IDInterruptCover} +var thresholdIDs = []string{IDRestShare, IDStaleParty, IDWalkStops, IDThreat, IDRefusals, IDFeedStalls, IDStuck, IDFacing, IDIdleShare, IDInterruptCover, IDTauntLatency, IDCCHold, IDPetThreat} // Set changes the threshold of the check with the given id (the CLI's --set id=value). deaths and gm_shortcuts have none. func (c *Config) Set(id string, v float64) error { @@ -89,6 +96,12 @@ func (c *Config) Set(id string, v float64) error { c.IdleSharePct = v case IDInterruptCover: c.InterruptCoverPct = v + case IDTauntLatency: + c.TauntLatencyS = v + case IDCCHold: + c.CCHoldPct = v + case IDPetThreat: + c.PetThreatPct = v default: return fmt.Errorf("no threshold for check %q (have %s)", id, strings.Join(thresholdIDs, ", ")) } From 966c4e13c761d87756b99c020fd0be2b4c750a19 Mon Sep 17 00:00:00 2001 From: pjerra <71784785+pjerra@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:10:39 +0200 Subject: [PATCH 2/3] trace-score: fix cc_hold and taunt latency false warnings cc_hold joins only long crowd control (the short stuns end unflagged and read as broken holds) and a mob killed while flagged is not broken. A taunt with a target answers only that mob's episode. --- README.md | 2 +- internal/tracecheck/spells.go | 18 ++++- internal/tracecheck/spells_more.go | 29 ++++---- internal/tracecheck/spells_test.go | 111 +++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 6a4c23c4..5ebf0f87 100644 --- a/README.md +++ b/README.md @@ -1467,7 +1467,7 @@ the others look at what only the agent sees (orders, rest gates, walks, the feed | `gm_shortcuts` | the honest-run flag: the run used any GM shortcut or was set up with `setup` commands, so a player could not have done it | 0 | never | | `stuck` | a member that should be moving (the lead in a walk of the plan, or a member more than 10 yd from the lead in one; alive and out of combat) did not move 1 yd for this long; the detail lists where, for a per-dungeon stuck-spot map | 5 s, any stall | no walk of the plan, or no frames | | `facing` | a bot in combat stands (no movement) within 6 yd of its hostile target for longer than this while its `fa` is over 90 degrees (the server rule, without its casting case: the agent does not see casts) | 2 s | the header lacks the `facing` capability, or no frame has a bot in melee range of its target | -| `spells` | **report only, never fails**: what each class cast and did, as readings of 1 Hz samples, not facts. Cast episodes per class from `cs` (a run of frames with the same spell is `ceil(duration / base cast time)` casts, at most one per frame; a spell with no known cast time counts once per run, so that number is a lower bound), action mix, verdict letters and reasons, idle share, power at death, taunts, and two proximity/observation readings: **interrupts** (each interrupt action is matched to at most one enemy cast, from 1 s before its first frame to its last frame, and the interrupter's target must be the casting mob when both are known; an action in the frame right after the cast's last is counted apart, because an interrupt that worked and a kick that came too late look the same at 1 Hz; "confirmed cut short" is a cast seen for under 80 % of its cast time) and **crowd control** (an observation, not a success rate: some mob shows a new cc flag within 4 s of a single-target action; it is not tied to the caster or the spell, and a new flag on the caster's own target is counted apart), **taunt latency** (seconds from a hostile's top victim becoming a party member that is not the tank to the tank's taunt or the hostile going back to the tank; the frames are the resolution, about 1 s), **cc hold** (a cc episode is a run of consecutive frames with the mob flagged: only an episode that starts within 4 s of a single-target cc action is joined, and a joined episode under 5 s that ends with the mob still in view and unflagged was broken; the party's targeting of the mark is not measured) and **pet threat** (the share of mob-frames whose top threat holder's guid starts `0xf140`). A verdict is *unknown* where that bot sent no verdict letter (`res`) within 10 s: it is left out of the idle share and not counted as executed, so an older or mixed trace says `unknown`/`not recorded` instead of guessing. `trace-score` prints the tables under the checks; `--json` has them as `spells` | warnings only: `idle_share` over 15 % of engaged mana-user frames with a verdict and no executed (`O`) action for 4 s; `interrupt_cover` under 30 % of enemy casts (from 5) with an interrupt action during or just after them (the favourable reading is held to the limit); `taunt_latency` over 3 s median (from 3 episodes); `cc_hold` under 50 % held (from 3 episodes); `pet_threat` over 5 % of mob-frames (from 30); `--set idle_share=20`, `--set interrupt_cover=20`, `--set taunt_latency=4`, `--set cc_hold=40`, `--set pet_threat=10`. The uncorrelated crowd-control flag observation has no limit | no bot frame has a `cs` or an `act` | +| `spells` | **report only, never fails**: what each class cast and did, as readings of 1 Hz samples, not facts. Cast episodes per class from `cs` (a run of frames with the same spell is `ceil(duration / base cast time)` casts, at most one per frame; a spell with no known cast time counts once per run, so that number is a lower bound), action mix, verdict letters and reasons, idle share, power at death, taunts, and two proximity/observation readings: **interrupts** (each interrupt action is matched to at most one enemy cast, from 1 s before its first frame to its last frame, and the interrupter's target must be the casting mob when both are known; an action in the frame right after the cast's last is counted apart, because an interrupt that worked and a kick that came too late look the same at 1 Hz; "confirmed cut short" is a cast seen for under 80 % of its cast time) and **crowd control** (an observation, not a success rate: some mob shows a new cc flag within 4 s of a single-target action; it is not tied to the caster or the spell, and a new flag on the caster's own target is counted apart), **taunt latency** (seconds from a hostile's top victim becoming a party member that is not the tank to the tank's taunt or the hostile going back to the tank; the frames are the resolution, about 1 s), **cc hold** (a cc episode is a run of consecutive frames with the mob flagged: only an episode that starts within 4 s of a single-target cc action is joined, and only long crowd control is joined (polymorph, sap, shackle undead, freezing trap, banish, hibernate, seduction, blind, fear, turn undead: the short stuns and disorients are left out); a joined episode under 5 s that ends with the mob still in view and unflagged, alive, was broken; the party's targeting of the mark is not measured) and **pet threat** (the share of mob-frames whose top threat holder's guid starts `0xf140`). A verdict is *unknown* where that bot sent no verdict letter (`res`) within 10 s: it is left out of the idle share and not counted as executed, so an older or mixed trace says `unknown`/`not recorded` instead of guessing. `trace-score` prints the tables under the checks; `--json` has them as `spells` | warnings only: `idle_share` over 15 % of engaged mana-user frames with a verdict and no executed (`O`) action for 4 s; `interrupt_cover` under 30 % of enemy casts (from 5) with an interrupt action during or just after them (the favourable reading is held to the limit); `taunt_latency` over 3 s median (from 3 episodes); `cc_hold` under 50 % held (from 3 episodes); `pet_threat` over 5 % of mob-frames (from 30); `--set idle_share=20`, `--set interrupt_cover=20`, `--set taunt_latency=4`, `--set cc_hold=40`, `--set pet_threat=10`. The uncorrelated crowd-control flag observation has no limit | no bot frame has a `cs` or an `act` | Not verified live: the traces were built and tested against the fake game, the fake feed and hand-made traces only. Open: whether a group run's party list stays fresh in a long fight (`stale_party` is there to find out), the feed's facing values, and what the frame diff --git a/internal/tracecheck/spells.go b/internal/tracecheck/spells.go index 24122cf4..3f6d3d35 100644 --- a/internal/tracecheck/spells.go +++ b/internal/tracecheck/spells.go @@ -232,6 +232,22 @@ var categoryList = []catEntry{ {"wait for attack", CatStay, false}, } +// longCC is the single-target crowd control that holds a mob long enough to read at 1 Hz. Only these join a cc_hold episode: the +// short stuns and disorients of the crowd-control category (cheap shot, kidney shot, bash, gouge, death coil, hammer of justice) +// end unflagged within a few seconds, so counting them would read as broken holds. +var longCC = []string{"polymorph", "sap", "shackle undead", "freezing trap", "banish", "hibernate", "seduction", "blind", "fear", "turn undead"} + +// longCCAction says whether an action name is long crowd control (the feed's suffixes are cut first). +func longCCAction(n string) bool { + n = normalizeAction(n) + for _, key := range longCC { + if keyMatch(n, key) { + return true + } + } + return false +} + // classKeys are the names that tell a class, listed by class. var classKeys = map[string][]string{ "warrior": {"sunder armor", "shield bash", "heroic strike", "revenge", "shield slam", "shield block", "thunder clap", "taunt", "challenging shout", @@ -714,7 +730,7 @@ func Spells(f *trace.File, c Config) SpellReport { r.Taunts.Unknown++ } case CatCC: - if single && a.plausible() { + if single && longCCAction(a.name) && a.plausible() { ccs = append(ccs, a) } } diff --git a/internal/tracecheck/spells_more.go b/internal/tracecheck/spells_more.go index 7d9e3ff7..b3d982d3 100644 --- a/internal/tracecheck/spells_more.go +++ b/internal/tracecheck/spells_more.go @@ -29,8 +29,8 @@ type TauntLatency struct { WorstS float64 `json:"worst_s"` } -// CCHold is the crowd-control episodes joined to a single-target cc action and whether they held. The flags are not tied to the -// caster: the feed does not say who a polymorph went to. +// CCHold is the crowd-control episodes joined to a single-target long cc action (polymorph, sap and the like; the short stuns are +// left out) and whether they held. The flags are not tied to the caster: the feed does not say who a polymorph went to. type CCHold struct { Joined int `json:"joined_episodes"` Broken int `json:"broken"` @@ -87,21 +87,25 @@ func tauntLatency(f *trace.File, acts []actEv, ivS float64) TauntLatency { party[b.G] = true } } - var taunts []int64 + var taunts []actEv for _, a := range acts { if cat, _ := CategoryOf(a.name); cat == CatTaunt && a.g == tank && a.plausible() { - taunts = append(taunts, a.t) + taunts = append(taunts, a) } } - sort.Slice(taunts, func(i, j int) bool { return taunts[i] < taunts[j] }) + sort.SliceStable(taunts, func(i, j int) bool { return taunts[i].t < taunts[j].t }) open := map[string]int64{} // guid -> the frame time the episode began var lats []float64 closeEp := func(g string, st, at int64) { delete(open, g) tt := int64(-1) - for _, t := range taunts { - if t >= st && t <= at { - tt = t + for _, ta := range taunts { + // A taunt with a target answers only that mob; without one the feed does not say, so any taunt in the episode counts. + if ta.target != "" && ta.target != g { + continue + } + if ta.t >= st && ta.t <= at { + tt = ta.t break } } @@ -167,8 +171,9 @@ func tauntLatency(f *trace.File, acts []actEv, ivS float64) TauntLatency { } // ccHold follows the cc flag of every mob: an episode is a run of consecutive frames with the mob present and flagged. Only the -// episodes that start within the cc window of a single-target cc action are joined (Joined); a joined episode under ccBrokenS -// that ends with the mob still in view and unflagged was broken, and every other one held. +// episodes that start within the cc window of a single-target long cc action are joined (Joined; ccs holds those, the short stuns +// and disorients are left out by longCC); a joined episode under ccBrokenS that ends with the mob still in view, alive and unflagged +// was broken, and every other one held. func ccHold(f *trace.File, ccs []actEv, ivS float64) CCHold { var r CCHold type episode struct { @@ -205,10 +210,10 @@ func ccHold(f *trace.File, ccs []actEv, ivS float64) CCHold { if m.CC && m.HPPct > 0 { open[m.G] = &episode{start: fr.T, last: fr.T} } + case m.HPPct == 0: + end(m.G, e, false) // dead: the mob was killed, which is not the hold breaking case !m.CC: end(m.G, e, true) - case m.HPPct == 0: - end(m.G, e, false) // dead default: e.last = fr.T } diff --git a/internal/tracecheck/spells_test.go b/internal/tracecheck/spells_test.go index ae0e4c7d..46433f3e 100644 --- a/internal/tracecheck/spells_test.go +++ b/internal/tracecheck/spells_test.go @@ -774,6 +774,117 @@ func TestSpellsCCHoldBrokenEpisodes(t *testing.T) { } } +// A victim that is not a party member (a pet) neither opens nor closes a taunt-latency episode. +func TestSpellsTauntLatencyIgnoresANonPartyVictim(t *testing.T) { + const pet = "0xf140000000000009" + b := spellTrace(40, func(s int) ([]trace.Bot, []trace.Mob) { + m := trace.Mob{G: "0xf1", HPPct: 80, Combat: true, Target: gTank, Threat: []trace.Threat{{G: gTank, Pct: 100}}} + if s >= 10 && s <= 14 { + m.Target, m.Threat = pet, []trace.Threat{{G: pet, Pct: 100}} + } + return []trace.Bot{sb(gTank, "sunder armor", "O")}, []trace.Mob{m} + }) + b.f.Header.Tank = "Tank" + if t1 := Spells(b.f, Defaults()).TauntLatency; t1.Episodes != 0 || t1.ByTaunt != 0 || t1.Returned != 0 { + t.Fatalf("%+v", t1) + } +} + +// The tank dying during an open episode cuts it off as open: the return after it is a new situation, not an answer. +func TestSpellsTauntLatencyTankDeathCutsTheEpisodeOff(t *testing.T) { + b := spellTrace(40, func(s int) ([]trace.Bot, []trace.Mob) { + m := trace.Mob{G: "0xf1", HPPct: 80, Combat: true, Target: gTank, Threat: []trace.Threat{{G: gTank, Pct: 100}}} + if s >= 10 && s < 20 { + m.Target, m.Threat = gHealer, []trace.Threat{{G: gHealer, Pct: 100}} + } + tank := sb(gTank, "sunder armor", "O") + tank.Alive = s < 12 || s >= 20 + return []trace.Bot{tank, sb(gHealer, "flash heal", "O")}, []trace.Mob{m} + }) + b.f.Header.Tank = "Tank" + if t1 := Spells(b.f, Defaults()).TauntLatency; t1.Open != 1 || t1.Episodes != 0 || t1.ByTaunt != 0 { + t.Fatalf("%+v", t1) + } +} + +// A taunt-category action from anyone but the tank answers nothing. +func TestSpellsTauntLatencyCountsOnlyTheTank(t *testing.T) { + b := spellTrace(40, func(s int) ([]trace.Bot, []trace.Mob) { + m := trace.Mob{G: "0xf1", HPPct: 80, Combat: true, Target: gTank, Threat: []trace.Threat{{G: gTank, Pct: 100}}} + healer := sb(gHealer, "flash heal", "O") + if s >= 10 && s <= 13 { + m.Target, m.Threat = gHealer, []trace.Threat{{G: gHealer, Pct: 100}} + } + if s == 13 { + healer = sb(gHealer, "taunt", "O") + } + return []trace.Bot{sb(gTank, "sunder armor", "O"), healer}, []trace.Mob{m} + }) + b.f.Header.Tank = "Tank" + if t1 := Spells(b.f, Defaults()).TauntLatency; t1.Episodes != 1 || t1.ByTaunt != 0 || t1.Returned != 1 { + t.Fatalf("%+v", t1) + } +} + +// A taunt with a target answers only that mob's episode; the other open episode is a return. +func TestSpellsTauntWithATargetAnswersOnlyThatMobsEpisode(t *testing.T) { + b := spellTrace(40, func(s int) ([]trace.Bot, []trace.Mob) { + tank := sb(gTank, "sunder armor", "O") + if s == 13 { + tank = sb(gTank, "taunt", "O") + tank.Target = "0xf1" + } + var mobs []trace.Mob + for _, g := range []string{"0xf1", "0xf2"} { + m := trace.Mob{G: g, HPPct: 80, Combat: true, Target: gTank, Threat: []trace.Threat{{G: gTank, Pct: 100}}} + if s >= 10 && s <= 13 { + m.Target, m.Threat = gHealer, []trace.Threat{{G: gHealer, Pct: 100}} + } + mobs = append(mobs, m) + } + return []trace.Bot{tank, sb(gHealer, "flash heal", "O")}, mobs + }) + b.f.Header.Tank = "Tank" + if t1 := Spells(b.f, Defaults()).TauntLatency; t1.Episodes != 2 || t1.ByTaunt != 1 || t1.Returned != 1 { + t.Fatalf("%+v", t1) + } +} + +// A short stun that ends unflagged is not a broken hold: cc_hold joins only the long crowd control. +func TestSpellsCCHoldIgnoresAShortStun(t *testing.T) { + b := spellTrace(30, func(s int) ([]trace.Bot, []trace.Mob) { + rogue := sb(gMage, "frostbolt", "O") + if s == 9 { + rogue = sb(gMage, "gouge", "O") + rogue.Target = "0xf1" + } + flagged := s >= 10 && s <= 13 // 4 s, then the mob is up again + return []trace.Bot{rogue}, []trace.Mob{{G: "0xf1", HPPct: 80, Combat: true, Target: gTank, CC: flagged}} + }) + if h := Spells(b.f, Defaults()).CCHold; h.Joined != 0 || h.Broken != 0 { + t.Fatalf("%+v", h) + } +} + +// A mob killed while flagged is not a broken hold: its death ended the episode, not the cc. +func TestSpellsCCHoldDoesNotCountAMobKilledWhileFlagged(t *testing.T) { + b := spellTrace(30, func(s int) ([]trace.Bot, []trace.Mob) { + mage := sb(gMage, "frostbolt", "O") + if s == 9 { + mage = sb(gMage, "polymorph", "O") + mage.Target = "0xf1" + } + mob := trace.Mob{G: "0xf1", HPPct: 80, Combat: true, Target: gTank, CC: s >= 10 && s <= 11} + if s >= 12 { + mob.HPPct = 0 // killed while polymorphed: the corpse is no longer flagged + } + return []trace.Bot{mage}, []trace.Mob{mob} + }) + if h := Spells(b.f, Defaults()).CCHold; h.Joined != 1 || h.Broken != 0 { + t.Fatalf("%+v", h) + } +} + // The share of mob-frames whose top threat holder is a pet (guid prefix 0xf140). func TestSpellsPetThreatShare(t *testing.T) { const pet = "0xf140000000000009" From 7ac921d3d1ed580c54d6dd6098370314e5ffcd7f Mon Sep 17 00:00:00 2001 From: pjerra <71784785+pjerra@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:18:52 +0200 Subject: [PATCH 3/3] trace-score: count Entangling Roots in cc_hold --- README.md | 2 +- internal/tracecheck/spells.go | 2 +- internal/tracecheck/spells_test.go | 16 ++++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5ebf0f87..22a7cdef 100644 --- a/README.md +++ b/README.md @@ -1467,7 +1467,7 @@ the others look at what only the agent sees (orders, rest gates, walks, the feed | `gm_shortcuts` | the honest-run flag: the run used any GM shortcut or was set up with `setup` commands, so a player could not have done it | 0 | never | | `stuck` | a member that should be moving (the lead in a walk of the plan, or a member more than 10 yd from the lead in one; alive and out of combat) did not move 1 yd for this long; the detail lists where, for a per-dungeon stuck-spot map | 5 s, any stall | no walk of the plan, or no frames | | `facing` | a bot in combat stands (no movement) within 6 yd of its hostile target for longer than this while its `fa` is over 90 degrees (the server rule, without its casting case: the agent does not see casts) | 2 s | the header lacks the `facing` capability, or no frame has a bot in melee range of its target | -| `spells` | **report only, never fails**: what each class cast and did, as readings of 1 Hz samples, not facts. Cast episodes per class from `cs` (a run of frames with the same spell is `ceil(duration / base cast time)` casts, at most one per frame; a spell with no known cast time counts once per run, so that number is a lower bound), action mix, verdict letters and reasons, idle share, power at death, taunts, and two proximity/observation readings: **interrupts** (each interrupt action is matched to at most one enemy cast, from 1 s before its first frame to its last frame, and the interrupter's target must be the casting mob when both are known; an action in the frame right after the cast's last is counted apart, because an interrupt that worked and a kick that came too late look the same at 1 Hz; "confirmed cut short" is a cast seen for under 80 % of its cast time) and **crowd control** (an observation, not a success rate: some mob shows a new cc flag within 4 s of a single-target action; it is not tied to the caster or the spell, and a new flag on the caster's own target is counted apart), **taunt latency** (seconds from a hostile's top victim becoming a party member that is not the tank to the tank's taunt or the hostile going back to the tank; the frames are the resolution, about 1 s), **cc hold** (a cc episode is a run of consecutive frames with the mob flagged: only an episode that starts within 4 s of a single-target cc action is joined, and only long crowd control is joined (polymorph, sap, shackle undead, freezing trap, banish, hibernate, seduction, blind, fear, turn undead: the short stuns and disorients are left out); a joined episode under 5 s that ends with the mob still in view and unflagged, alive, was broken; the party's targeting of the mark is not measured) and **pet threat** (the share of mob-frames whose top threat holder's guid starts `0xf140`). A verdict is *unknown* where that bot sent no verdict letter (`res`) within 10 s: it is left out of the idle share and not counted as executed, so an older or mixed trace says `unknown`/`not recorded` instead of guessing. `trace-score` prints the tables under the checks; `--json` has them as `spells` | warnings only: `idle_share` over 15 % of engaged mana-user frames with a verdict and no executed (`O`) action for 4 s; `interrupt_cover` under 30 % of enemy casts (from 5) with an interrupt action during or just after them (the favourable reading is held to the limit); `taunt_latency` over 3 s median (from 3 episodes); `cc_hold` under 50 % held (from 3 episodes); `pet_threat` over 5 % of mob-frames (from 30); `--set idle_share=20`, `--set interrupt_cover=20`, `--set taunt_latency=4`, `--set cc_hold=40`, `--set pet_threat=10`. The uncorrelated crowd-control flag observation has no limit | no bot frame has a `cs` or an `act` | +| `spells` | **report only, never fails**: what each class cast and did, as readings of 1 Hz samples, not facts. Cast episodes per class from `cs` (a run of frames with the same spell is `ceil(duration / base cast time)` casts, at most one per frame; a spell with no known cast time counts once per run, so that number is a lower bound), action mix, verdict letters and reasons, idle share, power at death, taunts, and two proximity/observation readings: **interrupts** (each interrupt action is matched to at most one enemy cast, from 1 s before its first frame to its last frame, and the interrupter's target must be the casting mob when both are known; an action in the frame right after the cast's last is counted apart, because an interrupt that worked and a kick that came too late look the same at 1 Hz; "confirmed cut short" is a cast seen for under 80 % of its cast time) and **crowd control** (an observation, not a success rate: some mob shows a new cc flag within 4 s of a single-target action; it is not tied to the caster or the spell, and a new flag on the caster's own target is counted apart), **taunt latency** (seconds from a hostile's top victim becoming a party member that is not the tank to the tank's taunt or the hostile going back to the tank; the frames are the resolution, about 1 s), **cc hold** (a cc episode is a run of consecutive frames with the mob flagged: only an episode that starts within 4 s of a single-target cc action is joined, and only long crowd control is joined (polymorph, sap, shackle undead, freezing trap, banish, hibernate, entangling roots, seduction, blind, fear, turn undead: the short stuns and disorients are left out); a joined episode under 5 s that ends with the mob still in view and unflagged, alive, was broken; the party's targeting of the mark is not measured) and **pet threat** (the share of mob-frames whose top threat holder's guid starts `0xf140`). A verdict is *unknown* where that bot sent no verdict letter (`res`) within 10 s: it is left out of the idle share and not counted as executed, so an older or mixed trace says `unknown`/`not recorded` instead of guessing. `trace-score` prints the tables under the checks; `--json` has them as `spells` | warnings only: `idle_share` over 15 % of engaged mana-user frames with a verdict and no executed (`O`) action for 4 s; `interrupt_cover` under 30 % of enemy casts (from 5) with an interrupt action during or just after them (the favourable reading is held to the limit); `taunt_latency` over 3 s median (from 3 episodes); `cc_hold` under 50 % held (from 3 episodes); `pet_threat` over 5 % of mob-frames (from 30); `--set idle_share=20`, `--set interrupt_cover=20`, `--set taunt_latency=4`, `--set cc_hold=40`, `--set pet_threat=10`. The uncorrelated crowd-control flag observation has no limit | no bot frame has a `cs` or an `act` | Not verified live: the traces were built and tested against the fake game, the fake feed and hand-made traces only. Open: whether a group run's party list stays fresh in a long fight (`stale_party` is there to find out), the feed's facing values, and what the frame diff --git a/internal/tracecheck/spells.go b/internal/tracecheck/spells.go index 3f6d3d35..9c141a85 100644 --- a/internal/tracecheck/spells.go +++ b/internal/tracecheck/spells.go @@ -235,7 +235,7 @@ var categoryList = []catEntry{ // longCC is the single-target crowd control that holds a mob long enough to read at 1 Hz. Only these join a cc_hold episode: the // short stuns and disorients of the crowd-control category (cheap shot, kidney shot, bash, gouge, death coil, hammer of justice) // end unflagged within a few seconds, so counting them would read as broken holds. -var longCC = []string{"polymorph", "sap", "shackle undead", "freezing trap", "banish", "hibernate", "seduction", "blind", "fear", "turn undead"} +var longCC = []string{"polymorph", "sap", "shackle undead", "freezing trap", "banish", "hibernate", "entangling roots", "seduction", "blind", "fear", "turn undead"} // longCCAction says whether an action name is long crowd control (the feed's suffixes are cut first). func longCCAction(n string) bool { diff --git a/internal/tracecheck/spells_test.go b/internal/tracecheck/spells_test.go index 46433f3e..ba2aa11a 100644 --- a/internal/tracecheck/spells_test.go +++ b/internal/tracecheck/spells_test.go @@ -866,6 +866,22 @@ func TestSpellsCCHoldIgnoresAShortStun(t *testing.T) { } } +// A root is long crowd control: an Entangling Roots episode that ends early is a broken hold, not left out like a short stun. +func TestSpellsCCHoldCountsAnEntanglingRootsBreak(t *testing.T) { + b := spellTrace(30, func(s int) ([]trace.Bot, []trace.Mob) { + druid := sb(gMage, "wrath", "O") + if s == 9 { + druid = sb(gMage, "entangling roots", "O") + druid.Target = "0xf1" + } + flagged := s >= 10 && s <= 13 // 4 s, then the mob is up again + return []trace.Bot{druid}, []trace.Mob{{G: "0xf1", HPPct: 80, Combat: true, Target: gTank, CC: flagged}} + }) + if h := Spells(b.f, Defaults()).CCHold; h.Joined != 1 || h.Broken != 1 { + t.Fatalf("%+v", h) + } +} + // A mob killed while flagged is not a broken hold: its death ended the episode, not the cc. func TestSpellsCCHoldDoesNotCountAMobKilledWhileFlagged(t *testing.T) { b := spellTrace(30, func(s int) ([]trace.Bot, []trace.Mob) {