From a62d84d7efb4bf5a0a830f61e15030659d9cba70 Mon Sep 17 00:00:00 2001 From: Bradley Bonitatibus Date: Thu, 4 Jan 2024 22:58:12 -0500 Subject: [PATCH] feat: add basic stats collector --- README.md | 33 +++++++++++- parser.go | 10 ++-- parser_test.go | 9 +--- summary.go | 138 ++++++++++++++++++++++++++++++++++++++++++++++++ summary_test.go | 43 +++++++++++++++ types.go | 10 ++-- utils.go | 37 +++++++++++++ 7 files changed, 263 insertions(+), 17 deletions(-) create mode 100644 summary.go create mode 100644 summary_test.go diff --git a/README.md b/README.md index 847aee4..11d59c1 100644 --- a/README.md +++ b/README.md @@ -28,5 +28,36 @@ func main() { } // handle []*frostparse.CombatLogRecord how you please } +``` -``` \ No newline at end of file +If you want basic summary statistics from the combat log, you can use the `Collector` struct: +```go +package main + +import ( + "log" + "github.com/bradleybonitatibus/frostparse" +) + +func main() { + p := frostparse.New( + frostparse.WithLogFile("C:\\Program Files (x86)\\World of Warcraft 3.3.5a\\Logs\\WoWCombatLog.txt"), + ) + data, err := p.Parse() + if err != nil { + log.Fatal("failed to parse combatlog: ", err) + } + coll := NewCollector() + coll.Run(data) + fmt.Println("DamageBySource: ", coll.DamageBySource) + fmt.Println("HealingBySource: ", coll.HealingBySource) + fmt.Println("DamageTakenBySource: ", coll.DamageTakenBySource) + fmt.Println("DamageTakenBySpell: ", coll.DamageTakenBySpell) +} +``` + +## Data Model + +The `CombatLogRecord` struct aggregates a `BaseCombatEvent`, a `Prefix` and a `Suffix`. +The `Prefix` struct is an aggregate to various prefixes, `SpellAndRangePrefix`, `EnchantPrefix`, and `EnvironmentalPrefix`. The member fields of the struct +are pointers because some properties are not populated based on the `BaseCombatEvent.EventType` field of the log record. diff --git a/parser.go b/parser.go index 196ac21..c99cd6a 100644 --- a/parser.go +++ b/parser.go @@ -24,6 +24,8 @@ import ( "time" ) +// ParserFunc is a function that accepts a pointer to a Parser struct +// to be used in the options variadic function in the `New` function. type ParserFunc func(*Parser) // Parser is responsible for loading the combat log file and parsing the data @@ -112,9 +114,9 @@ func parseRow(startTime time.Time, data string) CombatLogRecord { Timestamp: t, EventType: eventType, SourceID: eventParts[1], - SourceName: strings.ReplaceAll(eventParts[2], `"`, ""), + SourceName: removeQuoteString(eventParts[2]), TargetID: eventParts[4], - TargetName: strings.ReplaceAll(eventParts[5], `"`, ""), + TargetName: removeQuoteString(eventParts[5]), } prefix := Prefix{} suffix := Suffix{} @@ -230,8 +232,8 @@ func parseRow(startTime time.Time, data string) CombatLogRecord { } } -func parseSpellPrefix(eventParts []string) *SpellPrefix { - return &SpellPrefix{ +func parseSpellPrefix(eventParts []string) *SpellAndRangePrefix { + return &SpellAndRangePrefix{ SpellID: mustParseUint(eventParts[7]), SpellName: removeQuoteString(eventParts[8]), SpellSchool: mustParseSpellSchool(eventParts[9]), diff --git a/parser_test.go b/parser_test.go index 2319462..71b072e 100644 --- a/parser_test.go +++ b/parser_test.go @@ -39,9 +39,7 @@ func TestNewWithOptions(t *testing.T) { } func TestParserParse(t *testing.T) { - p := New( - WithLogFile("./testdata/test.txt"), - ) + p := newTestParser() start := time.Now() d, err := p.Parse() if err != nil { @@ -64,10 +62,7 @@ func TestParserWithEventListener(t *testing.T) { swingCount++ }) - p := New( - WithEventListener(el), - WithLogFile("./testdata/test.txt"), - ) + p := newTestParser() _, err := p.Parse() if err != nil { diff --git a/summary.go b/summary.go new file mode 100644 index 0000000..ff798dd --- /dev/null +++ b/summary.go @@ -0,0 +1,138 @@ +/* +Copyright 2023 Bradley Bonitatibus. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package frostparse + +import "time" + + +type Encounter struct { + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` +} + +// SummaryStats is responsible for listening to the parser.CombatLogRecord stream +// and aggregating the events into well-known raid metrics. +type SummaryStats struct { + DamageDoneOverTime map[time.Time]uint64 `json:"damage_done"` + HealingpDoneOverTime map[time.Time]uint64 `json:"healing_done"` + DamageTakenOverTime map[time.Time]uint64 `json:"damage_taken"` + EncounterOverlays map[string]Encounter `json:"encounter_overlays"` + DamageBySource map[string]uint64 `json:"damage_by_source"` + HealingBySource map[string]uint64 `json:"healing_by_source"` + DamageTakenBySource map[string]uint64 `json:"damage_taken_by_source"` + DamageTakenBySpell map[string]uint64 `json:"damage_taken_by_spell"` + InterruptsBySource map[string]uint64 `json:"interrupts_by_source"` + DispellsBySource map[string]uint64 `json:"dispells_by_source"` +} + +type Collector struct { + TimeResolution time.Duration +} + +type CollectorFunc func(*Collector) + +func WithTimeresolution(res time.Duration) CollectorFunc { + return func(c *Collector) { + c.TimeResolution=res + } +} + +// NewCollector initializes, allocates and returns a pointer to a Collector struct. +func NewCollector(opts ...CollectorFunc) *Collector { + t := &Collector{ + TimeResolution: time.Second * 30, + } + for _, o := range opts { + o(t) + } + return t +} + +// Run consumes the input channel of parser.CombatLogRecord and processes +// each event in the event handler. +func (c *Collector) Run(data []*CombatLogRecord) *SummaryStats { + s := &SummaryStats{ + DamageDoneOverTime: map[time.Time]uint64{}, + HealingpDoneOverTime: map[time.Time]uint64{}, + DamageTakenOverTime: map[time.Time]uint64{}, + DamageBySource: map[string]uint64{}, + HealingBySource: map[string]uint64{}, + DamageTakenBySource: map[string]uint64{}, + DamageTakenBySpell: map[string]uint64{}, + InterruptsBySource: map[string]uint64{}, + DispellsBySource: map[string]uint64{}, + EncounterOverlays: map[string]Encounter{}, + } + for i := range data { + s.handleEvent(*data[i], c.TimeResolution) + } + return s +} + +// handleEvent is responsible for aggregating the event based on event type +// and source-> target directionality. +func (c *SummaryStats) handleEvent(row CombatLogRecord, resolution time.Duration) { + if isDamageEvent(row) { + var amount uint64 = 0 + if row.ExtraAttacksSuffix != nil { + amount = row.ExtraAttacksSuffix.Amount + } else if row.DamageSuffix != nil { + amount = row.DamageSuffix.Amount + } + if isBossName(row.TargetName) { + encounter, ok := c.EncounterOverlays[row.TargetName] + now := row.Timestamp.Truncate(resolution) + if !ok { + encounter = Encounter{ + StartTime: now, + EndTime: now, + } + } else { + encounter.EndTime = now + } + c.EncounterOverlays[row.TargetName] = encounter + } + if (isBossID(row.SourceID) || isNPCID(row.SourceID)) && isPlayerID(row.TargetID) { + // NPC -> player, accumulate damage taken + c.DamageTakenBySource[row.SourceName] += amount + c.DamageTakenOverTime[row.Timestamp.Truncate(resolution)] += amount + if row.SpellAndRangePrefix != nil { + c.DamageTakenBySpell[row.SpellAndRangePrefix.SpellName] += amount + } + return + } + if isPlayerID(row.SourceID) && isNPCID(row.TargetID) || isBossID(row.TargetID) { + // player -> npc, accumulate damage done + c.DamageBySource[row.SourceName] += amount + c.DamageDoneOverTime[row.Timestamp.Truncate(resolution)] += amount + return + } + return + } + if isHealingEvent(row) { + if isPlayerID(row.SourceID) { + c.HealingBySource[row.SourceName] += row.HealSuffix.Amount + c.HealingpDoneOverTime[row.Timestamp.Truncate(resolution)] += row.HealSuffix.Amount + } + return + } + if isOverlayEvent(row) { + c.DispellsBySource[row.SourceName] += 1 + c.InterruptsBySource[row.SourceName] += 1 + return + } +} diff --git a/summary_test.go b/summary_test.go new file mode 100644 index 0000000..96103d2 --- /dev/null +++ b/summary_test.go @@ -0,0 +1,43 @@ +/* +Copyright 2023 Bradley Bonitatibus. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package frostparse + +import ( + "fmt" + "testing" +) + + +func newTestParser() *Parser { + return New( + WithLogFile("./testdata/test.txt"), + ) +} + +func TestCollectorRun(t *testing.T) { + p := newTestParser() + data, err := p.Parse() + if err != nil { + t.Error(err) + } + coll := NewCollector() + stats := coll.Run(data) + fmt.Println("DamageBySource: ", stats.DamageBySource) + fmt.Println("HealingBySource: ", stats.HealingBySource) + fmt.Println("DamageTakenBySource: ", stats.DamageTakenBySource) + fmt.Println("DamageTakenBySpell: ", stats.DamageTakenBySpell) +} diff --git a/types.go b/types.go index 2f538bb..70cc830 100644 --- a/types.go +++ b/types.go @@ -289,9 +289,9 @@ type EnvironmentalPrefix struct { EnvironmentalType EnvironmentalType } -// SpellPrefix is the most common prefix containing spell metadata for SPELL_ +// SpellAndRangePrefix is the most common prefix containing spell metadata for SPELL_ // and RANGE_ prefixed event types. -type SpellPrefix struct { +type SpellAndRangePrefix struct { SpellID uint64 SpellName string SpellSchool SpellSchool @@ -367,9 +367,9 @@ type EnchantPrefix struct { // Prefix aggregates all the prefix types. The sub-prefixes will be `nil` if the // event type does not match the prefix. type Prefix struct { - SpellAndRangePrefix *SpellPrefix - EnchantPrefix *EnchantPrefix - EnvironmentalPrefix *EnvironmentalPrefix + *SpellAndRangePrefix + *EnchantPrefix + *EnvironmentalPrefix } // ExtraAttacksSuffix provides metadata for how much an extra-attack hit for. diff --git a/utils.go b/utils.go index df75fae..e334a71 100644 --- a/utils.go +++ b/utils.go @@ -102,3 +102,40 @@ func parseNilBool(s string) bool { } return b } + +func sliceContains[T comparable](seq []T, v T) bool { + for i := range seq { + if seq[i] == v { + return true + } + } + return false +} + +func isDamageEvent(c CombatLogRecord) bool { + return sliceContains(DamageEvents, c.EventType) +} + +func isHealingEvent(c CombatLogRecord) bool { + return sliceContains(HealEvents, c.EventType) +} + +func isOverlayEvent(c CombatLogRecord) bool { + return sliceContains(OverlayEvents, c.EventType) +} + +func isBossName(s string) bool { + return sliceContains(BossNames, s) +} + +func isBossID(v string) bool { + return strings.HasPrefix(v, "0xF15") +} + +func isNPCID(v string) bool { + return strings.HasPrefix(v, "0xF13") +} + +func isPlayerID(v string) bool { + return strings.HasPrefix(v, "0x07") +}