From cf6029eb9588396a5488b6687c2313232d4f6f41 Mon Sep 17 00:00:00 2001 From: dk Date: Fri, 17 Jul 2020 15:50:45 -0700 Subject: [PATCH] Stat descriptions + tests, Skilldesc.txt loader (#590) * adding ranged number type, for use in stats * Loaded Skills.txt * asset manager only binds terminal commands if terminal != nil * WIP stats * cache getter and clear methods were not implemented * asset manager handles a nil terminal pointer * adding skilldesc.txt loader (needs work) * ctc stat descriptions functions working * moving description functionality out of itemstatcost loader and into stats * stats seem like a central part of diablo, moving into d2core. * stats seem like a central part of diablo, moving into d2core. * delint * adding statlist, statlist reduction, unit tests * minor edits to stat.go * lint error in statlist.go * Remove dependency on actual data from mpq files stats unit tests now use mock data * fixing some lint errors, formatting Co-authored-by: Maxime Lavigne (malavv) --- d2app/app.go | 1 + d2common/d2data/d2datadict/itemstatcost.go | 79 +-- d2common/d2data/d2datadict/monstats.go | 9 +- d2common/d2data/d2datadict/skilldesc.go | 258 ++++++++ d2common/d2resource/resource_paths.go | 1 + d2core/d2map/d2mapentity/npc.go | 2 +- d2core/d2stats/doc.go | 2 + d2core/d2stats/stat.go | 724 +++++++++++++++++++++ d2core/d2stats/stat_test.go | 382 +++++++++++ d2core/d2stats/statlist.go | 82 +++ d2core/d2stats/statlist_test.go | 90 +++ 11 files changed, 1563 insertions(+), 67 deletions(-) create mode 100644 d2common/d2data/d2datadict/skilldesc.go create mode 100644 d2core/d2stats/doc.go create mode 100644 d2core/d2stats/stat.go create mode 100644 d2core/d2stats/stat_test.go create mode 100644 d2core/d2stats/statlist.go create mode 100644 d2core/d2stats/statlist_test.go diff --git a/d2app/app.go b/d2app/app.go index 1a0f1c07..10885212 100644 --- a/d2app/app.go +++ b/d2app/app.go @@ -241,6 +241,7 @@ func (p *App) loadDataDict() error { {d2resource.Inventory, d2datadict.LoadInventory}, {d2resource.Skills, d2datadict.LoadSkills}, {d2resource.Properties, d2datadict.LoadProperties}, + {d2resource.SkillDesc, d2datadict.LoadSkillDescriptions}, } d2datadict.InitObjectRecords() diff --git a/d2common/d2data/d2datadict/itemstatcost.go b/d2common/d2data/d2datadict/itemstatcost.go index 29d05e33..69833f5a 100644 --- a/d2common/d2data/d2datadict/itemstatcost.go +++ b/d2common/d2data/d2datadict/itemstatcost.go @@ -1,11 +1,9 @@ package d2datadict import ( - "fmt" - "log" - "github.com/OpenDiablo2/OpenDiablo2/d2common" "github.com/OpenDiablo2/OpenDiablo2/d2common/d2enum" + "log" ) // ItemStatCostRecord represents a row from itemstatcost.txt @@ -101,61 +99,6 @@ type ItemStatCostRecord struct { DamageRelated bool // prevents stacking of stats while dual wielding } -//nolint:gochecknoglobals // better for lookup -var itemStatCostRecordLookup = []string{ - "+%f %s", - "%f%% %s", - "%f %s", - "+%f%% %s", - "%f%% %s", - "+%f %s %s", - "%f%% %s %s", - "+%f%% %s %s", - "%f %s %s", - "%f %s %s", - "Repairs 1 Durability In %.0f Seconds", - "+%f %s", - "+%.0f to %s Skill Levels", - "+%.0f to %s Skill Levels (%s Only)", - "%.0f%% chance to cast %d %s on %s", - "Level %d %s Aura When Equipped", - "%f %s (Increases near %d)", - "%f%% %s (Increases near %d)", - "", - "%f%% %s", - "%f %s", - "%f%% %s %s", - "", - "%f%% %s %s", - "Level %.0f %s (%d/%d Charges)", - "", - "", - "+%f to %s (%s Only)", - "+%.0f to %s", -} - -// DescString return a string based on the DescFnID -func (r *ItemStatCostRecord) DescString(a ...interface{}) string { - if r.DescFnID < 0 || r.DescFnID > len(itemStatCostRecordLookup) { - return "" - } - - format := itemStatCostRecordLookup[r.DescFnID] - - return fmt.Sprintf(format, a...) -} - -// DescGroupString return a string based on the DescGroupFuncID -func (r *ItemStatCostRecord) DescGroupString(a ...interface{}) string { - if r.DescGroupFuncID < 0 || r.DescGroupFuncID > len(itemStatCostRecordLookup) { - return "" - } - - format := itemStatCostRecordLookup[r.DescGroupFuncID] - - return fmt.Sprintf(format, a...) -} - // ItemStatCosts stores all of the ItemStatCostRecords //nolint:gochecknoglobals // Currently global by design var ItemStatCosts map[string]*ItemStatCostRecord @@ -217,10 +160,10 @@ func LoadItemStatCosts(file []byte) { DescPriority: d.Number("descpriority"), DescFnID: d.Number("descfunc"), - DescVal: d.Number("descval"), - DescStrPos: d.String("descstrpos"), - DescStrNeg: d.String("descstrneg"), - DescStr2: d.String("descstr2"), + // DescVal: d.Number("descval"), // needs special handling + DescStrPos: d.String("descstrpos"), + DescStrNeg: d.String("descstrneg"), + DescStr2: d.String("descstr2"), DescGroup: d.Number("dgrp"), DescGroupFuncID: d.Number("dgrpfunc"), @@ -232,6 +175,18 @@ func LoadItemStatCosts(file []byte) { Stuff: d.String("stuff"), } + + descValStr := d.String("descval") + switch descValStr { + case "2": + record.DescVal = 2 + case "0": + record.DescVal = 0 + default: + // handle empty fields, seems like they should have been 1 + record.DescVal = 1 + } + ItemStatCosts[record.Name] = record } diff --git a/d2common/d2data/d2datadict/monstats.go b/d2common/d2data/d2datadict/monstats.go index 1f53cb4c..f5cfae9a 100644 --- a/d2common/d2data/d2datadict/monstats.go +++ b/d2common/d2data/d2datadict/monstats.go @@ -28,7 +28,7 @@ type ( // column also links other hardcoded effects to the units, such as the // transparency on necro summons and the name-color change on unique boss // units (thanks to Kingpin for the info) - Id string //nolint:golint,stylecheck // called `hcIdx` in monstats.txt + Id int //nolint:golint,stylecheck // called `hcIdx` in monstats.txt // BaseKey is an ID pointer of the “base” unit for this specific // monster type (ex. There are five types of “Fallen”; all of them have @@ -43,7 +43,7 @@ type ( // NameStringTableKey the string-key used in the TBL (string.tbl, // expansionstring.tbl and patchstring.tbl) files to make this monsters // name appear when you highlight it. - NameStringTableKey string // called `NameStr` in monstats.txt + NameString string // called `NameStr` in monstats.txt // ExtraDataKey the ID pointer to an entry in MonStats2.txt. ExtraDataKey string // called `MonStatsEx` in monstats.txt @@ -691,11 +691,11 @@ func LoadMonStats(file []byte) { // nolint:funlen // Makes no sense to split for d.Next() { record := &MonStatsRecord{ Key: d.String("Id"), - Id: d.String("hcIdx"), + Id: d.Number("hcIdx"), BaseKey: d.String("BaseId"), NextKey: d.String("NextInClass"), PaletteId: d.Number("TransLvl"), - NameStringTableKey: d.String("NameStr"), + NameString: d.String("NameStr"), ExtraDataKey: d.String("MonStatsEx"), PropertiesKey: d.String("MonProp"), MonsterGroup: d.String("MonType"), @@ -943,6 +943,7 @@ func LoadMonStats(file []byte) { // nolint:funlen // Makes no sense to split SpecialEndGeneric: d.Number("SplEndGeneric") > 0, SpecialClientEnd: d.Number("SplClientEnd") > 0, } + MonStats[record.Key] = record } diff --git a/d2common/d2data/d2datadict/skilldesc.go b/d2common/d2data/d2datadict/skilldesc.go new file mode 100644 index 00000000..27dd6333 --- /dev/null +++ b/d2common/d2data/d2datadict/skilldesc.go @@ -0,0 +1,258 @@ +package d2datadict + +import ( + "github.com/OpenDiablo2/OpenDiablo2/d2common" + "log" +) + +type SkillDescriptionRecord struct { + Name string // skilldesc + SkillPage string // SkillPage + SkillRow string // SkillRow + SkillColumn string // SkillColumn + ListRow string // ListRow + ListPool string // ListPool + IconCel string // IconCel + NameKey string // str name + ShortKey string // str short + LongKey string // str long + AltKey string // str alt + ManaKey string // str mana + Descdam string // descdam + DdamCalc1 string // ddam calc1 + DdamCalc2 string // ddam calc2 + P1dmelem string // p1dmelem + P1dmmin string // p1dmmin + P1dmmax string // p1dmmax + P2dmelem string // p2dmelem + P2dmmin string // p2dmmin + P2dmmax string // p2dmmax + P3dmelem string // p3dmelem + P3dmmin string // p3dmmin + P3dmmax string // p3dmmax + Descatt string // descatt + Descmissile1 string // descmissile1 + Descmissile2 string // descmissile2 + Descmissile3 string // descmissile3 + Descline1 string // descline1 + Desctexta1 string // desctexta1 + Desctextb1 string // desctextb1 + Desccalca1 string // desccalca1 + Desccalcb1 string // desccalcb1 + Descline2 string // descline2 + Desctexta2 string // desctexta2 + Desctextb2 string // desctextb2 + Desccalca2 string // desccalca2 + Desccalcb2 string // desccalcb2 + Descline3 string // descline3 + Desctexta3 string // desctexta3 + Desctextb3 string // desctextb3 + Desccalca3 string // desccalca3 + Desccalcb3 string // desccalcb3 + Descline4 string // descline4 + Desctexta4 string // desctexta4 + Desctextb4 string // desctextb4 + Desccalca4 string // desccalca4 + Desccalcb4 string // desccalcb4 + Descline5 string // descline5 + Desctexta5 string // desctexta5 + Desctextb5 string // desctextb5 + Desccalca5 string // desccalca5 + Desccalcb5 string // desccalcb5 + Descline6 string // descline6 + Desctexta6 string // desctexta6 + Desctextb6 string // desctextb6 + Desccalca6 string // desccalca6 + Desccalcb6 string // desccalcb6 + Dsc2line1 string // dsc2line1 + Dsc2texta1 string // dsc2texta1 + Dsc2textb1 string // dsc2textb1 + Dsc2calca1 string // dsc2calca1 + Dsc2calcb1 string // dsc2calcb1 + Dsc2line2 string // dsc2line2 + Dsc2texta2 string // dsc2texta2 + Dsc2textb2 string // dsc2textb2 + Dsc2calca2 string // dsc2calca2 + Dsc2calcb2 string // dsc2calcb2 + Dsc2line3 string // dsc2line3 + Dsc2texta3 string // dsc2texta3 + Dsc2textb3 string // dsc2textb3 + Dsc2calca3 string // dsc2calca3 + Dsc2calcb3 string // dsc2calcb3 + Dsc2line4 string // dsc2line4 + Dsc2texta4 string // dsc2texta4 + Dsc2textb4 string // dsc2textb4 + Dsc2calca4 string // dsc2calca4 + Dsc2calcb4 string // dsc2calcb4 + Dsc3line1 string // dsc3line1 + Dsc3texta1 string // dsc3texta1 + Dsc3textb1 string // dsc3textb1 + Dsc3calca1 string // dsc3calca1 + Dsc3calcb1 string // dsc3calcb1 + Dsc3line2 string // dsc3line2 + Dsc3texta2 string // dsc3texta2 + Dsc3textb2 string // dsc3textb2 + Dsc3calca2 string // dsc3calca2 + Dsc3calcb2 string // dsc3calcb2 + Dsc3line3 string // dsc3line3 + Dsc3texta3 string // dsc3texta3 + Dsc3textb3 string // dsc3textb3 + Dsc3calca3 string // dsc3calca3 + Dsc3calcb3 string // dsc3calcb3 + Dsc3line4 string // dsc3line4 + Dsc3texta4 string // dsc3texta4 + Dsc3textb4 string // dsc3textb4 + Dsc3calca4 string // dsc3calca4 + Dsc3calcb4 string // dsc3calcb4 + Dsc3line5 string // dsc3line5 + Dsc3texta5 string // dsc3texta5 + Dsc3textb5 string // dsc3textb5 + Dsc3calca5 string // dsc3calca5 + Dsc3calcb5 string // dsc3calcb5 + Dsc3line6 string // dsc3line6 + Dsc3texta6 string // dsc3texta6 + Dsc3textb6 string // dsc3textb6 + Dsc3calca6 string // dsc3calca6 + Dsc3calcb6 string // dsc3calcb6 + Dsc3line7 string // dsc3line7 + Dsc3texta7 string // dsc3texta7 + Dsc3textb7 string // dsc3textb7 + Dsc3calca7 string // dsc3calca7 + Dsc3calcb7 string // dsc3calcb7 +} + +// ItemStatCosts stores all of the ItemStatCostRecords +//nolint:gochecknoglobals // Currently global by design +var SkillDescriptions map[string]*SkillDescriptionRecord + +// LoadItemStatCosts loads ItemStatCostRecord's from text +func LoadSkillDescriptions(file []byte) { + SkillDescriptions = make(map[string]*SkillDescriptionRecord) + + d := d2common.LoadDataDictionary(file) + for d.Next() { + record := &SkillDescriptionRecord{ + d.String("skilldesc"), + d.String("SkillPage"), + d.String("SkillRow"), + d.String("SkillColumn"), + d.String("ListRow"), + d.String("ListPool"), + d.String("IconCel"), + d.String("str name"), + d.String("str short"), + d.String("str long"), + d.String("str alt"), + d.String("str mana"), + d.String("descdam"), + d.String("ddam calc1"), + d.String("ddam calc2"), + d.String("p1dmelem"), + d.String("p1dmmin"), + d.String("p1dmmax"), + d.String("p2dmelem"), + d.String("p2dmmin"), + d.String("p2dmmax"), + d.String("p3dmelem"), + d.String("p3dmmin"), + d.String("p3dmmax"), + d.String("descatt"), + d.String("descmissile1"), + d.String("descmissile2"), + d.String("descmissile3"), + d.String("descline1"), + d.String("desctexta1"), + d.String("desctextb1"), + d.String("desccalca1"), + d.String("desccalcb1"), + d.String("descline2"), + d.String("desctexta2"), + d.String("desctextb2"), + d.String("desccalca2"), + d.String("desccalcb2"), + d.String("descline3"), + d.String("desctexta3"), + d.String("desctextb3"), + d.String("desccalca3"), + d.String("desccalcb3"), + d.String("descline4"), + d.String("desctexta4"), + d.String("desctextb4"), + d.String("desccalca4"), + d.String("desccalcb4"), + d.String("descline5"), + d.String("desctexta5"), + d.String("desctextb5"), + d.String("desccalca5"), + d.String("desccalcb5"), + d.String("descline6"), + d.String("desctexta6"), + d.String("desctextb6"), + d.String("desccalca6"), + d.String("desccalcb6"), + d.String("dsc2line1"), + d.String("dsc2texta1"), + d.String("dsc2textb1"), + d.String("dsc2calca1"), + d.String("dsc2calcb1"), + d.String("dsc2line2"), + d.String("dsc2texta2"), + d.String("dsc2textb2"), + d.String("dsc2calca2"), + d.String("dsc2calcb2"), + d.String("dsc2line3"), + d.String("dsc2texta3"), + d.String("dsc2textb3"), + d.String("dsc2calca3"), + d.String("dsc2calcb3"), + d.String("dsc2line4"), + d.String("dsc2texta4"), + d.String("dsc2textb4"), + d.String("dsc2calca4"), + d.String("dsc2calcb4"), + d.String("dsc3line1"), + d.String("dsc3texta1"), + d.String("dsc3textb1"), + d.String("dsc3calca1"), + d.String("dsc3calcb1"), + d.String("dsc3line2"), + d.String("dsc3texta2"), + d.String("dsc3textb2"), + d.String("dsc3calca2"), + d.String("dsc3calcb2"), + d.String("dsc3line3"), + d.String("dsc3texta3"), + d.String("dsc3textb3"), + d.String("dsc3calca3"), + d.String("dsc3calcb3"), + d.String("dsc3line4"), + d.String("dsc3texta4"), + d.String("dsc3textb4"), + d.String("dsc3calca4"), + d.String("dsc3calcb4"), + d.String("dsc3line5"), + d.String("dsc3texta5"), + d.String("dsc3textb5"), + d.String("dsc3calca5"), + d.String("dsc3calcb5"), + d.String("dsc3line6"), + d.String("dsc3texta6"), + d.String("dsc3textb6"), + d.String("dsc3calca6"), + d.String("dsc3calcb6"), + d.String("dsc3line7"), + d.String("dsc3texta7"), + d.String("dsc3textb7"), + d.String("dsc3calca7"), + d.String("dsc3calcb7"), + } + + SkillDescriptions[record.Name] = record + } + + if d.Err != nil { + panic(d.Err) + } + + log.Printf("Loaded %d Skill Description records", len(SkillDescriptions)) +} diff --git a/d2common/d2resource/resource_paths.go b/d2common/d2resource/resource_paths.go index 599a7a46..e14ebde8 100644 --- a/d2common/d2resource/resource_paths.go +++ b/d2common/d2resource/resource_paths.go @@ -188,6 +188,7 @@ const ( AutoMap = "/data/global/excel/AutoMap.txt" CubeRecipes = "/data/global/excel/cubemain.txt" Skills = "/data/global/excel/skills.txt" + SkillDesc = "/data/global/excel/skilldesc.txt" // --- Animations --- diff --git a/d2core/d2map/d2mapentity/npc.go b/d2core/d2map/d2mapentity/npc.go index 52a47872..32ef6e02 100644 --- a/d2core/d2map/d2mapentity/npc.go +++ b/d2core/d2map/d2mapentity/npc.go @@ -56,7 +56,7 @@ func CreateNPC(x, y int, monstat *d2datadict.MonStatsRecord, direction int) *NPC result.composite.SetDirection(direction) if result.monstatRecord != nil && result.monstatRecord.IsInteractable { - result.name = d2common.TranslateString(result.monstatRecord.NameStringTableKey) + result.name = d2common.TranslateString(result.monstatRecord.NameString) } return result diff --git a/d2core/d2stats/doc.go b/d2core/d2stats/doc.go new file mode 100644 index 00000000..892a63a8 --- /dev/null +++ b/d2core/d2stats/doc.go @@ -0,0 +1,2 @@ +// Package d2stats provides item/skill/character stats functionality +package d2stats diff --git a/d2core/d2stats/stat.go b/d2core/d2stats/stat.go new file mode 100644 index 00000000..905597b9 --- /dev/null +++ b/d2core/d2stats/stat.go @@ -0,0 +1,724 @@ +package d2stats + +import ( + "fmt" + "regexp" + "strings" + + "github.com/OpenDiablo2/OpenDiablo2/d2common" + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2data/d2datadict" + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2enum" +) + +type descValPosition int + +const ( + descValHide descValPosition = iota + descValPrefix + descValPostfix +) + +// CreateStat creates a stat instance with the given ID and number of values +func CreateStat(record *d2datadict.ItemStatCostRecord, values ...int) *Stat { + if record == nil { + return nil + } + + stat := &Stat{ + Record: record, + Values: values, + } + + return stat +} + +// Stat is an instance of a Stat, with a set of Values +type Stat struct { + Record *d2datadict.ItemStatCostRecord + Values []int +} + +// Clone returns a deep copy of the Stat +func (s Stat) Clone() *Stat { + clone := &Stat{ + Record: s.Record, + Values: make([]int, len(s.Values)), + } + + for idx := range s.Values { + clone.Values[idx] = s.Values[idx] + } + + return clone +} + +// Combine sums the other stat with this one, altering the +// values of this one. +func (s *Stat) combine(other *Stat) (success bool) { + if !s.canBeCombinedWith(other) { + return false + } + + for idx := range s.Values { + // todo different combination logic per descfnid + s.Values[idx] += other.Values[idx] + } + + return true +} + +func (s *Stat) canBeCombinedWith(other *Stat) bool { + if s.Record != other.Record { + return false + } + + if len(s.Values) != len(other.Values) { + return false + } + // todo `10% reanimate as: foo` is not the same as `10% reanimate as: bar` + + return true +} + +// Description returns the formatted description string +func (s *Stat) Description() string { + return s.DescString(s.Values...) +} + +// StatDescriptionFormatStrings is an array of the base format strings used +// by the `descfn` methods for stats. The records in itemstatcost.txt have a +// number field which denotes which of these functions is used for formatting +// the stat description. +// These came from phrozen keep: +// https://d2mods.info/forum/kb/viewarticle?a=448 +//nolint:gochecknoglobals // better for lookup +var baseFormatStrings = []string{ + "", + "%v %s", + "%v%% %s", + "%v %s", + "%v%% %s", + "%v%% %s", + "%v %s %s", + "%v%% %v %s", + "%v%% %s %s", + "%v %s %s", + "%v %s %s", + "Repairs 1 Durability In %v Seconds", + "%v +%v", + "+%v %s", + "+%v to %s %s", + "%v%% %s", + "%v %s", + "%v %s (Increases near %v)", + "%v%% %s (Increases near %v)", + "", + "%v%% %s", + "%v %s", + "%v%% %s %s", + "%v%% %s %s", + "Level %v %s %s", + "", + "", + "+%v to %s %s", + "+%v to %s", +} + +var statValueCountLookup map[int]int //nolint:gochecknoglobals // lookup + +// DescString return a string based on the DescFnID +func (s *Stat) DescString(values ...int) string {// nolint:gocyclo switch statement is not so bad + if s.Record.DescFnID < 0 || s.Record.DescFnID > len(baseFormatStrings) { + return "" + } + + var result string + + //nolint:gomdn introducing a const for these would be worse + switch s.Record.DescFnID { + case 1: + result = s.descFn1(values...) + case 2: + result = s.descFn2(values...) + case 3: + result = s.descFn3(values...) + case 4: + result = s.descFn4(values...) + case 5: + result = s.descFn5(values...) + case 6: + result = s.descFn6(values...) + case 7: + result = s.descFn7(values...) + case 8: + result = s.descFn8(values...) + case 9: + result = s.descFn9(values...) + case 11: + result = s.descFn11(values...) + case 12: + result = s.descFn12(values...) + case 13: + result = s.descFn13(values...) + case 14: + result = s.descFn14(values...) + case 15: + result = s.descFn15(values...) + case 16: + result = s.descFn16(values...) + case 20: + result = s.descFn20(values...) + case 22: + result = s.descFn22(values...) + case 23: + result = s.descFn23(values...) + case 24: + result = s.descFn24(values...) + case 27: + result = s.descFn27(values...) + case 28: + result = s.descFn28(values...) + } + + return result +} + +func (s *Stat) descFn1(values ...int) string { + format := baseFormatStrings[s.Record.DescFnID] + + // we know there is only one value for this stat + value := values[0] + + var stringTableKey string + if value < 0 { + stringTableKey = s.Record.DescStrNeg + } else { + format = strings.Join([]string{"+", format}, "") + stringTableKey = s.Record.DescStrPos + } + + stringTableString := d2common.TranslateString(stringTableKey) + + var result string + + switch descValPosition(s.Record.DescVal) { + case descValHide: + result = fmt.Sprintf(format, stringTableString) + case descValPrefix: + result = fmt.Sprintf(format, value, stringTableString) + case descValPostfix: + formatSplit := strings.Split(format, " ") + format = strings.Join(reverseStringSlice(formatSplit), " ") + result = fmt.Sprintf(format, stringTableString, value) + default: + result = "" + } + + result = strings.ReplaceAll(result, "+-", "-") + result = strings.ReplaceAll(result, " +%d", "") + + return result +} + +func (s *Stat) descFn2(values ...int) string { + format := baseFormatStrings[s.Record.DescFnID] + + // we know there is only one value for this stat + value := values[0] + + var stringTableKey string + if value < 0 { + stringTableKey = s.Record.DescStrNeg + } else { + format = strings.Join([]string{"+", format}, "") + stringTableKey = s.Record.DescStrPos + } + + stringTableString := d2common.TranslateString(stringTableKey) + + var result string + + switch descValPosition(s.Record.DescVal) { + case descValHide: + result = fmt.Sprintf(format, stringTableString) + case descValPrefix: + result = fmt.Sprintf(format, value, stringTableString) + case descValPostfix: + formatSplit := strings.Split(format, " ") + format = strings.Join(reverseStringSlice(formatSplit), " ") + result = fmt.Sprintf(format, stringTableString, value) + default: + result = "" + } + + return fixString(result) +} + +func (s *Stat) descFn3(values ...int) string { + format := baseFormatStrings[s.Record.DescFnID] + + // we know there is only one value for this stat + value := values[0] + + var stringTableKey string + if value < 0 { + stringTableKey = s.Record.DescStrNeg + } else { + stringTableKey = s.Record.DescStrPos + } + + stringTableString := d2common.TranslateString(stringTableKey) + + var result string + + switch descValPosition(s.Record.DescVal) { + case descValHide: + format = strings.Split(format, " ")[0] + result = fmt.Sprintf(format, stringTableString) + case descValPrefix: + result = fmt.Sprintf(format, value, stringTableString) + case descValPostfix: + formatSplit := strings.Split(format, " ") + format = strings.Join(reverseStringSlice(formatSplit), " ") + result = fmt.Sprintf(format, stringTableString, value) + default: + result = "" + } + + return fixString(result) +} + +func (s *Stat) descFn4(values ...int) string { + // for now, same as fn2 + return s.descFn2(values...) +} + +func (s *Stat) descFn5(values ...int) string { + format := baseFormatStrings[s.Record.DescFnID] + + // we know there is only one value for this stat + value := values[0] + + var stringTableKey string + if value < 0 { + stringTableKey = s.Record.DescStrNeg + } else { + stringTableKey = s.Record.DescStrPos + } + + stringTableString := d2common.TranslateString(stringTableKey) + + var result string + + switch descValPosition(s.Record.DescVal) { + case descValHide: + result = fmt.Sprintf(format, stringTableString) + case descValPrefix: + result = fmt.Sprintf(format, value, stringTableString) + case descValPostfix: + formatSplit := strings.Split(format, " ") + format = strings.Join(reverseStringSlice(formatSplit), " ") + result = fmt.Sprintf(format, stringTableString, value) + default: + result = "" + } + + return fixString(result) +} + +func (s *Stat) descFn6(values ...int) string { + format := baseFormatStrings[s.Record.DescFnID] + + // we know there is only one value for this stat + value := values[0] + + var stringTableKey1 string + if value < 0 { + stringTableKey1 = s.Record.DescStrNeg + } else { + format = strings.Join([]string{"+", format}, "") + stringTableKey1 = s.Record.DescStrPos + } + + stringTableStr1 := d2common.TranslateString(stringTableKey1) + + // this stat has an additional string (Based on Character Level) + stringTableStr2 := d2common.TranslateString(s.Record.DescStr2) + + var result string + + switch descValPosition(s.Record.DescVal) { + case descValHide: + result = fmt.Sprintf(format, stringTableStr1, stringTableStr2) + case descValPrefix: + result = fmt.Sprintf(format, value, stringTableStr1, stringTableStr2) + case descValPostfix: + formatSplit := strings.Split(format, " ") + format = strings.Join(reverseStringSlice(formatSplit), " ") + result = fmt.Sprintf(format, stringTableStr1, value) + default: + result = "" + } + + return fixString(result) +} + +func (s *Stat) descFn7(values ...int) string { + format := baseFormatStrings[s.Record.DescFnID] + + // we know there is only one value for this stat + value := values[0] + + var stringTableKey string + if value < 0 { + stringTableKey = s.Record.DescStrNeg + } else { + format = strings.Join([]string{"+", format}, "") + stringTableKey = s.Record.DescStrPos + } + + stringTableStr1 := d2common.TranslateString(stringTableKey) + + // this stat has an additional string (Based on Character Level) + stringTableStr2 := d2common.TranslateString(s.Record.DescStr2) + + var result string + + switch descValPosition(s.Record.DescVal) { + case descValHide: + result = fmt.Sprintf(format, stringTableStr1, stringTableStr2) + case descValPrefix: + result = fmt.Sprintf(format, value, stringTableStr1, stringTableStr2) + case descValPostfix: + formatSplit := strings.Split(format, " ") + formatSplit[0], formatSplit[1] = formatSplit[1], formatSplit[0] + format = strings.Join(formatSplit, " ") + result = fmt.Sprintf(format, stringTableStr1, value, stringTableStr2) + default: + result = "" + } + + return result +} + +func (s *Stat) descFn8(values ...int) string { + // for now, same as fn7 + return s.descFn7(values...) +} + +func (s *Stat) descFn9(values ...int) string { + format := baseFormatStrings[s.Record.DescFnID] + + // we know there is only one value for this stat + value := values[0] + + var stringTableKey1 string + if value < 0 { + stringTableKey1 = s.Record.DescStrNeg + } else { + stringTableKey1 = s.Record.DescStrPos + } + + stringTableStr1 := d2common.TranslateString(stringTableKey1) + + // this stat has an additional string (Based on Character Level) + stringTableStr2 := d2common.TranslateString(s.Record.DescStr2) + + var result string + + switch descValPosition(s.Record.DescVal) { + case descValHide: + result = fmt.Sprintf(format, stringTableStr1, stringTableStr2) + case descValPrefix: + result = fmt.Sprintf(format, value, stringTableStr1, stringTableStr2) + case descValPostfix: + formatSplit := strings.Split(format, " ") + formatSplit[0], formatSplit[1] = formatSplit[1], formatSplit[0] + format = strings.Join(formatSplit, " ") + result = fmt.Sprintf(format, stringTableStr1, value, stringTableStr2) + default: + result = "" + } + + return result +} + +func (s *Stat) descFn11(values ...int) string { + // we know there is only one value for this stat + value := values[0] + + // the only stat to use this fn is "Repairs durability in X seconds" + format := d2common.TranslateString(s.Record.DescStrPos) + + return fmt.Sprintf(format, value) +} + +func (s *Stat) descFn12(values ...int) string { + format := baseFormatStrings[s.Record.DescFnID] + + // we know there is only one value for this stat + value := values[0] + + str1 := d2common.TranslateString(s.Record.DescStrPos) + + return fmt.Sprintf(format, str1, value) +} + +func (s *Stat) descFn13(values ...int) string { + format := baseFormatStrings[s.Record.DescFnID] + numSkills, heroIndex := values[0], values[1] + + heroMap := map[int]d2enum.Hero{ + int(d2enum.HeroAmazon): d2enum.HeroAmazon, + int(d2enum.HeroSorceress): d2enum.HeroSorceress, + int(d2enum.HeroNecromancer): d2enum.HeroNecromancer, + int(d2enum.HeroPaladin): d2enum.HeroPaladin, + int(d2enum.HeroBarbarian): d2enum.HeroBarbarian, + int(d2enum.HeroDruid): d2enum.HeroDruid, + int(d2enum.HeroAssassin): d2enum.HeroAssassin, + } + + classRecord := d2datadict.CharStats[heroMap[heroIndex]] + descStr1 := d2common.TranslateString(classRecord.SkillStrAll) + result := fmt.Sprintf(format, numSkills, descStr1) + + result = strings.ReplaceAll(result, "+-", "-") + + return result +} + +func (s *Stat) descFn14(values ...int) string { + numSkills, heroIndex, skillTabIndex := values[0], values[1], values[2] + + if skillTabIndex > 2 || skillTabIndex < 0 { + skillTabIndex = 0 + } + + heroMap := map[int]d2enum.Hero{ + int(d2enum.HeroAmazon): d2enum.HeroAmazon, + int(d2enum.HeroSorceress): d2enum.HeroSorceress, + int(d2enum.HeroNecromancer): d2enum.HeroNecromancer, + int(d2enum.HeroPaladin): d2enum.HeroPaladin, + int(d2enum.HeroBarbarian): d2enum.HeroBarbarian, + int(d2enum.HeroDruid): d2enum.HeroDruid, + int(d2enum.HeroAssassin): d2enum.HeroAssassin, + } + + classRecord := d2datadict.CharStats[heroMap[heroIndex]] + skillTabKey := classRecord.SkillStrTab[skillTabIndex] + classOnlyKey := classRecord.SkillStrClassOnly + + skillTabStr := d2common.TranslateString(skillTabKey) + " %v" + skillTabStr = strings.ReplaceAll(skillTabStr, "%d", "%v") + classOnlyStr := d2common.TranslateString(classOnlyKey) + result := fmt.Sprintf(skillTabStr, numSkills, classOnlyStr) + + return fixString(result) +} + +func within(n, min, max int) int { + if n < min { + n = min + } else if n > max { + n = max + } + + return n +} + +func (s *Stat) descFn15(values ...int) string { + format := d2common.TranslateString(s.Record.DescStrPos) + chanceToCast, skillLevel, skillIndex := values[0], values[1], values[2] + + chanceToCast = within(chanceToCast, 0, 100) + skillLevel = within(skillLevel, 0, 255) + skillLevel = within(skillLevel, 0, 255) + + skillRecord := d2datadict.SkillDetails[skillIndex] + result := fmt.Sprintf(format, chanceToCast, skillLevel, skillRecord.Skill) + result = strings.ReplaceAll(result, "+-", "-") + + return result +} + +func (s *Stat) descFn16(values ...int) string { + skillLevel, skillIndex := values[0], values[1] + str1 := d2common.TranslateString(s.Record.DescStrPos) + skillRecord := d2datadict.SkillDetails[skillIndex] + result := fmt.Sprintf(str1, skillLevel, skillRecord.Skill) + + return fixString(result) +} + +/* +func (s *Stat) descFn17(values ...int) string { + // these were not implemented in original D2 + // leaving them out for now as I don't know how to + // write a test for them, nor do I think vanilla content uses them + // but these are the stat keys which point to this func... + // item_armor_bytime + // item_hp_bytime + // item_mana_bytime + // item_maxdamage_bytime + // item_strength_bytime + // item_dexterity_bytime + // item_energy_bytime + // item_vitality_bytime + // item_tohit_bytime + // item_cold_damagemax_bytime + // item_fire_damagemax_bytime + // item_ltng_damagemax_bytime + // item_pois_damagemax_bytime + // item_stamina_bytime + // item_tohit_demon_bytime + // item_tohit_undead_bytime + // item_kick_damage_bytime +} + +func (s *Stat) descFn18(values ...int) string { + // ... same with these ... + // item_armorpercent_bytime + // item_maxdamage_percent_bytime + // item_tohitpercent_bytime + // item_resist_cold_bytime + // item_resist_fire_bytime + // item_resist_ltng_bytime + // item_resist_pois_bytime + // item_absorb_cold_bytime + // item_absorb_fire_bytime + // item_absorb_ltng_bytime + // item_find_gold_bytime + // item_find_magic_bytime + // item_regenstamina_bytime + // item_damage_demon_bytime + // item_damage_undead_bytime + // item_crushingblow_bytime + // item_openwounds_bytime + // item_deadlystrike_bytime +} +*/ + +func (s *Stat) descFn20(values ...int) string { + // for now, same as fn2 + return s.descFn2(values...) +} + +func (s *Stat) descFn22(values ...int) string { + format := baseFormatStrings[s.Record.DescFnID] + statAgainst, monsterIndex := values[0], values[1] + + var monsterKey string + + for key := range d2datadict.MonStats { + if d2datadict.MonStats[key].Id == monsterIndex { + monsterKey = key + break + } + } + + str1 := d2common.TranslateString(s.Record.DescStrPos) + monsterName := d2datadict.MonStats[monsterKey].NameString + + result := fmt.Sprintf(format, statAgainst, str1, monsterName) + + result = strings.ReplaceAll(result, "+-", "-") + + return result +} + +func (s *Stat) descFn23(values ...int) string { + // for now, same as fn22 + return s.descFn22(values...) +} + +func (s *Stat) descFn24(values ...int) string { + format := baseFormatStrings[s.Record.DescFnID] + lvl, skillID, chargeMax, chargeCurrent := values[0], values[1], values[2], values[3] + + charges := d2common.TranslateString(s.Record.DescStrPos) + charges = fmt.Sprintf(charges, chargeCurrent, chargeMax) + + skillName := d2datadict.SkillDetails[skillID].Skill + + result := fmt.Sprintf(format, lvl, skillName, charges) + + return result +} + +func (s *Stat) descFn27(values ...int) string { + format := baseFormatStrings[s.Record.DescFnID] + amount, skillID, heroIndex := values[0], values[1], values[2] + + skillName := d2datadict.SkillDetails[skillID].Skill + + heroMap := map[int]d2enum.Hero{ + int(d2enum.HeroAmazon): d2enum.HeroAmazon, + int(d2enum.HeroSorceress): d2enum.HeroSorceress, + int(d2enum.HeroNecromancer): d2enum.HeroNecromancer, + int(d2enum.HeroPaladin): d2enum.HeroPaladin, + int(d2enum.HeroBarbarian): d2enum.HeroBarbarian, + int(d2enum.HeroDruid): d2enum.HeroDruid, + int(d2enum.HeroAssassin): d2enum.HeroAssassin, + } + + classRecord := d2datadict.CharStats[heroMap[heroIndex]] + classOnlyStr := d2common.TranslateString(classRecord.SkillStrClassOnly) + + return fmt.Sprintf(format, amount, skillName, classOnlyStr) +} + +func (s *Stat) descFn28(values ...int) string { + format := baseFormatStrings[s.Record.DescFnID] + amount, skillID := values[0], values[1] + + skillName := d2datadict.SkillDetails[skillID].Skill + + return fmt.Sprintf(format, amount, skillName) +} + +func fixString(s string) string { + s = strings.ReplaceAll(s, "+-", "-") + s = strings.ReplaceAll(s, " +%d", "") + + return s +} + +// DescGroupString return a string based on the DescGroupFuncID +func (s *Stat) DescGroupString(a ...interface{}) string { + if s.Record.DescGroupFuncID < 0 || s.Record.DescGroupFuncID > len( + baseFormatStrings) { + return "" + } + + format := baseFormatStrings[s.Record.DescGroupFuncID] + + return fmt.Sprintf(format, a...) +} + +// NumStatValues returns the number of values a stat instance for this +// record should have +func (s *Stat) NumStatValues() int { + if num, found := statValueCountLookup[s.Record.DescGroupFuncID]; found { + return num + } + + if statValueCountLookup == nil { + statValueCountLookup = make(map[int]int) + } + + format := baseFormatStrings[s.Record.DescGroupFuncID] + pattern := regexp.MustCompile("%v") + matches := pattern.FindAllStringIndex(format, -1) + num := len(matches) + statValueCountLookup[s.Record.DescGroupFuncID] = num + + return num +} + +func reverseStringSlice(s []string) []string { + for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 { + s[i], s[j] = s[j], s[i] + } + + return s +} diff --git a/d2core/d2stats/stat_test.go b/d2core/d2stats/stat_test.go new file mode 100644 index 00000000..2ed554bc --- /dev/null +++ b/d2core/d2stats/stat_test.go @@ -0,0 +1,382 @@ +package d2stats + +import ( + "fmt" + "testing" + + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2data/d2datadict" + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2enum" +) + +const ( + errStr string = "incorrect description string format for stat" + errFmt string = "%v:\n\tKey: %v\n\tVal: %+v\n\texpected: %v\n\tgot: %v\n\n" +) + +//nolint:funlen // this just gets mock data ready for the tests +func TestStat_InitMockData(t *testing.T) { + var itemStatCosts = map[string]*d2datadict.ItemStatCostRecord{ + "strength": { + Name: "strength", + DescFnID: 1, + DescVal: int(descValPrefix), + DescStrPos: "to Strength", + DescStrNeg: "to Strength", + }, + "dexterity": { + Name: "dexterity", + DescFnID: 1, + DescVal: int(descValPrefix), + DescStrPos: "to Dexterity", + DescStrNeg: "to Dexterity", + }, + "vitality": { + Name: "vitality", + DescFnID: 1, + DescVal: int(descValPrefix), + DescStrPos: "to Vitality", + DescStrNeg: "to Vitality", + }, + "energy": { + Name: "energy", + DescFnID: 1, + DescVal: int(descValPrefix), + DescStrPos: "to Energy", + DescStrNeg: "to Energy", + }, + "hpregen": { + Name: "hpregen", + DescFnID: 1, + DescVal: int(descValPostfix), + DescStrPos: "Replenish Life", + DescStrNeg: "Drain Life", + }, + "toblock": { + Name: "toblock", + DescFnID: 2, + DescVal: int(descValPrefix), + DescStrPos: "Increased Chance of Blocking", + DescStrNeg: "Increased Chance of Blocking", + }, + "item_absorblight_percent": { + Name: "item_absorblight_percent", + DescFnID: 2, + DescVal: int(descValPostfix), + DescStrPos: "Lightning Absorb", + DescStrNeg: "Lightning Absorb", + }, + "item_restinpeace": { + Name: "item_restinpeace", + DescFnID: 3, + DescVal: int(descValHide), + DescStrPos: "Slain Monsters Rest in Peace", + DescStrNeg: "Slain Monsters Rest in Peace", + }, + "normal_damage_reduction": { + Name: "normal_damage_reduction", + DescFnID: 3, + DescVal: int(descValPostfix), + DescStrPos: "Damage Reduced by", + DescStrNeg: "Damage Reduced by", + }, + "poisonresist": { + Name: "poisonresist", + DescFnID: 4, + DescVal: int(descValPostfix), + DescStrPos: "Poison Resist", + DescStrNeg: "Poison Resist", + }, + "item_fastermovevelocity": { + Name: "item_fastermovevelocity", + DescFnID: 4, + DescVal: int(descValPrefix), + DescStrPos: "Faster Run/Walk", + DescStrNeg: "Faster Run/Walk", + }, + "item_howl": { + Name: "item_howl", + DescFnID: 5, + DescVal: int(descValPostfix), + DescStrPos: "Hit Causes Monster to Flee", + DescStrNeg: "Hit Causes Monster to Flee", + }, + "item_hp_perlevel": { + Name: "item_hp_perlevel", + DescFnID: 6, + DescVal: int(descValPrefix), + DescStrPos: "to Life", + DescStrNeg: "to Life", + DescStr2: "(Based on Character Level)", + }, + "item_resist_ltng_perlevel": { + Name: "item_resist_ltng_perlevel", + DescFnID: 7, + DescVal: int(descValPostfix), + DescStrPos: "Lightning Resist", + DescStrNeg: "Lightning Resist", + DescStr2: "(Based on Character Level)", + }, + "item_find_magic_perlevel": { + Name: "item_find_magic_perlevel", + DescFnID: 7, + DescVal: int(descValPrefix), + DescStrPos: "Better Chance of Getting Magic Items", + DescStrNeg: "Better Chance of Getting Magic Items", + DescStr2: "(Based on Character Level)", + }, + "item_armorpercent_perlevel": { + Name: "item_armorpercent_perlevel", + DescFnID: 8, + DescVal: int(descValPrefix), + DescStrPos: "Enhanced Defense", + DescStrNeg: "Enhanced Defense", + DescStr2: "(Based on Character Level)", + }, + "item_regenstamina_perlevel": { + Name: "item_regenstamina_perlevel", + DescFnID: 8, + DescVal: int(descValPostfix), + DescStrPos: "Heal Stamina Plus", + DescStrNeg: "Heal Stamina Plus", + DescStr2: "(Based on Character Level)", + }, + "item_thorns_perlevel": { + Name: "item_thorns_perlevel", + DescFnID: 9, + DescVal: int(descValPostfix), + DescStrPos: "Attacker Takes Damage of", + DescStrNeg: "Attacker Takes Damage of", + DescStr2: "(Based on Character Level)", + }, + "item_replenish_durability": { + Name: "item_replenish_durability", + DescFnID: 11, + DescVal: int(descValPrefix), + DescStrPos: "Repairs %v durability per second", + DescStrNeg: "Repairs %v durability per second", + DescStr2: "", + }, + "item_stupidity": { + Name: "item_stupidity", + DescFnID: 12, + DescVal: int(descValPostfix), + DescStrPos: "Hit Blinds Target", + DescStrNeg: "Hit Blinds Target", + }, + "item_addclassskills": { + Name: "item_addclassskills", + DescFnID: 13, + DescVal: int(descValPrefix), + }, + "item_addskill_tab": { + Name: "item_addskill_tab", + DescFnID: 14, + DescVal: int(descValPrefix), + }, + "item_skillonattack": { + Name: "item_skillonattack", + DescFnID: 15, + DescVal: int(descValPrefix), + DescStrPos: "%d%% Chance to cast level %d %s on attack", + DescStrNeg: "%d%% Chance to cast level %d %s on attack", + }, + "item_aura": { + Name: "item_aura", + DescFnID: 16, + DescVal: int(descValPrefix), + DescStrPos: "Level %d %s Aura When Equipped", + DescStrNeg: "Level %d %s Aura When Equipped", + }, + "item_fractionaltargetac": { + Name: "item_fractionaltargetac", + DescFnID: 20, + DescVal: int(descValPrefix), + DescStrPos: "Target Defense", + DescStrNeg: "Target Defense", + }, + "attack_vs_montype": { + Name: "item_fractionaltargetac", + DescFnID: 22, + DescVal: int(descValPrefix), + DescStrPos: "to Attack Rating versus", + DescStrNeg: "to Attack Rating versus", + }, + "item_reanimate": { + Name: "item_reanimate", + DescFnID: 23, + DescVal: int(descValPostfix), + DescStrPos: "Reanimate as:", + DescStrNeg: "Reanimate as:", + }, + "item_charged_skill": { + Name: "item_charged_skill", + DescFnID: 24, + DescVal: int(descValPostfix), + DescStrPos: "(%d/%d Charges)", + DescStrNeg: "(%d/%d Charges)", + }, + "item_singleskill": { + Name: "item_singleskill", + DescFnID: 27, + DescVal: int(descValPostfix), + DescStrPos: "(%d/%d Charges)", + DescStrNeg: "(%d/%d Charges)", + }, + "item_nonclassskill": { + Name: "item_nonclassskill", + DescFnID: 28, + DescVal: int(descValPostfix), + DescStrPos: "(%d/%d Charges)", + DescStrNeg: "(%d/%d Charges)", + }, + } + + var charStats = map[d2enum.Hero]*d2datadict.CharStatsRecord{ + d2enum.HeroPaladin: { + Class: d2enum.HeroPaladin, + SkillStrAll: "to Paladin Skill Levels", + SkillStrClassOnly: "(Paladin Only)", + SkillStrTab: [3]string{ + "+%d to Combat Skills", + "+%d to Offensive Auras", + "+%d to Defensive Auras", + }, + }, + } + + var skillDetails = map[int]*d2datadict.SkillRecord{ + 37: {Skill: "Warmth"}, + 64: {Skill: "Frozen Orb"}, + } + + var monStats = map[string]*d2datadict.MonStatsRecord{ + "Specter": {NameString: "Specter", Id: 40}, + } + + d2datadict.ItemStatCosts = itemStatCosts + d2datadict.CharStats = charStats + d2datadict.SkillDetails = skillDetails + d2datadict.MonStats = monStats +} + +func TestStat_Clone(t *testing.T) { + r := d2datadict.ItemStatCosts["strength"] + s1 := CreateStat(r, 5) + s2 := s1.Clone() + + // make sure the stats are distinct + if &s1 == &s2 { + t.Errorf("stats share the same pointer %d == %d", &s1, &s2) + } + + // make sure the stat values are unique + vs1, vs2 := s1.Values, s2.Values + if &vs1 == &vs2 { + t.Errorf("stat values share the same pointer %d == %d", &s1, &s2) + } + + s2.Values[0] = 6 + v1, v2 := s1.Values[0], s2.Values[0] + + // make sure the value ranges are distinct + if v1 == v2 { + t.Errorf("stat value ranges should not be equal") + } +} + +func TestStat_Descriptions(t *testing.T) { + tests := []struct { + recordKey string + vals []int + expect string + }{ + // DescFn1 + {"strength", []int{31}, "+31 to Strength"}, + {"hpregen", []int{20}, "Replenish Life +20"}, + {"hpregen", []int{-8}, "Drain Life -8"}, + + // DescFn2 + {"toblock", []int{16}, "+16% Increased Chance of Blocking"}, + {"item_absorblight_percent", []int{10}, "Lightning Absorb +10%"}, + + // DescFn3 + {"normal_damage_reduction", []int{25}, "Damage Reduced by 25"}, + {"item_restinpeace", []int{25}, "Slain Monsters Rest in Peace"}, + + // DescFn4 + {"poisonresist", []int{25}, "Poison Resist +25%"}, + {"item_fastermovevelocity", []int{25}, "+25% Faster Run/Walk"}, + + // DescFn5 + {"item_howl", []int{25}, "Hit Causes Monster to Flee 25%"}, + + // DescFn6 + {"item_hp_perlevel", []int{25}, "+25 to Life (Based on Character Level)"}, + + // DescFn7 + {"item_resist_ltng_perlevel", []int{25}, "Lightning Resist +25% (Based on Character Level)"}, + {"item_find_magic_perlevel", []int{25}, "+25% Better Chance of Getting Magic Items (" + + "Based on Character Level)"}, + + // DescFn8 + {"item_armorpercent_perlevel", []int{25}, "+25% Enhanced Defense (Based on Character Level)"}, + {"item_regenstamina_perlevel", []int{25}, + "Heal Stamina Plus +25% (Based on Character Level)"}, + + // DescFn9 + {"item_thorns_perlevel", []int{25}, "Attacker Takes Damage of 25 (Based on Character Level)"}, + + // DescFn11 + {"item_replenish_durability", []int{2}, "Repairs 2 durability per second"}, + + // DescFn12 + {"item_stupidity", []int{5}, "Hit Blinds Target +5"}, + + // DescFn13 + {"item_addclassskills", []int{5, 3}, "+5 to Paladin Skill Levels"}, + + // DescFn14 + {"item_addskill_tab", []int{5, 3, 0}, "+5 to Combat Skills (Paladin Only)"}, + {"item_addskill_tab", []int{5, 3, 1}, "+5 to Offensive Auras (Paladin Only)"}, + {"item_addskill_tab", []int{5, 3, 2}, "+5 to Defensive Auras (Paladin Only)"}, + + // DescFn15 + {"item_skillonattack", []int{5, 7, 64}, "5% Chance to cast level 7 Frozen Orb on attack"}, + + // DescFn16 + {"item_aura", []int{3, 37}, "Level 3 Warmth Aura When Equipped"}, + + // DescFn20 + {"item_fractionaltargetac", []int{-25}, "-25% Target Defense"}, + + // DescFn22 + {"attack_vs_montype", []int{25, 40}, "25% to Attack Rating versus Specter"}, + + // DescFn23 + {"item_reanimate", []int{25, 40}, "25% Reanimate as: Specter"}, + + // DescFn24 + {"item_charged_skill", []int{25, 64, 20, 19}, "Level 25 Frozen Orb (19/20 Charges)"}, + + // DescFn27 + {"item_singleskill", []int{25, 64, 3}, "+25 to Frozen Orb (Paladin Only)"}, + + // DescFn28 + {"item_nonclassskill", []int{25, 64}, "+25 to Frozen Orb"}, + } + + for idx := range tests { + test := tests[idx] + record := d2datadict.ItemStatCosts[test.recordKey] + expect := test.expect + stat := CreateStat(record, test.vals...) + + if got := stat.Description(); got != expect { + t.Errorf(errFmt, errStr, test.recordKey, test.vals, expect, got) + } else { + success := "[Desc Func %d][%s %+v] %s" + success = fmt.Sprintf(success, record.DescFnID, record.Name, test.vals, got) + fmt.Println(success) + } + } +} diff --git a/d2core/d2stats/statlist.go b/d2core/d2stats/statlist.go new file mode 100644 index 00000000..45c9ca84 --- /dev/null +++ b/d2core/d2stats/statlist.go @@ -0,0 +1,82 @@ +package d2stats + +import "github.com/OpenDiablo2/OpenDiablo2/d2common/d2data/d2datadict" + +// CreateStatList creates a stat list +func CreateStatList(stats ...*Stat) *StatList { + return &StatList{stats} +} + +// StatList is a list that contains stats +type StatList struct { + stats []*Stat +} + +// Clone returns a deep copy of the stat list +func (sl *StatList) Clone() *StatList { + clone := &StatList{} + clone.stats = make([]*Stat, len(sl.stats)) + + for idx := range sl.stats { + clone.stats[idx] = sl.stats[idx].Clone() + } + + return clone +} + +// Reduce returns a new stat list, combining like stats +func (sl *StatList) Reduce() *StatList { + clone := sl.Clone() + reduction := make([]*Stat, 0) + + // for quick lookups + lookup := make(map[*d2datadict.ItemStatCostRecord]int) + + for len(clone.stats) > 0 { + applied := false + stat := clone.removeStat(0) + + // use lookup, may have found it already + if idx, found := lookup[stat.Record]; found { + if success := reduction[idx].combine(stat); success { + continue + } + + reduction = append(reduction, stat) + } + + for idx := range reduction { + if reduction[idx].combine(stat) { + lookup[stat.Record] = idx + applied = true + + break + } + } + + if !applied { + reduction = append(reduction, stat) + } + } + + clone.stats = reduction + + return clone +} + +func (sl *StatList) removeStat(idx int) *Stat { + picked := sl.stats[idx] + sl.stats[idx] = sl.stats[len(sl.stats)-1] + sl.stats[len(sl.stats)-1] = nil + sl.stats = sl.stats[:len(sl.stats)-1] + + return picked +} + +// Append returns a new stat list, combining like stats +func (sl *StatList) Append(other *StatList) *StatList { + clone := sl.Clone() + clone.stats = append(clone.stats, other.stats...) + + return clone +} diff --git a/d2core/d2stats/statlist_test.go b/d2core/d2stats/statlist_test.go new file mode 100644 index 00000000..d6775ae1 --- /dev/null +++ b/d2core/d2stats/statlist_test.go @@ -0,0 +1,90 @@ +package d2stats + +import ( + "testing" + + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2data/d2datadict" +) + +func TestStatList_Clone(t *testing.T) { + record := d2datadict.ItemStatCosts["strength"] + strength := CreateStat(record, 10) + + list1 := CreateStatList(strength) + list2 := list1.Clone() + + if list1.stats[0].Description() != list2.stats[0].Description() { + t.Errorf("Stats of cloned stat list should be identitcal") + } + + list2.stats[0].Values[0] = 0 + if list1.stats[0].Description() == list2.stats[0].Description() { + t.Errorf("Stats of cloned stat list should be different") + } +} + +func TestStatList_Reduce(t *testing.T) { + records := []*d2datadict.ItemStatCostRecord{ + d2datadict.ItemStatCosts["strength"], + d2datadict.ItemStatCosts["energy"], + d2datadict.ItemStatCosts["dexterity"], + d2datadict.ItemStatCosts["vitality"], + } + + stats := []*Stat{ + CreateStat(records[0], 1), + CreateStat(records[0], 1), + CreateStat(records[0], 1), + CreateStat(records[0], 1), + } + + list := CreateStatList(stats...) + reduction := list.Reduce() + + if len(reduction.stats) != 1 || reduction.stats[0].Description() != "+4 to Strength" { + t.Errorf("Stat reduction failed") + } + + stats = []*Stat{ + CreateStat(records[0], 1), + CreateStat(records[1], 1), + CreateStat(records[2], 1), + CreateStat(records[3], 1), + } + + list = CreateStatList(stats...) + reduction = list.Reduce() + + if len(reduction.stats) != 4 { + t.Errorf("Stat reduction failed") + } +} + +func TestStatList_Append(t *testing.T) { + records := []*d2datadict.ItemStatCostRecord{ + d2datadict.ItemStatCosts["strength"], + d2datadict.ItemStatCosts["energy"], + d2datadict.ItemStatCosts["dexterity"], + d2datadict.ItemStatCosts["vitality"], + } + + list1 := &StatList{ + []*Stat{ + CreateStat(records[0], 1), + CreateStat(records[1], 1), + CreateStat(records[2], 1), + CreateStat(records[3], 1), + }, + } + list2 := list1.Clone() + + list3 := list1.Append(list2) + + if len(list3.stats) != 8 { + t.Errorf("Stat append failed") + } + + if len(list3.Reduce().stats) != 4 { + t.Errorf("Stat append failed") + } +}