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) <duguigne@gmail.com>
This commit is contained in:
dk 2020-07-17 15:50:45 -07:00 committed by GitHub
parent 54ff33c552
commit cf6029eb95
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 1563 additions and 67 deletions

View File

@ -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()

View File

@ -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
}

View File

@ -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
}

View File

@ -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))
}

View File

@ -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 ---

View File

@ -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

2
d2core/d2stats/doc.go Normal file
View File

@ -0,0 +1,2 @@
// Package d2stats provides item/skill/character stats functionality
package d2stats

724
d2core/d2stats/stat.go Normal file
View File

@ -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
}

382
d2core/d2stats/stat_test.go Normal file
View File

@ -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)
}
}
}

View File

@ -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
}

View File

@ -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")
}
}