Skip to content

Commit

Permalink
feat: add basic stats collector
Browse files Browse the repository at this point in the history
  • Loading branch information
bradleybonitatibus committed Jan 5, 2024
1 parent 164052b commit a62d84d
Show file tree
Hide file tree
Showing 7 changed files with 263 additions and 17 deletions.
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,36 @@ func main() {
}
// handle []*frostparse.CombatLogRecord how you please
}
```

```
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.
10 changes: 6 additions & 4 deletions parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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{}
Expand Down Expand Up @@ -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]),
Expand Down
9 changes: 2 additions & 7 deletions parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
138 changes: 138 additions & 0 deletions summary.go
Original file line number Diff line number Diff line change
@@ -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
}
}
43 changes: 43 additions & 0 deletions summary_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
10 changes: 5 additions & 5 deletions types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
37 changes: 37 additions & 0 deletions utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

0 comments on commit a62d84d

Please sign in to comment.