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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
Expand Down
2 changes: 1 addition & 1 deletion cmd/turtle-agent/trace_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <run_id>.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
Expand Down
69 changes: 56 additions & 13 deletions internal/tracecheck/spells.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -229,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", "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 {
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",
Expand Down Expand Up @@ -711,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)
}
}
Expand Down Expand Up @@ -872,6 +891,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
}
Expand All @@ -885,6 +908,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).
Expand Down Expand Up @@ -1041,6 +1073,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)
}
Expand Down
Loading
Loading