From c114ab9eb7d3d921845f4ca774e7519a52b28795 Mon Sep 17 00:00:00 2001 From: lord Date: Thu, 23 Jul 2020 16:03:58 -0700 Subject: [PATCH] add interfaces for stats, added diablo 2 implementation (#614) * add interface for stats, d2 is an implementation * fix incorrect comment, remove ennecessary int * simplified description functions, remove duplicates * moved default stringer functions * fixed incorrect stat combine method * change `Create` to `New` in method names --- d2core/d2stats/diablo2stats/diablo2stats.go | 45 ++ d2core/d2stats/diablo2stats/doc.go | 2 + d2core/d2stats/diablo2stats/stat.go | 527 +++++++++++++ .../d2stats/{ => diablo2stats}/stat_test.go | 83 +- d2core/d2stats/diablo2stats/stat_value.go | 76 ++ .../diablo2stats/stat_value_stringers.go | 83 ++ d2core/d2stats/diablo2stats/statlist.go | 120 +++ d2core/d2stats/diablo2stats/statlist_test.go | 106 +++ d2core/d2stats/stat.go | 732 +----------------- d2core/d2stats/stat_list.go | 16 + d2core/d2stats/stat_value.go | 27 + d2core/d2stats/statlist.go | 82 -- d2core/d2stats/statlist_test.go | 90 --- 13 files changed, 1055 insertions(+), 934 deletions(-) create mode 100644 d2core/d2stats/diablo2stats/diablo2stats.go create mode 100644 d2core/d2stats/diablo2stats/doc.go create mode 100644 d2core/d2stats/diablo2stats/stat.go rename d2core/d2stats/{ => diablo2stats}/stat_test.go (70%) create mode 100644 d2core/d2stats/diablo2stats/stat_value.go create mode 100644 d2core/d2stats/diablo2stats/stat_value_stringers.go create mode 100644 d2core/d2stats/diablo2stats/statlist.go create mode 100644 d2core/d2stats/diablo2stats/statlist_test.go create mode 100644 d2core/d2stats/stat_list.go create mode 100644 d2core/d2stats/stat_value.go delete mode 100644 d2core/d2stats/statlist.go delete mode 100644 d2core/d2stats/statlist_test.go diff --git a/d2core/d2stats/diablo2stats/diablo2stats.go b/d2core/d2stats/diablo2stats/diablo2stats.go new file mode 100644 index 00000000..525e625b --- /dev/null +++ b/d2core/d2stats/diablo2stats/diablo2stats.go @@ -0,0 +1,45 @@ +package diablo2stats + +import ( + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2data/d2datadict" + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2stats" +) + +// NewStat creates a stat instance with the given record and values +func NewStat(record *d2datadict.ItemStatCostRecord, values ...d2stats.StatValue) d2stats.Stat { + if record == nil { + return nil + } + + stat := &Diablo2Stat{ + record: record, + values: values, + } + + return stat +} + +// NewStatList creates a stat list +func NewStatList(stats ...d2stats.Stat) d2stats.StatList { + return &Diablo2StatList{stats} +} + +// NewStatValue creates a stat value of the given type +func NewStatValue(t d2stats.StatValueType) d2stats.StatValue { + sv := &Diablo2StatValue{_type: t} + + switch t { + case d2stats.StatValueFloat: + sv._stringer = stringerUnsignedFloat + case d2stats.StatValueInt: + sv._stringer = stringerUnsignedInt + default: + sv._stringer = stringerEmpty + } + + return sv +} + +func intVal(i int) d2stats.StatValue { + return NewStatValue(d2stats.StatValueInt).SetInt(i) +} diff --git a/d2core/d2stats/diablo2stats/doc.go b/d2core/d2stats/diablo2stats/doc.go new file mode 100644 index 00000000..3ae88128 --- /dev/null +++ b/d2core/d2stats/diablo2stats/doc.go @@ -0,0 +1,2 @@ +// Package diablo2stats is the Diablo 2 stats implementation +package diablo2stats diff --git a/d2core/d2stats/diablo2stats/stat.go b/d2core/d2stats/diablo2stats/stat.go new file mode 100644 index 00000000..efa5c2c4 --- /dev/null +++ b/d2core/d2stats/diablo2stats/stat.go @@ -0,0 +1,527 @@ +package diablo2stats + +import ( + "fmt" + "reflect" + "strings" + + "github.com/OpenDiablo2/OpenDiablo2/d2common" + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2data/d2datadict" + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2stats" +) + +// static check that Diablo2Stat implements Stat +var _ d2stats.Stat = &Diablo2Stat{} + +type descValPosition int + +const ( + descValHide descValPosition = iota + descValPrefix + descValPostfix +) + +const ( + maxSkillTabIndex = 2 +) + +const ( + twoComponentStr = "%s %s" + threeComponentStr = "%s %s %s" + fourComponentStr = "%s %s %s %s" +) + +// Diablo2Stat is an instance of a Diablo2Stat, with a set of values +type Diablo2Stat struct { + record *d2datadict.ItemStatCostRecord + values []d2stats.StatValue +} + +// Name returns the name of the stat (the key in itemstatcosts) +func (s *Diablo2Stat) Name() string { + return s.record.Name +} + +// Priority returns the description printing priority +func (s *Diablo2Stat) Priority() int { + return s.record.DescPriority +} + +// Values returns the stat values of the stat +func (s *Diablo2Stat) Values() []d2stats.StatValue { + return s.values +} + +// SetValues sets the stat values +func (s *Diablo2Stat) SetValues(values ...d2stats.StatValue) { + s.values = make([]d2stats.StatValue, len(values)) + for idx := range values { + s.values[idx] = values[idx] + } +} + +// Clone returns a deep copy of the Diablo2Stat +func (s *Diablo2Stat) Clone() d2stats.Stat { + clone := &Diablo2Stat{ + record: s.record, + values: make([]d2stats.StatValue, len(s.Values())), + } + + for idx := range s.values { + srcVal := s.values[idx] + dstVal := reflect.New(reflect.ValueOf(srcVal).Elem().Type()).Interface().(d2stats.StatValue) + + switch srcVal.Type() { + case d2stats.StatValueInt: + dstVal.SetInt(srcVal.Int()) + case d2stats.StatValueFloat: + dstVal.SetFloat(srcVal.Float()) + } + + clone.values[idx] = dstVal + } + + return clone +} + +// Copy to this stat value the values of the given stat value +func (s *Diablo2Stat) Copy(from d2stats.Stat) d2stats.Stat { + srcValues := from.Values() + s.values = make([]d2stats.StatValue, len(srcValues)) + + for idx := range srcValues { + src := srcValues[idx] + valType := src.Type() + dst := &Diablo2StatValue{_type: valType} + dst.SetStringer(src.Stringer()) + + switch valType { + case d2stats.StatValueInt: + dst.SetInt(src.Int()) + case d2stats.StatValueFloat: + dst.SetFloat(src.Float()) + } + + s.values[idx] = dst + } + + return s +} + +// Combine sums the other stat with this one (does not alter this stat, returns altered clone!) +func (s *Diablo2Stat) Combine(other d2stats.Stat) (result d2stats.Stat, err error) { + cantBeCombinedErr := fmt.Errorf("cannot combine %s with %s", s.Name(), other.Name()) + + if !s.canBeCombinedWith(other) { + return nil, cantBeCombinedErr + } + + result = s.Clone() + srcValues, dstValues := other.Values(), result.Values() + + for idx := range result.Values() { + v1, v2 := dstValues[idx], srcValues[idx] + + valType := v1.Type() + switch valType { + case d2stats.StatValueInt: + v1.SetInt(v1.Int() + v2.Int()) + case d2stats.StatValueFloat: + v1.SetFloat(v1.Float() + v2.Float()) + } + } + + return result, nil +} + +func (s *Diablo2Stat) canBeCombinedWith(other d2stats.Stat) bool { + if s.Name() != other.Name() { + return false + } + + values1, values2 := s.Values(), other.Values() + if len(values1) != len(values2) { + return false + } + + for idx := range values1 { + if values1[idx].Type() != values2[idx].Type() { + return false + } + } + + return true +} + +// String returns the formatted description string +func (s *Diablo2Stat) String() string { //nolint:gocyclo switch statement is not so bad + var result string + + //nolint:gomdn introducing a const for these would be worse + switch s.record.DescFnID { + case 1: + s.values[0].SetStringer(stringerIntSigned) + result = s.descFn1() + case 2: + s.values[0].SetStringer(stringerIntPercentageSigned) + result = s.descFn2() + case 3: + result = s.descFn3() + case 4: + s.values[0].SetStringer(stringerIntPercentageSigned) + result = s.descFn4() + case 5: + s.values[0].SetStringer(stringerIntPercentageUnsigned) + result = s.descFn5() + case 6: + s.values[0].SetStringer(stringerIntSigned) + result = s.descFn6() + case 7: + s.values[0].SetStringer(stringerIntPercentageSigned) + result = s.descFn7() + case 8: + s.values[0].SetStringer(stringerIntPercentageSigned) + result = s.descFn8() + case 9: + result = s.descFn9() + case 11: + result = s.descFn11() + case 12: + s.values[0].SetStringer(stringerIntSigned) + result = s.descFn12() + case 13: + s.values[0].SetStringer(stringerIntSigned) + s.values[1].SetStringer(stringerClassAllSkills) + result = s.descFn13() + case 14: + s.values[0].SetStringer(stringerIntSigned) + s.values[1].SetStringer(stringerClassOnly) + result = s.descFn14() + case 15: + s.values[2].SetStringer(stringerSkillName) + result = s.descFn15() + case 16: + s.values[1].SetStringer(stringerSkillName) + result = s.descFn16() + case 20: + s.values[0].SetStringer(stringerIntPercentageSigned) + result = s.descFn20() + case 22: + s.values[0].SetStringer(stringerIntPercentageUnsigned) + s.values[1].SetStringer(stringerMonsterName) + result = s.descFn22() + case 23: + s.values[0].SetStringer(stringerIntPercentageUnsigned) + s.values[1].SetStringer(stringerMonsterName) + result = s.descFn23() + case 24: + s.values[1].SetStringer(stringerSkillName) + result = s.descFn24() + case 27: + s.values[0].SetStringer(stringerIntSigned) + s.values[1].SetStringer(stringerSkillName) + s.values[2].SetStringer(stringerClassOnly) + result = s.descFn27() + case 28: + s.values[0].SetStringer(stringerIntSigned) + s.values[1].SetStringer(stringerSkillName) + result = s.descFn28() + default: + result = "" + } + + return result +} + +// +31 to Strength +// Replenish Life +20 || Drain Life -8 +func (s *Diablo2Stat) descFn1() string { + var stringTableKey, result string + + value := s.values[0] + + formatString := twoComponentStr + + if value.Int() < 0 { + stringTableKey = s.record.DescStrNeg + } else { + stringTableKey = s.record.DescStrPos + } + + stringTableString := d2common.TranslateString(stringTableKey) + + switch descValPosition(s.record.DescVal) { + case descValPrefix: + result = fmt.Sprintf(formatString, value, stringTableString) + case descValPostfix: + result = fmt.Sprintf(formatString, stringTableString, value) + case descValHide: + result = stringTableString + default: + result = "" + } + + return result +} + +// +16% Increased Chance of Blocking +// Lightning Absorb +10% +func (s *Diablo2Stat) descFn2() string { + // for now, same as fn1 + return s.descFn1() +} + +// Damage Reduced by 25 +// Slain Monsters Rest in Peace +func (s *Diablo2Stat) descFn3() string { + // for now, same as fn1 + return s.descFn1() +} + +// Poison Resist +25% +// +25% Faster Run/Walk +func (s *Diablo2Stat) descFn4() string { + // for now, same as fn1 + return s.descFn1() +} + +// Hit Causes Monster to Flee 25% +func (s *Diablo2Stat) descFn5() string { + // for now, same as fn1 + return s.descFn1() +} + +// +25 to Life (Based on Character Level) +func (s *Diablo2Stat) descFn6() string { + var stringTableKey, result string + + value := s.values[0] + + formatString := threeComponentStr + + if value.Int() < 0 { + stringTableKey = s.record.DescStrNeg + } else { + stringTableKey = s.record.DescStrPos + } + + str1 := d2common.TranslateString(stringTableKey) + str2 := d2common.TranslateString(s.record.DescStr2) + + switch descValPosition(s.record.DescVal) { + case descValPrefix: + result = fmt.Sprintf(formatString, value, str1, str2) + case descValPostfix: + result = fmt.Sprintf(formatString, str1, value, str2) + case descValHide: + formatString = twoComponentStr + result = fmt.Sprintf(formatString, value, str2) + default: + result = "" + } + + return result +} + +// Lightning Resist +25% (Based on Character Level) +// +25% Better Chance of Getting Magic Items (Based on Character Level) +func (s *Diablo2Stat) descFn7() string { + // for now, same as fn6 + return s.descFn6() +} + +// +25% Enhanced Defense (Based on Character Level) +// Heal Stamina Plus +25% (Based on Character Level) +func (s *Diablo2Stat) descFn8() string { + // for now, same as fn6 + return s.descFn6() +} + +// Attacker Takes Damage of 25 (Based on Character Level) +func (s *Diablo2Stat) descFn9() string { + var stringTableKey, result string + + value := s.values[0] + + formatString := threeComponentStr + + if value.Int() < 0 { + stringTableKey = s.record.DescStrNeg + } else { + stringTableKey = s.record.DescStrPos + } + + str1 := d2common.TranslateString(stringTableKey) + str2 := d2common.TranslateString(s.record.DescStr2) + + switch descValPosition(s.record.DescVal) { + case descValPrefix: + result = fmt.Sprintf(formatString, value, str1, str2) + case descValPostfix: + result = fmt.Sprintf(formatString, str1, value, str2) + case descValHide: + result = fmt.Sprintf(twoComponentStr, value, str2) + default: + result = "" + } + + return result +} + +// Repairs 2 durability per second +func (s *Diablo2Stat) descFn11() string { + var stringTableKey string + + value := s.values[0] + + if value.Int() < 0 { + stringTableKey = s.record.DescStrNeg + } else { + stringTableKey = s.record.DescStrPos + } + + str1 := d2common.TranslateString(stringTableKey) + + formatString := str1 + + return fmt.Sprintf(formatString, value) +} + +// Hit Blinds Target +5 +func (s *Diablo2Stat) descFn12() string { + // for now, same as fn1 + return s.descFn1() +} + +// +5 to Paladin Skill Levels +func (s *Diablo2Stat) descFn13() string { + value := s.values[0] + allSkills := s.values[1] + + formatString := twoComponentStr + + switch descValPosition(s.record.DescVal) { + case descValPrefix: + return fmt.Sprintf(formatString, value, allSkills) + case descValPostfix: + return fmt.Sprintf(formatString, allSkills, value) + case descValHide: + return allSkills.String() + default: + return "" + } +} + +// +5 to Combat Skills (Paladin Only) +func (s *Diablo2Stat) descFn14() string { + // strings come out like `+5 to Combat Skills (Paladin Only)` + numSkills, hero, skillTab := s.values[0], s.values[1], s.values[2] + heroMap := getHeroMap() + heroIndex := hero.Int() + classRecord := d2datadict.CharStats[heroMap[heroIndex]] + + // diablo 2 is hardcoded to have only 3 skill tabs + skillTabIndex := skillTab.Int() + if skillTabIndex < 0 || skillTabIndex > maxSkillTabIndex { + skillTabIndex = 0 + } + + // `+5` + numSkillsStr := numSkills.String() + + // `to Combat Skills` + skillTabKey := classRecord.SkillStrTab[skillTabIndex] + skillTabStr := d2common.TranslateString(skillTabKey) + skillTabStr = strings.ReplaceAll(skillTabStr, "+%d ", "") // has a token we dont need + + // `(Paladin Only)` + classOnlyStr := hero.String() + + return fmt.Sprintf(threeComponentStr, numSkillsStr, skillTabStr, classOnlyStr) +} + +// 5% Chance to cast level 7 Frozen Orb on attack +func (s *Diablo2Stat) descFn15() string { + chance, lvl, skill := s.values[0], s.values[1], s.values[2] + + // Special case, `chance to cast` format is actually in the string table! + chanceToCastStr := d2common.TranslateString(s.record.DescStrPos) + + return fmt.Sprintf(chanceToCastStr, chance.Int(), lvl.Int(), skill) +} + +// Level 3 Warmth Aura When Equipped +func (s *Diablo2Stat) descFn16() string { + skillLevel, skillIndex := s.values[0], s.values[1] + + // Special case, `Level # XYZ Aura When Equipped`, format is actually in the string table! + format := d2common.TranslateString(s.record.DescStrPos) + + return fmt.Sprintf(format, skillLevel.Int(), skillIndex) +} + +// -25% Target Defense +func (s *Diablo2Stat) descFn20() string { + // for now, same as fn2 + return s.descFn2() +} + +// 25% to Attack Rating versus Specter +func (s *Diablo2Stat) descFn22() string { + arBonus, monsterIndex := s.values[0], s.values[1] + arVersus := d2common.TranslateString(s.record.DescStrPos) + + return fmt.Sprintf(threeComponentStr, arBonus, arVersus, monsterIndex) +} + +// 25% Reanimate as: Specter +func (s *Diablo2Stat) descFn23() string { + // for now, same as fn22 + return s.descFn22() +} + +// Level 25 Frozen Orb (19/20 Charges) +func (s *Diablo2Stat) descFn24() string { + // Special case formatting + format := "Level " + threeComponentStr + + lvl, skill, chargeMax, chargeCurrent := s.values[0], + s.values[1], + s.values[2].Int(), + s.values[3].Int() + + chargeStr := d2common.TranslateString(s.record.DescStrPos) + chargeStr = fmt.Sprintf(chargeStr, chargeCurrent, chargeMax) + + return fmt.Sprintf(format, lvl, skill, chargeStr) +} + +// +25 to Frozen Orb (Paladin Only) +func (s *Diablo2Stat) descFn27() string { + amount, skill, hero := s.values[0], s.values[1], s.values[2] + + return fmt.Sprintf(fourComponentStr, amount, "to", skill, hero) +} + +// +25 to Frozen Orb +func (s *Diablo2Stat) descFn28() string { + amount, skill := s.values[0], s.values[1] + + return fmt.Sprintf(threeComponentStr, amount, "to", skill) +} + +// DescGroupString return a string based on the DescGroupFuncID +func (s *Diablo2Stat) DescGroupString(a ...interface{}) string { + if s.record.DescGroupFuncID < 0 { + return "" + } + + format := "" + for range a { + format += "%s " + } + + format = strings.Trim(format, " ") + + return fmt.Sprintf(format, a...) +} diff --git a/d2core/d2stats/stat_test.go b/d2core/d2stats/diablo2stats/stat_test.go similarity index 70% rename from d2core/d2stats/stat_test.go rename to d2core/d2stats/diablo2stats/stat_test.go index 2ed554bc..5a5c2d5b 100644 --- a/d2core/d2stats/stat_test.go +++ b/d2core/d2stats/diablo2stats/stat_test.go @@ -1,7 +1,8 @@ -package d2stats +package diablo2stats import ( "fmt" + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2stats" "testing" "github.com/OpenDiablo2/OpenDiablo2/d2common/d2data/d2datadict" @@ -10,7 +11,7 @@ import ( 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" + errFmt string = "%v:\n\tDescFnID: %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 @@ -261,7 +262,7 @@ func TestStat_InitMockData(t *testing.T) { func TestStat_Clone(t *testing.T) { r := d2datadict.ItemStatCosts["strength"] - s1 := CreateStat(r, 5) + s1 := NewStat(r, intVal(5)) s2 := s1.Clone() // make sure the stats are distinct @@ -270,109 +271,109 @@ func TestStat_Clone(t *testing.T) { } // make sure the stat values are unique - vs1, vs2 := s1.Values, s2.Values + 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] + s2.Values()[0].SetInt(6) + v1, v2 := s1.Values()[0].Int(), s2.Values()[0].Int() // make sure the value ranges are distinct if v1 == v2 { - t.Errorf("stat value ranges should not be equal") + t.Errorf("clones should not share stat values") } } func TestStat_Descriptions(t *testing.T) { tests := []struct { recordKey string - vals []int + vals []d2stats.StatValue expect string }{ // DescFn1 - {"strength", []int{31}, "+31 to Strength"}, - {"hpregen", []int{20}, "Replenish Life +20"}, - {"hpregen", []int{-8}, "Drain Life -8"}, + {"strength", []d2stats.StatValue{intVal(31)}, "+31 to Strength"}, + {"hpregen", []d2stats.StatValue{intVal(20)}, "Replenish Life +20"}, + {"hpregen", []d2stats.StatValue{intVal(-8)}, "Drain Life -8"}, // DescFn2 - {"toblock", []int{16}, "+16% Increased Chance of Blocking"}, - {"item_absorblight_percent", []int{10}, "Lightning Absorb +10%"}, + {"toblock", []d2stats.StatValue{intVal(16)}, "+16% Increased Chance of Blocking"}, + {"item_absorblight_percent", []d2stats.StatValue{intVal(10)}, "Lightning Absorb +10%"}, // DescFn3 - {"normal_damage_reduction", []int{25}, "Damage Reduced by 25"}, - {"item_restinpeace", []int{25}, "Slain Monsters Rest in Peace"}, + {"normal_damage_reduction", []d2stats.StatValue{intVal(25)}, "Damage Reduced by 25"}, + {"item_restinpeace", []d2stats.StatValue{intVal(25)}, "Slain Monsters Rest in Peace"}, // DescFn4 - {"poisonresist", []int{25}, "Poison Resist +25%"}, - {"item_fastermovevelocity", []int{25}, "+25% Faster Run/Walk"}, + {"poisonresist", []d2stats.StatValue{intVal(25)}, "Poison Resist +25%"}, + {"item_fastermovevelocity", []d2stats.StatValue{intVal(25)}, "+25% Faster Run/Walk"}, // DescFn5 - {"item_howl", []int{25}, "Hit Causes Monster to Flee 25%"}, + {"item_howl", []d2stats.StatValue{intVal(25)}, "Hit Causes Monster to Flee 25%"}, // DescFn6 - {"item_hp_perlevel", []int{25}, "+25 to Life (Based on Character Level)"}, + {"item_hp_perlevel", []d2stats.StatValue{intVal(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 (" + + {"item_resist_ltng_perlevel", []d2stats.StatValue{intVal(25)}, "Lightning Resist +25% (Based on Character Level)"}, + {"item_find_magic_perlevel", []d2stats.StatValue{intVal(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}, + {"item_armorpercent_perlevel", []d2stats.StatValue{intVal(25)}, "+25% Enhanced Defense (Based on Character Level)"}, + {"item_regenstamina_perlevel", []d2stats.StatValue{intVal(25)}, "Heal Stamina Plus +25% (Based on Character Level)"}, // DescFn9 - {"item_thorns_perlevel", []int{25}, "Attacker Takes Damage of 25 (Based on Character Level)"}, + {"item_thorns_perlevel", []d2stats.StatValue{intVal(25)}, "Attacker Takes Damage of 25 (Based on Character Level)"}, // DescFn11 - {"item_replenish_durability", []int{2}, "Repairs 2 durability per second"}, + {"item_replenish_durability", []d2stats.StatValue{intVal(2)}, "Repairs 2 durability per second"}, // DescFn12 - {"item_stupidity", []int{5}, "Hit Blinds Target +5"}, + {"item_stupidity", []d2stats.StatValue{intVal(5)}, "Hit Blinds Target +5"}, // DescFn13 - {"item_addclassskills", []int{5, 3}, "+5 to Paladin Skill Levels"}, + {"item_addclassskills", []d2stats.StatValue{intVal(5), intVal(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)"}, + {"item_addskill_tab", []d2stats.StatValue{intVal(5), intVal(3), intVal(0)}, "+5 to Combat Skills (Paladin Only)"}, + {"item_addskill_tab", []d2stats.StatValue{intVal(5), intVal(3), intVal(1)}, "+5 to Offensive Auras (Paladin Only)"}, + {"item_addskill_tab", []d2stats.StatValue{intVal(5), intVal(3), intVal(2)}, "+5 to Defensive Auras (Paladin Only)"}, // DescFn15 - {"item_skillonattack", []int{5, 7, 64}, "5% Chance to cast level 7 Frozen Orb on attack"}, + {"item_skillonattack", []d2stats.StatValue{intVal(5), intVal(7), intVal(64)}, "5% Chance to cast level 7 Frozen Orb on attack"}, // DescFn16 - {"item_aura", []int{3, 37}, "Level 3 Warmth Aura When Equipped"}, + {"item_aura", []d2stats.StatValue{intVal(3), intVal(37)}, "Level 3 Warmth Aura When Equipped"}, // DescFn20 - {"item_fractionaltargetac", []int{-25}, "-25% Target Defense"}, + {"item_fractionaltargetac", []d2stats.StatValue{intVal(-25)}, "-25% Target Defense"}, // DescFn22 - {"attack_vs_montype", []int{25, 40}, "25% to Attack Rating versus Specter"}, + {"attack_vs_montype", []d2stats.StatValue{intVal(25), intVal(40)}, "25% to Attack Rating versus Specter"}, // DescFn23 - {"item_reanimate", []int{25, 40}, "25% Reanimate as: Specter"}, + {"item_reanimate", []d2stats.StatValue{intVal(25), intVal(40)}, "25% Reanimate as: Specter"}, // DescFn24 - {"item_charged_skill", []int{25, 64, 20, 19}, "Level 25 Frozen Orb (19/20 Charges)"}, + {"item_charged_skill", []d2stats.StatValue{intVal(25), intVal(64), intVal(20), intVal(19)}, "Level 25 Frozen Orb (19/20 Charges)"}, // DescFn27 - {"item_singleskill", []int{25, 64, 3}, "+25 to Frozen Orb (Paladin Only)"}, + {"item_singleskill", []d2stats.StatValue{intVal(25), intVal(64), intVal(3)}, "+25 to Frozen Orb (Paladin Only)"}, // DescFn28 - {"item_nonclassskill", []int{25, 64}, "+25 to Frozen Orb"}, + {"item_nonclassskill", []d2stats.StatValue{intVal(25), intVal(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...) + stat := NewStat(record, test.vals...) - if got := stat.Description(); got != expect { - t.Errorf(errFmt, errStr, test.recordKey, test.vals, expect, got) + if got := stat.String(); got != expect { + t.Errorf(errFmt, errStr, record.DescFnID, 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) diff --git a/d2core/d2stats/diablo2stats/stat_value.go b/d2core/d2stats/diablo2stats/stat_value.go new file mode 100644 index 00000000..a8041b8a --- /dev/null +++ b/d2core/d2stats/diablo2stats/stat_value.go @@ -0,0 +1,76 @@ +package diablo2stats + +import ( + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2stats" +) + +// static check that Diablo2StatValue implements StatValue +var _ d2stats.StatValue = &Diablo2StatValue{} + +// Diablo2StatValue is a diablo 2 implementation of a stat value +type Diablo2StatValue struct { + number float64 + _stringer func(d2stats.StatValue) string + _type d2stats.StatValueType +} + +// Type returns the stat value type +func (sv *Diablo2StatValue) Type() d2stats.StatValueType { + return sv._type +} + +// Clone returns a deep copy of the stat value +func (sv Diablo2StatValue) Clone() d2stats.StatValue { + clone := &Diablo2StatValue{} + + switch sv._type { + case d2stats.StatValueInt: + clone.SetInt(sv.Int()) + case d2stats.StatValueFloat: + clone.SetFloat(sv.Float()) + } + + clone._stringer = sv._stringer + + return clone +} + +// Int returns the integer version of the stat value +func (sv *Diablo2StatValue) Int() int { + return int(sv.number) +} + +// String returns a string version of the value +func (sv *Diablo2StatValue) String() string { + return sv._stringer(sv) +} + +// Float returns a float64 version of the value +func (sv *Diablo2StatValue) Float() float64 { + return sv.number +} + +// SetInt sets the stat value using an int +func (sv *Diablo2StatValue) SetInt(i int) d2stats.StatValue { + sv.number = float64(i) + + return sv +} + +// SetFloat sets the stat value using a float64 +func (sv *Diablo2StatValue) SetFloat(f float64) d2stats.StatValue { + sv.number = f + + return sv +} + +// Stringer returns the string evaluation function +func (sv *Diablo2StatValue) Stringer() func(d2stats.StatValue) string { + return sv._stringer +} + +// SetStringer sets the string evaluation function +func (sv *Diablo2StatValue) SetStringer(f func(d2stats.StatValue) string) d2stats.StatValue { + sv._stringer = f + return sv +} diff --git a/d2core/d2stats/diablo2stats/stat_value_stringers.go b/d2core/d2stats/diablo2stats/stat_value_stringers.go new file mode 100644 index 00000000..36b313d3 --- /dev/null +++ b/d2core/d2stats/diablo2stats/stat_value_stringers.go @@ -0,0 +1,83 @@ +package diablo2stats + +import ( + "fmt" + + "github.com/OpenDiablo2/OpenDiablo2/d2common" + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2data/d2datadict" + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2enum" + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2stats" +) + +const ( + monsterNotFound = "{Monster not found!}" +) + +func getHeroMap() map[int]d2enum.Hero { + return 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, + } +} + +func stringerUnsignedInt(sv d2stats.StatValue) string { + return fmt.Sprintf("%d", sv.Int()) +} + +func stringerUnsignedFloat(sv d2stats.StatValue) string { + return fmt.Sprintf("%.2f", sv.Float()) +} + +func stringerEmpty(_ d2stats.StatValue) string { + return "" +} + +func stringerIntSigned(sv d2stats.StatValue) string { + return fmt.Sprintf("%+d", sv.Int()) +} + +func stringerIntPercentageSigned(sv d2stats.StatValue) string { + return fmt.Sprintf("%+d%%", sv.Int()) +} + +func stringerIntPercentageUnsigned(sv d2stats.StatValue) string { + return fmt.Sprintf("%d%%", sv.Int()) +} + +func stringerClassAllSkills(sv d2stats.StatValue) string { + heroIndex := sv.Int() + + heroMap := getHeroMap() + classRecord := d2datadict.CharStats[heroMap[heroIndex]] + + return d2common.TranslateString(classRecord.SkillStrAll) +} + +func stringerClassOnly(sv d2stats.StatValue) string { + heroMap := getHeroMap() + heroIndex := sv.Int() + classRecord := d2datadict.CharStats[heroMap[heroIndex]] + classOnlyKey := classRecord.SkillStrClassOnly + + return d2common.TranslateString(classOnlyKey) +} + +func stringerSkillName(sv d2stats.StatValue) string { + skillRecord := d2datadict.SkillDetails[sv.Int()] + return skillRecord.Skill +} + +func stringerMonsterName(sv d2stats.StatValue) string { + for key := range d2datadict.MonStats { + if d2datadict.MonStats[key].Id == sv.Int() { + return d2datadict.MonStats[key].NameString + } + } + + return monsterNotFound +} diff --git a/d2core/d2stats/diablo2stats/statlist.go b/d2core/d2stats/diablo2stats/statlist.go new file mode 100644 index 00000000..32e9fefd --- /dev/null +++ b/d2core/d2stats/diablo2stats/statlist.go @@ -0,0 +1,120 @@ +package diablo2stats + +import ( + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2stats" +) + +// static check that Diablo2Stat implements Stat +var _ d2stats.StatList = &Diablo2StatList{} + +// Diablo2StatList is a diablo 2 implementation of a stat list +type Diablo2StatList struct { + stats []d2stats.Stat +} + +// Index returns a stat given with the given index +func (sl *Diablo2StatList) Index(idx int) d2stats.Stat { + if idx < 0 || idx > len(sl.stats) { + return nil + } + + return sl.stats[idx] +} + +// Stats returns a slice of stats +func (sl *Diablo2StatList) Stats() []d2stats.Stat { + return sl.stats +} + +// SetStats sets the stats, given a slice of stats +func (sl *Diablo2StatList) SetStats(stats []d2stats.Stat) d2stats.StatList { + sl.stats = stats + return sl +} + +// Pop removes the last stat from the stat list +func (sl *Diablo2StatList) Pop() d2stats.Stat { + num := len(sl.stats) + if num < 1 { + return nil + } + + idx := num - 1 + last := sl.stats[idx] + sl.stats = sl.stats[:idx] + + return last +} + +// Push adds a stat at the end of the stat list +func (sl *Diablo2StatList) Push(stat d2stats.Stat) d2stats.StatList { + sl.stats = append(sl.stats, stat) + + return sl +} + +// Clone returns a deep copy of the stat list +func (sl *Diablo2StatList) Clone() d2stats.StatList { + clone := &Diablo2StatList{} + clone.stats = make([]d2stats.Stat, len(sl.stats)) + + for idx := range sl.stats { + if stat := sl.Index(idx); stat != nil { + clone.stats[idx] = stat.Clone() + } + } + + return clone +} + +// ReduceStats combines like stats (does not alter this stat list, returns clone) +func (sl *Diablo2StatList) ReduceStats() d2stats.StatList { + clone := sl.Clone() + reduction := make([]d2stats.Stat, 0) + + // for quick lookups + lookup := make(map[string]int) + + for len(clone.Stats()) > 0 { + stat := clone.Pop() + + // if we find it in the lookup, immediately try to combine + // if it doesn't combine, we append to the reduction + if idx, found := lookup[stat.Name()]; found { + if result, err := reduction[idx].Combine(stat); err == nil { + reduction[idx] = result + continue + } + } + + // we didnt find it in the lookup, so we will try to combine with other stats + for idx := range reduction { + if _, err := reduction[idx].Combine(stat); err == nil { + continue + } + } + + lookup[stat.Name()] = len(lookup) + + reduction = append(reduction, stat) + } + + return clone.SetStats(reduction) +} + +// RemoveStatAtIndex removes the stat from the stat list, returns the stat +func (sl *Diablo2StatList) RemoveStatAtIndex(idx int) d2stats.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 +} + +// AppendStatList adds the stats from the other stat list to this stat list +func (sl *Diablo2StatList) AppendStatList(other d2stats.StatList) d2stats.StatList { + sl.stats = append(sl.stats, other.Stats()...) + + return sl +} diff --git a/d2core/d2stats/diablo2stats/statlist_test.go b/d2core/d2stats/diablo2stats/statlist_test.go new file mode 100644 index 00000000..8bc50e61 --- /dev/null +++ b/d2core/d2stats/diablo2stats/statlist_test.go @@ -0,0 +1,106 @@ +package diablo2stats + +import ( + "testing" + + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2data/d2datadict" + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2stats" +) + +func TestDiablo2StatList_Index(t *testing.T) { + record := d2datadict.ItemStatCosts["strength"] + strength := NewStat(record, intVal(10)) + + list1 := &Diablo2StatList{stats: []d2stats.Stat{strength}} + if list1.Index(0) != strength { + t.Error("list should contain a stat") + } +} + +func TestStatList_Clone(t *testing.T) { + record := d2datadict.ItemStatCosts["strength"] + strength := NewStat(record, intVal(10)) + + list1 := &Diablo2StatList{} + list1.Push(strength) + + list2 := list1.Clone() + str1 := list1.Index(0).String() + str2 := list2.Index(0).String() + + if str1 != str2 { + t.Errorf("Stats of cloned stat list should be identitcal") + } + + list2.Index(0).Values()[0].SetInt(0) + + if list1.Index(0).String() == list2.Index(0).String() { + 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 := []d2stats.Stat{ + NewStat(records[0], intVal(1)), + NewStat(records[0], intVal(1)), + NewStat(records[0], intVal(1)), + NewStat(records[0], intVal(1)), + } + + list := NewStatList(stats...) + reduction := list.ReduceStats() + + if len(reduction.Stats()) != 1 || reduction.Index(0).String() != "+4 to Strength" { + t.Errorf("Diablo2Stat reduction failed") + } + + stats = []d2stats.Stat{ + NewStat(records[0], intVal(1)), + NewStat(records[1], intVal(1)), + NewStat(records[2], intVal(1)), + NewStat(records[3], intVal(1)), + } + + list = NewStatList(stats...) + reduction = list.ReduceStats() + + if len(reduction.Stats()) != 4 { + t.Errorf("Diablo2Stat reduction failed") + } +} + +func TestStatList_Append(t *testing.T) { + records := []*d2datadict.ItemStatCostRecord{ + d2datadict.ItemStatCosts["strength"], + d2datadict.ItemStatCosts["energy"], + d2datadict.ItemStatCosts["dexterity"], + d2datadict.ItemStatCosts["vitality"], + } + + list1 := &Diablo2StatList{ + []d2stats.Stat{ + NewStat(records[0], intVal(1)), + NewStat(records[1], intVal(1)), + NewStat(records[2], intVal(1)), + NewStat(records[3], intVal(1)), + }, + } + list2 := list1.Clone() + + list3 := list1.AppendStatList(list2) + + if len(list3.Stats()) != 8 { + t.Errorf("Diablo2Stat append failed") + } + + if len(list3.ReduceStats().Stats()) != 4 { + t.Errorf("Diablo2Stat append failed") + } +} diff --git a/d2core/d2stats/stat.go b/d2core/d2stats/stat.go index 905597b9..8a8a40ae 100644 --- a/d2core/d2stats/stat.go +++ b/d2core/d2stats/stat.go @@ -1,724 +1,14 @@ 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 +// Stat a generic interface for a stat. It is something which can be +// combined with other stats, holds one or more values, and handles the +// way that it is printed as a string +type Stat interface { + Name() string + Clone() Stat + Copy(Stat) Stat + Combine(Stat) (combined Stat, err error) + String() string + Values() []StatValue + SetValues(...StatValue) } diff --git a/d2core/d2stats/stat_list.go b/d2core/d2stats/stat_list.go new file mode 100644 index 00000000..cd9bacbe --- /dev/null +++ b/d2core/d2stats/stat_list.go @@ -0,0 +1,16 @@ +package d2stats + +// StatList is useful for reducing stats. +// They provide a context for stats to alter other stats or infer values +// during stat assignment/calculation +type StatList interface { + Index(idx int) Stat + Stats() []Stat + SetStats([]Stat) StatList + Clone() StatList + ReduceStats() StatList + RemoveStatAtIndex(idx int) Stat + AppendStatList(other StatList) StatList + Pop() Stat + Push(Stat) StatList +} diff --git a/d2core/d2stats/stat_value.go b/d2core/d2stats/stat_value.go new file mode 100644 index 00000000..289f18ff --- /dev/null +++ b/d2core/d2stats/stat_value.go @@ -0,0 +1,27 @@ +package d2stats + +// StatValueType is a value type for a stat value +type StatValueType int + +// Stat value types +const ( + StatValueInt StatValueType = iota + StatValueFloat +) + +// StatValue is something that can have both integer and float +// number components, as well as a means of retrieving a string for +// its values. +type StatValue interface { + Type() StatValueType + Clone() StatValue + + SetInt(int) StatValue + SetFloat(float64) StatValue + SetStringer(func(StatValue) string) StatValue + + Int() int + Float() float64 + String() string + Stringer() func(StatValue) string +} diff --git a/d2core/d2stats/statlist.go b/d2core/d2stats/statlist.go deleted file mode 100644 index 45c9ca84..00000000 --- a/d2core/d2stats/statlist.go +++ /dev/null @@ -1,82 +0,0 @@ -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 deleted file mode 100644 index d6775ae1..00000000 --- a/d2core/d2stats/statlist_test.go +++ /dev/null @@ -1,90 +0,0 @@ -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") - } -}