diff --git a/d2app/app.go b/d2app/app.go index 03b54a15..8f113646 100644 --- a/d2app/app.go +++ b/d2app/app.go @@ -270,6 +270,8 @@ func (a *App) loadDataDict() error { entry.loader(data) } + d2datadict.LoadItemEquivalencies() // depends on ItemCommon and ItemTypes + return nil } diff --git a/d2common/d2data/d2datadict/item_affix.go b/d2common/d2data/d2datadict/item_affix.go index e0b6637f..a17574b9 100644 --- a/d2common/d2data/d2datadict/item_affix.go +++ b/d2common/d2data/d2datadict/item_affix.go @@ -9,9 +9,11 @@ import ( ) // MagicPrefix stores all of the magic prefix records -var MagicPrefix []*ItemAffixCommonRecord //nolint:gochecknoglobals // Currently global by design +var MagicPrefix map[string]*ItemAffixCommonRecord //nolint:gochecknoglobals // Currently global by +// design // MagicSuffix stores all of the magic suffix records -var MagicSuffix []*ItemAffixCommonRecord //nolint:gochecknoglobals // Currently global by design +var MagicSuffix map[string]*ItemAffixCommonRecord //nolint:gochecknoglobals // Currently global by +// design // LoadMagicPrefix loads MagicPrefix.txt func LoadMagicPrefix(file []byte) { @@ -48,7 +50,11 @@ func getAffixString(t1 d2enum.ItemAffixSuperType, t2 d2enum.ItemAffixSubType) st return name } -func loadDictionary(file []byte, superType d2enum.ItemAffixSuperType, subType d2enum.ItemAffixSubType) []*ItemAffixCommonRecord { +func loadDictionary( + file []byte, + superType d2enum.ItemAffixSuperType, + subType d2enum.ItemAffixSubType, +) map[string]*ItemAffixCommonRecord { d := d2common.LoadDataDictionary(file) records := createItemAffixRecords(d, superType, subType) name := getAffixString(superType, subType) @@ -57,8 +63,12 @@ func loadDictionary(file []byte, superType d2enum.ItemAffixSuperType, subType d2 return records } -func createItemAffixRecords(d *d2common.DataDictionary, superType d2enum.ItemAffixSuperType, subType d2enum.ItemAffixSubType) []*ItemAffixCommonRecord { - records := make([]*ItemAffixCommonRecord, 0) +func createItemAffixRecords( + d *d2common.DataDictionary, + superType d2enum.ItemAffixSuperType, + subType d2enum.ItemAffixSubType, +) map[string]*ItemAffixCommonRecord { + records := make(map[string]*ItemAffixCommonRecord) for d.Next() { affix := &ItemAffixCommonRecord{ @@ -82,7 +92,7 @@ func createItemAffixRecords(d *d2common.DataDictionary, superType d2enum.ItemAff PriceScale: d.Number("multiply"), } - // modifiers (Property references with parameters to be eval'd) + // modifiers (Code references with parameters to be eval'd) for i := 1; i <= 3; i++ { codeKey := fmt.Sprintf("mod%dcode", i) paramKey := fmt.Sprintf("mod%dparam", i) @@ -125,7 +135,7 @@ func createItemAffixRecords(d *d2common.DataDictionary, superType d2enum.ItemAff group := ItemAffixGroups[affix.GroupID] group.addMember(affix) - records = append(records, affix) + records[affix.Name] = affix } if d.Err != nil { panic(d.Err) diff --git a/d2common/d2data/d2datadict/item_common.go b/d2common/d2data/d2datadict/item_common.go index bc6ec34d..f81e4fb2 100644 --- a/d2common/d2data/d2datadict/item_common.go +++ b/d2common/d2data/d2datadict/item_common.go @@ -177,6 +177,10 @@ func LoadCommonItems(file []byte, source d2enum.InventoryItemType) map[string]*I } rec := createCommonItemRecord(line, mapping, source) + if rec.Name == "Expansion" { + continue + } + items[rec.Code] = &rec CommonItems[rec.Code] = &rec } @@ -392,3 +396,4 @@ func createItemUsageStats(r *[]string, mapping map[string]int) [3]ItemUsageStat return result } + diff --git a/d2common/d2data/d2datadict/item_types.go b/d2common/d2data/d2datadict/item_types.go index 024b6960..08cb7323 100644 --- a/d2common/d2data/d2datadict/item_types.go +++ b/d2common/d2data/d2datadict/item_types.go @@ -246,7 +246,7 @@ func LoadItemTypes(file []byte) { StorePage: d.String("StorePage"), } - ItemTypes[itemType.Name] = itemType + ItemTypes[itemType.Code] = itemType } if d.Err != nil { @@ -255,3 +255,101 @@ func LoadItemTypes(file []byte) { log.Printf("Loaded %d ItemType records", len(ItemTypes)) } + +// ItemEquivalenciesByTypeCode describes item equivalencies for ItemTypes +var ItemEquivalenciesByTypeCode map[string][]*ItemCommonRecord + +// LoadItemEquivalencies loads a map of ItemType string codes to slices of ItemCommonRecord pointers +func LoadItemEquivalencies() { + ItemEquivalenciesByTypeCode = make(map[string][]*ItemCommonRecord) + + makeEmptyEquivalencyMaps() + + for icrCode := range CommonItems { + commonItem := CommonItems[icrCode] + updateEquivalencies(commonItem, ItemTypes[commonItem.Type], nil) + + if commonItem.Type2 != "" { // some items (like gems) have a secondary type + updateEquivalencies(commonItem, ItemTypes[commonItem.Type2], nil) + } + } +} + +func makeEmptyEquivalencyMaps() { + for typeCode := range ItemTypes { + code := []string{ + typeCode, + ItemTypes[typeCode].Equiv1, + ItemTypes[typeCode].Equiv2, + } + + for _, str := range code { + if str == "" { + continue + } + + if ItemEquivalenciesByTypeCode[str] == nil { + ItemEquivalenciesByTypeCode[str] = make([]*ItemCommonRecord, 0) + } + } + } +} + +func updateEquivalencies(icr *ItemCommonRecord, itemType *ItemTypeRecord, checked []string) { + if itemType.Code == "" { + return + } + + if checked == nil { + checked = make([]string, 0) + } + + checked = append(checked, itemType.Code) + + if !itemEquivPresent(icr, ItemEquivalenciesByTypeCode[itemType.Code]) { + ItemEquivalenciesByTypeCode[itemType.Code] = append(ItemEquivalenciesByTypeCode[itemType.Code], icr) + } + + if itemType.Equiv1 != "" { + updateEquivalencies(icr, ItemTypes[itemType.Equiv1], checked) + } + + if itemType.Equiv2 != "" { + updateEquivalencies(icr, ItemTypes[itemType.Equiv2], checked) + } +} + +func itemEquivPresent(icr *ItemCommonRecord, list []*ItemCommonRecord) bool { + for idx := range list { + if list[idx] == icr { + return true + } + } + + return false +} + +var itemCommonTypeLookup map[*ItemCommonRecord][]string + +func FindEquivalentTypesByItemCommonRecord(icr *ItemCommonRecord) []string { + if itemCommonTypeLookup == nil { + itemCommonTypeLookup = make(map[*ItemCommonRecord][]string) + } + + // the first lookup generates the lookup table entry, next time will just use the table + if itemCommonTypeLookup[icr] == nil { + itemCommonTypeLookup[icr] = make([]string, 0) + + for code := range ItemEquivalenciesByTypeCode { + icrList := ItemEquivalenciesByTypeCode[code] + for idx := range icrList { + if icr == icrList[idx] { + itemCommonTypeLookup[icr] = append(itemCommonTypeLookup[icr], code) + break + } + } + } + } + + return itemCommonTypeLookup[icr] +} diff --git a/d2common/d2data/d2datadict/properties.go b/d2common/d2data/d2datadict/properties.go index 77604ac4..3f1b882f 100644 --- a/d2common/d2data/d2datadict/properties.go +++ b/d2common/d2data/d2datadict/properties.go @@ -1,24 +1,23 @@ package d2datadict import ( -"log" + "log" -"github.com/OpenDiablo2/OpenDiablo2/d2common" + "github.com/OpenDiablo2/OpenDiablo2/d2common" ) -type stat struct { - SetID int - Value int +type PropertyStatRecord struct { + SetID int + Value int FunctionID int - StatCode string + StatCode string } -// PropertyRecord is a representation of a single row of gems.txt -// it describes the properties of socketable items +// PropertyRecord is a representation of a single row of properties.txt type PropertyRecord struct { - Code string + Code string Active string - Stats [7]*stat + Stats [7]*PropertyStatRecord } // Properties stores all of the PropertyRecords @@ -32,50 +31,50 @@ func LoadProperties(file []byte) { d := d2common.LoadDataDictionary(file) for d.Next() { prop := &PropertyRecord{ - Code: d.String("code"), + Code: d.String("code"), Active: d.String("*done"), - Stats: [7]*stat{ + Stats: [7]*PropertyStatRecord{ { - SetID: d.Number("set1"), - Value: d.Number("val1"), + SetID: d.Number("set1"), + Value: d.Number("val1"), FunctionID: d.Number("func1"), - StatCode: d.String("stat1"), + StatCode: d.String("stat1"), }, { - SetID: d.Number("set2"), - Value: d.Number("val2"), + SetID: d.Number("set2"), + Value: d.Number("val2"), FunctionID: d.Number("func2"), - StatCode: d.String("stat2"), + StatCode: d.String("stat2"), }, { - SetID: d.Number("set3"), - Value: d.Number("val3"), + SetID: d.Number("set3"), + Value: d.Number("val3"), FunctionID: d.Number("func3"), - StatCode: d.String("stat3"), + StatCode: d.String("stat3"), }, { - SetID: d.Number("set4"), - Value: d.Number("val4"), + SetID: d.Number("set4"), + Value: d.Number("val4"), FunctionID: d.Number("func4"), - StatCode: d.String("stat4"), + StatCode: d.String("stat4"), }, { - SetID: d.Number("set5"), - Value: d.Number("val5"), + SetID: d.Number("set5"), + Value: d.Number("val5"), FunctionID: d.Number("func5"), - StatCode: d.String("stat5"), + StatCode: d.String("stat5"), }, { - SetID: d.Number("set6"), - Value: d.Number("val6"), + SetID: d.Number("set6"), + Value: d.Number("val6"), FunctionID: d.Number("func6"), - StatCode: d.String("stat6"), + StatCode: d.String("stat6"), }, { - SetID: d.Number("set7"), - Value: d.Number("val7"), + SetID: d.Number("set7"), + Value: d.Number("val7"), FunctionID: d.Number("func7"), - StatCode: d.String("stat7"), + StatCode: d.String("stat7"), }, }, } @@ -88,4 +87,3 @@ func LoadProperties(file []byte) { log.Printf("Loaded %d Property records", len(Properties)) } - diff --git a/d2common/d2data/d2datadict/set_items.go b/d2common/d2data/d2datadict/set_items.go index 51c390dc..d7731667 100644 --- a/d2common/d2data/d2datadict/set_items.go +++ b/d2common/d2data/d2datadict/set_items.go @@ -1,15 +1,31 @@ package d2datadict import ( + "fmt" "github.com/OpenDiablo2/OpenDiablo2/d2common" "log" ) +const ( + numPropertiesOnSetItem = 9 + numBonusPropertiesOnSetItem = 5 + bonusToken1 = "a" + bonusToken2 = "b" + propCodeFmt = "prop%d" + propParamFmt = "par%d" + propMinFmt = "min%d" + propMaxFmt = "max%d" + bonusCodeFmt = "aprop%d%s" + bonusParamFmt = "apar%d%s" + bonusMinFmt = "amin%d%s" + bonusMaxFmt = "amax%d%s" +) + // SetItemRecord represents a set item type SetItemRecord struct { - // StringTableKey (index) + // SetItemKey (index) // string key to item's name in a .tbl file - StringTableKey string + SetItemKey string // SetKey (set) // string key to the index field in Sets.txt - the set the item is a part of. @@ -90,63 +106,37 @@ type SetItemRecord struct { // on a set item. See the appendix for further details about this field's effects. AddFn int - // Prop (prop1 to prop9) - // An ID pointer of a property from Properties.txt, - // these columns control each of the nine different fixed ( - // blue) modifiers a set item can grant you at most. - Prop [9]string + // Properties are a propert code, parameter, min, max for generating an item propert + Properties [numPropertiesOnSetItem]*SetItemProperty - // Par (par1 to par9) - // The parameter passed on to the associated property, this is used to pass skill IDs, state IDs, - // monster IDs, montype IDs and the like on to the properties that require them, - // these fields support calculations. - Par [9]int + // SetPropertiesLevel1 is the first version of bonus properties for the set + SetPropertiesLevel1 [numBonusPropertiesOnSetItem]*SetItemProperty - // Min, Max (min1 to min9, max1 to max9) - // Minimum value to assign to the associated (blue) property. - // Certain properties have special interpretations based on stat encoding (e.g. - // chance-to-cast and charged skills). See the File Guide for Properties.txt and ItemStatCost. - // txt for further details. - Min [9]int - Max [9]int + // SetPropertiesLevel2 is the second version of bonus properties for the set + SetPropertiesLevel2 [numBonusPropertiesOnSetItem]*SetItemProperty +} - // APropA, APropB (aprop1a,aprop1b to aprop5a,aprop5b) - // An ID pointer of a property from Properties.txt, - // these columns control each of the five pairs of different variable ( - // green) modifiers a set item can grant you at most. - APropA [5]string - APropB [5]string - - // AParA, AParB (apar1a,apar1b to apar5a,apar5b) - // The parameter passed on to the associated property, this is used to pass skill IDs, state IDs, - // monster IDs, montype IDs and the like on to the properties that require them, - // these fields support calculations. - AParA [5]int - AParB [5]int - - // AMinA, AMinB, AMaxA, AMaxB (amin1a,amin1b to amin5a,amin5b) - // Minimum value to assign to the associated property. - // Certain properties have special interpretations based on stat encoding (e.g. - // chance-to-cast and charged skills). See the File Guide for Properties.txt and ItemStatCost. - // txt for further details. - AMinA [5]int - AMinB [5]int - AMaxA [5]int - AMaxB [5]int +// SetItemProperty is describes a property of a set item +type SetItemProperty struct { + Code string + Parameter string // depending on the property, this may be an int (usually), or a string + Min int + Max int } // SetItems holds all of the SetItemRecords -var SetItems []*SetItemRecord //nolint:gochecknoglobals // Currently global by design, only written once +var SetItems map[string]*SetItemRecord //nolint:gochecknoglobals // Currently global by design, +// only written once // LoadSetItems loads all of the SetItemRecords from SetItems.txt func LoadSetItems(file []byte) { - SetItems = make([]*SetItemRecord, 0) + SetItems = make(map[string]*SetItemRecord) d := d2common.LoadDataDictionary(file) for d.Next() { record := &SetItemRecord{ - StringTableKey: d.String("index"), + SetItemKey: d.String("index"), SetKey: d.String("set"), ItemCode: d.String("item"), Rarity: d.Number("rarity"), @@ -162,109 +152,47 @@ func LoadSetItems(file []byte) { CostMult: d.Number("cost mult"), CostAdd: d.Number("cost add"), AddFn: d.Number("add func"), - Prop: [9]string{ - d.String("prop1"), - d.String("prop2"), - d.String("prop3"), - d.String("prop4"), - d.String("prop5"), - d.String("prop6"), - d.String("prop7"), - d.String("prop8"), - d.String("prop9"), - }, - Par: [9]int{ - d.Number("par1"), - d.Number("par2"), - d.Number("par3"), - d.Number("par4"), - d.Number("par5"), - d.Number("par6"), - d.Number("par7"), - d.Number("par8"), - d.Number("par9"), - }, - Min: [9]int{ - d.Number("min1"), - d.Number("min2"), - d.Number("min3"), - d.Number("min4"), - d.Number("min5"), - d.Number("min6"), - d.Number("min7"), - d.Number("min8"), - d.Number("min9"), - }, - Max: [9]int{ - d.Number("max1"), - d.Number("max2"), - d.Number("max3"), - d.Number("max4"), - d.Number("max5"), - d.Number("max6"), - d.Number("max7"), - d.Number("max8"), - d.Number("max9"), - }, - APropA: [5]string{ - d.String("aprop1a"), - d.String("aprop2a"), - d.String("aprop3a"), - d.String("aprop4a"), - d.String("aprop5a"), - }, - APropB: [5]string{ - d.String("aprop1b"), - d.String("aprop2b"), - d.String("aprop3b"), - d.String("aprop4b"), - d.String("aprop5b"), - }, - AParA: [5]int{ - d.Number("apar1a"), - d.Number("apar2a"), - d.Number("apar3a"), - d.Number("apar4a"), - d.Number("apar5a"), - }, - AParB: [5]int{ - d.Number("apar1b"), - d.Number("apar2b"), - d.Number("apar3b"), - d.Number("apar4b"), - d.Number("apar5b"), - }, - AMinA: [5]int{ - d.Number("amin1a"), - d.Number("amin2a"), - d.Number("amin3a"), - d.Number("amin4a"), - d.Number("amin5a"), - }, - AMinB: [5]int{ - d.Number("amin1b"), - d.Number("amin2b"), - d.Number("amin3b"), - d.Number("amin4b"), - d.Number("amin5b"), - }, - AMaxA: [5]int{ - d.Number("amax1a"), - d.Number("amax2a"), - d.Number("amax3a"), - d.Number("amax4a"), - d.Number("amax5a"), - }, - AMaxB: [5]int{ - d.Number("amax1b"), - d.Number("amax2b"), - d.Number("amax3b"), - d.Number("amax4b"), - d.Number("amax5b"), - }, + } - SetItems = append(SetItems, record) + // normal properties + props := [numPropertiesOnSetItem]*SetItemProperty{} + + for idx := 0; idx < numPropertiesOnSetItem; idx++ { + num := idx + 1 + props[idx] = &SetItemProperty{ + d.String(fmt.Sprintf(propCodeFmt, num)), + d.String(fmt.Sprintf(propParamFmt, num)), + d.Number(fmt.Sprintf(propMinFmt, num)), + d.Number(fmt.Sprintf(propMaxFmt, num)), + } + } + + // set bonus properties + bonus1 := [numBonusPropertiesOnSetItem]*SetItemProperty{} + bonus2 := [numBonusPropertiesOnSetItem]*SetItemProperty{} + + for idx := 0; idx < numBonusPropertiesOnSetItem; idx++ { + num := idx + 1 + + bonus1[idx] = &SetItemProperty{ + d.String(fmt.Sprintf(bonusCodeFmt, num, bonusToken1)), + d.String(fmt.Sprintf(bonusParamFmt, num, bonusToken1)), + d.Number(fmt.Sprintf(bonusMinFmt, num,bonusToken1)), + d.Number(fmt.Sprintf(bonusMaxFmt, num, bonusToken1)), + } + + bonus2[idx] = &SetItemProperty{ + d.String(fmt.Sprintf(bonusCodeFmt, num, bonusToken2)), + d.String(fmt.Sprintf(bonusParamFmt, num, bonusToken2)), + d.Number(fmt.Sprintf(bonusMinFmt, num,bonusToken2)), + d.Number(fmt.Sprintf(bonusMaxFmt, num, bonusToken2)), + } + } + + record.Properties = props + + SetItems[record.SetItemKey] = record } if d.Err != nil { diff --git a/d2common/d2data/d2datadict/treasure_class.go b/d2common/d2data/d2datadict/treasure_class.go index 00a1fa97..ea2847ca 100644 --- a/d2common/d2data/d2datadict/treasure_class.go +++ b/d2common/d2data/d2datadict/treasure_class.go @@ -2,15 +2,45 @@ package d2datadict import ( "fmt" - "log" - "github.com/OpenDiablo2/OpenDiablo2/d2common" + "log" ) const ( - numTreasures = 10 - treasureItemFmt = "Item%d" - treasureProbFmt = "Prob%d" + maxTreasuresPerRecord = 10 + treasureItemFmt = "Item%d" + treasureProbFmt = "Prob%d" +) + +// TreasureDropType indicates the drop type of the treasure +type TreasureDropType int + +const ( + // TreasureNone is default bad case, but nothing should have this + TreasureNone TreasureDropType = iota + + // TreasureGold indicates that the treasure drop type is for gold + TreasureGold + + // indicates that the drop type resolves directly to an ItemCommonRecord + TreasureWeapon + TreasureArmor + TreasureMisc + + // indicates that the code is for a dynamic item record, because the treasure code has + // and item level appended to it. this is for things like `armo63` or `weap24` which does not + // explicitly have an item record that matches this code, but we need to resolve this + TreasureWeaponDynamic + TreasureArmorDynamic + TreasureMiscDynamic +) + +const ( + GoldMultDropCodeStr string = "gld,mul=" + GoldDropCodeStr = "gld" + WeaponDropCodeStr = "weap" + ArmorDropCodeStr = "armo" + MiscDropCodeStr = "misc" ) // TreasureClassRecord represents a rule for item drops in diablo 2 @@ -28,12 +58,13 @@ type TreasureClassRecord struct { } // Treasure describes a treasure to drop -// the key is either a reference to an item, or to another treasure class +// the Name is either a reference to an item, or to another treasure class type Treasure struct { - Name string + Code string Probability int } +// TreasureClass contains all of the TreasureClassRecords var TreasureClass map[string]*TreasureClassRecord //nolint:gochecknoglobals // Currently global by design // LoadTreasureClassRecords loads treasure class records from TreasureClassEx.txt @@ -56,7 +87,11 @@ func LoadTreasureClassRecords(file []byte) { FreqNoDrop: d.Number("NoDrop"), } - for treasureIdx := 0; treasureIdx < numTreasures; treasureIdx++ { + if record.Name == "" { + continue + } + + for treasureIdx := 0; treasureIdx < maxTreasuresPerRecord; treasureIdx++ { treasureColumnKey := fmt.Sprintf(treasureItemFmt, treasureIdx+1) probColumnKey := fmt.Sprintf(treasureProbFmt, treasureIdx+1) @@ -68,16 +103,15 @@ func LoadTreasureClassRecords(file []byte) { prob := d.Number(probColumnKey) treasure := &Treasure{ - Name: treasureName, + Code: treasureName, Probability: prob, } if record.Treasures == nil { record.Treasures = []*Treasure{treasure} - continue + } else { + record.Treasures = append(record.Treasures, treasure) } - - record.Treasures = append(record.Treasures, treasure) } TreasureClass[record.Name] = record diff --git a/d2common/d2data/d2datadict/unique_items.go b/d2common/d2data/d2datadict/unique_items.go index 25e18c64..0adefd2f 100644 --- a/d2common/d2data/d2datadict/unique_items.go +++ b/d2common/d2data/d2datadict/unique_items.go @@ -42,8 +42,8 @@ type UniqueItemRecord struct { // UniqueItemProperty is describes a property of a unique item type UniqueItemProperty struct { - Property string - Parameter d2common.CalcString // depending on the property, this may be an int (usually), or a string + Code string + Parameter string // depending on the property, this may be an int (usually), or a string Min int Max int } @@ -105,8 +105,8 @@ func createUniqueItemRecord(r []string) UniqueItemRecord { func createUniqueItemProperty(r *[]string, inc func() int) UniqueItemProperty { result := UniqueItemProperty{ - Property: (*r)[inc()], - Parameter: d2common.CalcString((*r)[inc()]), + Code: (*r)[inc()], + Parameter: (*r)[inc()], Min: d2common.StringToInt(d2common.EmptyToZero((*r)[inc()])), Max: d2common.StringToInt(d2common.EmptyToZero((*r)[inc()])), } diff --git a/d2common/d2enum/equipped_slot.go b/d2common/d2enum/equipped_slot.go index fc2196b0..4e0d6e10 100644 --- a/d2common/d2enum/equipped_slot.go +++ b/d2common/d2enum/equipped_slot.go @@ -5,7 +5,8 @@ type EquippedSlot int // Equipped slot ID's const ( - EquippedSlotHead EquippedSlot = iota + 1 + EquippedSlotNone EquippedSlot = iota + EquippedSlotHead EquippedSlotTorso EquippedSlotLegs EquippedSlotRightArm diff --git a/d2core/d2item/context.go b/d2core/d2item/context.go new file mode 100644 index 00000000..d1e8d5d3 --- /dev/null +++ b/d2core/d2item/context.go @@ -0,0 +1,12 @@ +package d2item + +import "github.com/OpenDiablo2/OpenDiablo2/d2core/d2stats" + +// StatContext is anything which has a `StatList` method which yields a StatList. +// This is used for resolving stat dependencies for showing actual values, like +// stats that are based off of the current character level +type StatContext interface { + Equipper + BaseStatList() d2stats.StatList + StatList() d2stats.StatList +} diff --git a/d2core/d2item/diablo2item/diablo2item.go b/d2core/d2item/diablo2item/diablo2item.go new file mode 100644 index 00000000..98fc504d --- /dev/null +++ b/d2core/d2item/diablo2item/diablo2item.go @@ -0,0 +1,19 @@ +package diablo2item + +import "github.com/OpenDiablo2/OpenDiablo2/d2common/d2data/d2datadict" + +// NewProperty creates a property +func NewProperty(code string, values ...int) *Property { + record := d2datadict.Properties[code] + + if record == nil { + return nil + } + + result := &Property{ + record: record, + inputParams: values, + } + + return result.init() +} diff --git a/d2core/d2item/diablo2item/doc.go b/d2core/d2item/diablo2item/doc.go new file mode 100644 index 00000000..25a3b207 --- /dev/null +++ b/d2core/d2item/diablo2item/doc.go @@ -0,0 +1,3 @@ +// Package Item provides the Diablo 2 implementation of items for +// the OpenDiablo2 interfaces +package diablo2item diff --git a/d2core/d2item/diablo2item/item.go b/d2core/d2item/diablo2item/item.go new file mode 100644 index 00000000..9759cbfb --- /dev/null +++ b/d2core/d2item/diablo2item/item.go @@ -0,0 +1,735 @@ +package diablo2item + +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/d2item" + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2stats" + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2stats/diablo2stats" + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2ui" + "math/rand" +) + +// PropertyPool is used for separating properties by their source +type PropertyPool int + +// Property pools +const ( + PropertyPoolPrefix PropertyPool = iota + PropertyPoolSuffix + PropertyPoolUnique + PropertyPoolSetItem + PropertyPoolSet +) + +// for handling special cases +const ( + jewelItemCode = "jew" + propertyEthereal = "ethereal" + propertyIndestructable = "indestruct" +) + + +const ( + magicItemPrefixMax = 1 + magicItemSuffixMax = 1 + rareItemPrefixMax = 3 + rareItemSuffixMax = 3 + rareJewelPrefixMax = 3 + rareJewelSuffixMax = 3 + rareJewelAffixMax = 4 +) + +// static check to ensure Item implements Item +var _ d2item.Item = &Item{} + +type Item struct { + name string + Seed int64 + rand *rand.Rand // non-global rand instance for re-generating the item + + slotType d2enum.EquippedSlot + + TypeCode string + CommonCode string + UniqueCode string + SetCode string + SetItemCode string + PrefixCodes []string + SuffixCodes []string + + properties map[PropertyPool][]*Property + statContext d2item.StatContext + statList d2stats.StatList + uniqueStatList d2stats.StatList + setItemStatList d2stats.StatList + + attributes *itemAttributes + + sockets []*d2item.Item // there will be checks for handling the craziness this might entail +} + +type itemAttributes struct { + worldSprite *d2ui.Sprite + inventorySprite *d2ui.Sprite + + damageOneHand minMaxEnhanceable + damageTwoHand minMaxEnhanceable + damageMissile minMaxEnhanceable + stackSize minMaxEnhanceable + durability minMaxEnhanceable + + personalization string + + quality int + defense int + currentStackSize int + currentDurability int + baseItemLevel int + requiredLevel int + numSockets int + requirementsEnhancement int + requiredStrength int + requiredDexterity int + classSpecific d2enum.Hero + + durable bool // some items specify that they have no durability + indestructable bool + ethereal bool + throwable bool +} + +type minMaxEnhanceable struct { + min int + max int + enhance int +} + +// Name returns the item name +func (i *Item) Name() string { + return i.name +} + +// Context returns the statContext that is being used to evaluate stats. for example, +// stats which are based on character level will be evaluated with the player +// as the statContext, as the player stat list will contain stats that describe the +// character level +func (i *Item) Context() d2item.StatContext { + return i.statContext +} + +// SetContext sets the statContext for evaluating item stats +func (i *Item) SetContext(ctx d2item.StatContext) { + i.statContext = ctx +} + +// ItemType returns the type of item +func (i *Item) ItemType() string { + return i.TypeCode +} + +// ItemLevel returns the level of item +func (i *Item) ItemLevel() int { + return i.attributes.baseItemLevel +} + +// TypeRecord returns the ItemTypeRecord of the item +func (i *Item) TypeRecord() *d2datadict.ItemTypeRecord { + return d2datadict.ItemTypes[i.TypeCode] +} + +// CommonRecord returns the ItemCommonRecord of the item +func (i *Item) CommonRecord() *d2datadict.ItemCommonRecord { + return d2datadict.CommonItems[i.CommonCode] +} + +// UniqueRecord returns the UniqueItemRecord of the item +func (i *Item) UniqueRecord() *d2datadict.UniqueItemRecord { + return d2datadict.UniqueItems[i.UniqueCode] +} + +// SetRecord returns the SetRecord of the item +func (i *Item) SetRecord() *d2datadict.SetRecord { + return d2datadict.SetRecords[i.SetCode] +} + +// SetItemRecord returns the SetRecord of the item +func (i *Item) SetItemRecord() *d2datadict.SetItemRecord { + return d2datadict.SetItems[i.SetItemCode] +} + +// PrefixRecords returns the ItemAffixCommonRecords of the prefixes of the item +func (i *Item) PrefixRecords() []*d2datadict.ItemAffixCommonRecord { + return affixRecords(i.PrefixCodes, d2datadict.MagicPrefix) +} + +// PrefixRecords returns the ItemAffixCommonRecords of the prefixes of the item +func (i *Item) SuffixRecords() []*d2datadict.ItemAffixCommonRecord { + return affixRecords(i.SuffixCodes, d2datadict.MagicSuffix) +} + +func affixRecords( + fromCodes []string, + affixes map[string]*d2datadict.ItemAffixCommonRecord, +) []*d2datadict.ItemAffixCommonRecord { + if len(fromCodes) < 1 { + return nil + } + + result := make([]*d2datadict.ItemAffixCommonRecord, len(fromCodes)) + + for idx, code := range fromCodes { + rec := affixes[code] + result[idx] = rec + } + + return result +} + + +// SlotType returns the slot type (where it can be equipped) +func (i *Item) SlotType() d2enum.EquippedSlot { + return i.slotType +} + +// StatList returns the evaluated stat list +func (i *Item) StatList() d2stats.StatList { + return i.statList +} + +// Description returns the full description string for the item +func (i *Item) Description() string { + return "" +} + +// applyDropModifier attempts to find the necessary set, unique, or +// affix records, depending on the drop modifier given. If an unsupported +// drop modifier is supplied, it will attempt to reconcile by picked +// magic affixes as if it were a rare. +func (i *Item) applyDropModifier(modifier DropModifier) { + + modifier = i.sanitizeDropModifier(modifier) + + switch modifier { + case DropModifierUnique: + i.pickUniqueRecord() + + if i.UniqueRecord() == nil { + i.applyDropModifier(DropModifierRare) + return + } + case DropModifierSet: + i.pickSetRecords() + + if i.SetRecord() == nil || i.SetItemRecord() == nil { + i.applyDropModifier(DropModifierRare) + return + } + case DropModifierRare, DropModifierMagic: + // the method of picking stays the same for magic/rare + // but magic gets to pick more, and jewels have a special + // way of picking affixes + i.pickMagicAffixes(modifier) + case DropModifierNone: + default: + return + } +} + +func (i *Item) sanitizeDropModifier(modifier DropModifier) DropModifier { + if i.TypeRecord() == nil { + i.TypeCode = i.CommonRecord().Type + } + + // should this item always be normal? + if i.TypeRecord().Normal { + modifier = DropModifierNone + } + + // should this item always be magic? + if i.TypeRecord().Magic { + modifier = DropModifierMagic + } + + // if it isn't allowed to be rare, force it to be magic + if modifier == DropModifierRare && !i.TypeRecord().Rare { + modifier = DropModifierMagic + } + + return modifier +} + +func (i *Item) pickUniqueRecord() { + matches := findMatchingUniqueRecords(i.CommonRecord()) + if len(matches) > 0 { + match := matches[i.rand.Intn(len(matches))] + i.UniqueCode = match.Code + } +} + +func (i *Item) pickSetRecords() { + if matches := findMatchingSetItemRecords(i.CommonRecord()); len(matches) > 0 { + picked := matches[i.rand.Intn(len(matches))] + i.SetItemCode = picked.SetItemKey + + if rec := i.SetItemRecord(); rec != nil { + i.SetCode = rec.SetKey + } + } +} + +func (i *Item) pickMagicAffixes(mod DropModifier) { + if i.PrefixCodes == nil { + i.PrefixCodes = make([]string, 0) + } + + if i.SuffixCodes == nil { + i.SuffixCodes = make([]string, 0) + } + + totalAffixes, numSuffixes, numPrefixes := 0, 0, 0 + + switch mod { + case DropModifierRare: + if i.CommonRecord().Type == jewelItemCode { + numPrefixes, numSuffixes = rareJewelPrefixMax, rareJewelSuffixMax + totalAffixes = rareJewelAffixMax + } else { + numPrefixes, numSuffixes = rareItemPrefixMax, rareItemSuffixMax + totalAffixes = numPrefixes + numSuffixes + } + case DropModifierMagic: + numPrefixes, numSuffixes = magicItemPrefixMax, magicItemSuffixMax + totalAffixes = numPrefixes + numSuffixes + } + + i.pickMagicPrefixes(numPrefixes, totalAffixes) + i.pickMagicSuffixes(numSuffixes, totalAffixes) +} + +func (i *Item) pickMagicPrefixes(max, totalMax int) { + for numPicks := 0; numPicks < max; numPicks++ { + matches := findMatchingAffixes(i.CommonRecord(), d2datadict.MagicPrefix) + + if rollPrefix := i.rand.Intn(2); rollPrefix > 0 { + affixCount := len(i.PrefixRecords()) + len(i.SuffixRecords()) + if len(i.PrefixRecords()) > max || affixCount > totalMax { + break + } + + if len(matches) > 0 { + picked := matches[i.rand.Intn(len(matches))] + i.PrefixCodes = append(i.PrefixCodes, picked.Name) + } + } + } +} + +func (i *Item) pickMagicSuffixes(max, totalMax int) { + for numPicks := 0; numPicks < max; numPicks++ { + matches := findMatchingAffixes(i.CommonRecord(), d2datadict.MagicSuffix) + + if rollSuffix := i.rand.Intn(2); rollSuffix > 0 { + affixCount := len(i.PrefixRecords()) + len(i.SuffixRecords()) + if len(i.PrefixRecords()) > max || affixCount > totalMax { + break + } + + if len(matches) > 0 { + picked := matches[i.rand.Intn(len(matches))] + i.SuffixCodes = append(i.SuffixCodes, picked.Name) + } + } + } +} + +func (i *Item) generateAllProperties() { + if i.attributes == nil { + i.attributes = &itemAttributes{} + } + + // these will get updated by any generated properties + i.attributes.ethereal = false + i.attributes.indestructable = false + + pools := []PropertyPool{ + PropertyPoolPrefix, + PropertyPoolSuffix, + PropertyPoolUnique, + PropertyPoolSetItem, + PropertyPoolSet, + } + + for _, pool := range pools { + i.generateProperties(pool) + } +} + +func (i *Item) generateProperties(pool PropertyPool) { + var props []*Property + + switch pool { + case PropertyPoolPrefix: + if generated := i.generatePrefixProperties(); generated != nil { + props = generated + } + case PropertyPoolSuffix: + if generated := i.generateSuffixProperties(); generated != nil { + props = generated + } + case PropertyPoolUnique: + if generated := i.generateUniqueProperties(); generated != nil { + props = generated + } + case PropertyPoolSetItem: + if generated := i.generateSetItemProperties(); generated != nil { + props = generated + } + case PropertyPoolSet: + // todo set bonus handling, needs player/equipment context + } + + if props == nil { + return + } + + if i.properties == nil { + i.properties = make(map[PropertyPool][]*Property) + } + + i.properties[pool] = props + + // in the case one of the properties is a stat-less prop for indestructable/ethereal + // we need to set the item attributes to the rolled values. we use `||` here just in + // case another property has already set the flag + for propIdx := range props { + prop := props[propIdx] + switch prop.record.Code { + case propertyEthereal: + i.attributes.ethereal = i.attributes.ethereal || prop.computedBool + case propertyIndestructable: + i.attributes.indestructable = i.attributes.ethereal || prop.computedBool + } + } +} + +func (i *Item) updateItemAttributes() { + i.generateName() + + r := i.CommonRecord() + i.attributes = &itemAttributes{ + damageOneHand: minMaxEnhanceable{ + min: r.MinDamage, + max: r.MaxDamage, + }, + + damageTwoHand: minMaxEnhanceable{ + min: r.Min2HandDamage, + max: r.Max2HandDamage, + }, + + damageMissile: minMaxEnhanceable{ + min: r.MinMissileDamage, + max: r.MaxMissileDamage, + }, + stackSize: minMaxEnhanceable{ + min: r.MinStack, + max: r.MaxStack, + }, + durability: minMaxEnhanceable{ + min: r.Durability, + max: r.Durability, + }, + + baseItemLevel: r.Level, + requiredLevel: r.RequiredLevel, + requiredStrength: r.RequiredStrength, + requiredDexterity: r.RequiredDexterity, + durable: !r.NoDurability, + throwable: r.Throwable, + } + + def, minDef, maxDef := 0, r.MinAC, r.MaxAC + + if minDef < 1 && maxDef < 1 { + if maxDef < minDef { + minDef, maxDef = maxDef, minDef + } + + def = i.rand.Intn(maxDef-minDef+1) + minDef + } + + i.attributes.defense = def +} + +func (i *Item) generatePrefixProperties() []*Property { + if i.PrefixRecords() == nil || len(i.PrefixRecords()) < 1 { + return nil + } + + result := make([]*Property, 0) + + // for each prefix + for recIdx := range i.PrefixRecords() { + prefix := i.PrefixRecords()[recIdx] + // for each modifier + for modIdx := range prefix.Modifiers { + mod := prefix.Modifiers[modIdx] + + prop := NewProperty(mod.Code, mod.Parameter, mod.Min, mod.Max) + if prop == nil { + continue + } + + result = append(result, prop) + } + } + + return result +} + +func (i *Item) generateSuffixProperties() []*Property { + if i.SuffixRecords() == nil || len(i.SuffixRecords()) < 1 { + return nil + } + + result := make([]*Property, 0) + + // for each prefix + for recIdx := range i.SuffixRecords() { + prefix := i.SuffixRecords()[recIdx] + // for each modifier + for modIdx := range prefix.Modifiers { + mod := prefix.Modifiers[modIdx] + + prop := NewProperty(mod.Code, mod.Parameter, mod.Min, mod.Max) + if prop == nil { + continue + } + + result = append(result, prop) + } + } + + return result +} + +func (i *Item) generateUniqueProperties() []*Property { + if i.UniqueRecord() == nil { + return nil + } + + result := make([]*Property, 0) + + for propIdx := range i.UniqueRecord().Properties { + propInfo := i.UniqueRecord().Properties[propIdx] + + // sketchy ass unique records, the param should be an int but sometimes it's the name + // of a skill, which needs to be converted to the skill index + paramStr := getStringComponent(propInfo.Parameter) + paramInt := getNumericComponent(propInfo.Parameter) + + if paramStr != "" { + for skillID := range d2datadict.SkillDetails { + if d2datadict.SkillDetails[skillID].Skill == paramStr { + paramInt = skillID + } + } + } + + prop := NewProperty(propInfo.Code, paramInt, propInfo.Min, propInfo.Max) + if prop == nil { + continue + } + + result = append(result, prop) + } + + return result +} + +func (i *Item) generateSetItemProperties() []*Property { + if i.SetItemRecord() == nil { + return nil + } + + result := make([]*Property, 0) + + for propIdx := range i.SetItemRecord().Properties { + setProp := i.SetItemRecord().Properties[propIdx] + + // like with unique records, the property param is sometimes a skill name + // as a string, not an integer index + paramStr := getStringComponent(setProp.Parameter) + paramInt := getNumericComponent(setProp.Parameter) + + if paramStr != "" { + for skillID := range d2datadict.SkillDetails { + if d2datadict.SkillDetails[skillID].Skill == paramStr { + paramInt = skillID + } + } + } + + prop := NewProperty(setProp.Code, paramInt, setProp.Min, setProp.Max) + if prop == nil { + continue + } + + result = append(result, prop) + } + + return result +} + +func (i *Item) generateName() { + if i.SetItemRecord() != nil { + i.name = d2common.TranslateString(i.SetItemRecord().SetItemKey) + return + } + + if i.UniqueRecord() != nil { + i.name = d2common.TranslateString(i.UniqueRecord().Name) + return + } + + name := d2common.TranslateString(i.CommonRecord().NameString) + + if i.PrefixRecords() != nil { + if len(i.PrefixRecords()) > 0 { + affix := i.PrefixRecords()[i.rand.Intn(len(i.PrefixRecords()))] + name = fmt.Sprintf("%s %s", affix.Name, name) + } + } + + if i.SuffixRecords() != nil { + if len(i.SuffixRecords()) > 0 { + affix := i.SuffixRecords()[i.rand.Intn(len(i.SuffixRecords()))] + name = fmt.Sprintf("%s %s", name, affix.Name) + } + } + + i.name = name +} + +// GetStatStrings is a test function for getting all stat strings +func (i *Item) GetStatStrings() []string { + result := make([]string, 0) + stats := make([]d2stats.Stat, 0) + + for pool := range i.properties { + propPool := i.properties[pool] + if propPool == nil { + continue + } + + for propIdx := range propPool { + if propPool[propIdx] == nil { + continue + } + + prop := propPool[propIdx] + + for statIdx := range prop.stats { + stats = append(stats, prop.stats[statIdx]) + } + } + } + + if len(stats) > 0 { + stats = diablo2stats.NewStatList(stats...).ReduceStats().Stats() + } + + for statIdx := range stats { + statStr := stats[statIdx].String() + if statStr != "" { + result = append(result, statStr) + } + } + + return result +} + +func findMatchingUniqueRecords(icr *d2datadict.ItemCommonRecord) []*d2datadict.UniqueItemRecord { + result := make([]*d2datadict.UniqueItemRecord, 0) + + c1, c2, c3, c4 := icr.Code, icr.NormalCode, icr.UberCode, icr.UltraCode + + for uCode := range d2datadict.UniqueItems { + uRec := d2datadict.UniqueItems[uCode] + + switch uCode { + case c1, c2, c3, c4: + result = append(result, uRec) + } + } + + return result +} + +// find possible SetItemRecords that the given ItemCommonRecord can have +func findMatchingSetItemRecords(icr *d2datadict.ItemCommonRecord) []*d2datadict.SetItemRecord { + result := make([]*d2datadict.SetItemRecord, 0) + + c1, c2, c3, c4 := icr.Code, icr.NormalCode, icr.UberCode, icr.UltraCode + + for setItemIdx := range d2datadict.SetItems { + switch d2datadict.SetItems[setItemIdx].ItemCode { + case c1, c2, c3, c4: + result = append(result, d2datadict.SetItems[setItemIdx]) + } + } + + return result +} + +// for a given ItemCommonRecord, find all possible affixes that can spawn +func findMatchingAffixes( + icr *d2datadict.ItemCommonRecord, + fromAffixes map[string]*d2datadict.ItemAffixCommonRecord, +) []*d2datadict.ItemAffixCommonRecord { + result := make([]*d2datadict.ItemAffixCommonRecord, 0) + + equivItemTypes := d2datadict.FindEquivalentTypesByItemCommonRecord(icr) + + for prefixIdx := range fromAffixes { + include, exclude := false, false + affix := fromAffixes[prefixIdx] + + for itemTypeIdx := range equivItemTypes { + itemType := equivItemTypes[itemTypeIdx] + + for _, excludedType := range affix.ItemExclude { + if itemType == excludedType { + exclude = true + break + } + } + + if exclude { + break + } + + for _, includedType := range affix.ItemInclude { + if itemType == includedType { + include = true + break + } + } + + if !include { + continue + } + + if icr.Level < affix.Level { + continue + } + + result = append(result, affix) + } + } + + return result +} diff --git a/d2core/d2item/diablo2item/item_generator.go b/d2core/d2item/diablo2item/item_generator.go new file mode 100644 index 00000000..e9975cf0 --- /dev/null +++ b/d2core/d2item/diablo2item/item_generator.go @@ -0,0 +1,253 @@ +package diablo2item + +import ( + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2data/d2datadict" + "math/rand" + "regexp" + "strconv" +) + +const ( + DropModifierBaseProbability = 1024 // base DropModifier probability total +) + +type DropModifier int + +const ( + DropModifierNone DropModifier = iota + DropModifierUnique + DropModifierSet + DropModifierRare + DropModifierMagic +) + +const ( + // DynamicItemLevelRange for treasure codes like `armo33`, this code is used to + // select all equivalent items (matching `armo` in this case) with item levels 33,34,35 + DynamicItemLevelRange = 3 +) + +const ( + goldItemCodeWithMult = "gld,mul=" + goldItemCode = "gld" +) + +// ItemGenerator is a diablo 2 implementation of an item generator +type ItemGenerator struct { + rand *rand.Rand + source rand.Source + Seed int64 +} + +// SetSeed sets the item generator seed +func (ig *ItemGenerator) SetSeed(seed int64) { + if ig.rand == nil || ig.source == nil { + ig.source = rand.NewSource(seed) + ig.rand = rand.New(ig.source) + } + ig.Seed = seed +} + +func (ig *ItemGenerator) rollDropModifier(tcr *d2datadict.TreasureClassRecord) DropModifier { + modMap := map[int]DropModifier{ + 0: DropModifierNone, + 1: DropModifierUnique, + 2: DropModifierSet, + 3: DropModifierRare, + 4: DropModifierMagic, + } + + dropModifiers := []int{ + DropModifierBaseProbability, + tcr.FreqUnique, + tcr.FreqSet, + tcr.FreqRare, + tcr.FreqMagic, + } + + for idx := range dropModifiers { + if idx == 0 { + continue + } + + dropModifiers[idx] += dropModifiers[idx-1] + } + + roll := ig.rand.Intn(dropModifiers[len(dropModifiers)-1]) + + for idx := range dropModifiers { + if roll < dropModifiers[idx] { + return modMap[idx] + } + } + + return DropModifierNone +} + +func (ig *ItemGenerator) rollTreasurePick(tcr *d2datadict.TreasureClassRecord) *d2datadict.Treasure { + // treasure probabilities + tprob := make([]int, len(tcr.Treasures)+1) + total := tcr.FreqNoDrop + tprob[0] = total + + for idx := range tcr.Treasures { + total += tcr.Treasures[idx].Probability + tprob[idx+1] = total + } + + roll := ig.rand.Intn(total) + + for idx := range tprob { + if roll < tprob[idx] { + if idx == 0 { + break + } + + return tcr.Treasures[idx-1] + } + } + + return nil +} + +// ItemsFromTreasureClass rolls for and creates items using a treasure class record +func (ig *ItemGenerator) ItemsFromTreasureClass(tcr *d2datadict.TreasureClassRecord) []*Item { + result := make([]*Item, 0) + + treasurePicks := make([]*d2datadict.Treasure, 0) + + // if tcr.NumPicks is negative, each item probability is instead a count for how many + // of that treasure to drop + if tcr.NumPicks < 0 { + picksLeft := tcr.NumPicks + + // for each of the treasures, we pick it N times, where N is the count for the item + // we do this until we run out of picks + for idx := range tcr.Treasures { + howMany := tcr.Treasures[idx].Probability + for count := 0; count < howMany && picksLeft < 0; count++ { + treasurePicks = append(treasurePicks, tcr.Treasures[idx]) + picksLeft++ + } + } + } else { + // for N picks, we roll for a treasure and append to our treasures if it isn't a NoDrop + for picksLeft := tcr.NumPicks; picksLeft > 0; picksLeft-- { + rolledTreasure := ig.rollTreasurePick(tcr) + + if rolledTreasure == nil { + continue + } + + treasurePicks = append(treasurePicks, rolledTreasure) + } + } + + // for each of our picked/rolled treasures, we will attempt to generate an item. + // The treasure may actually be a reference to another treasure class, in which + // case we will roll that treasure class, eventually getting a slice of items + for idx := range treasurePicks { + picked := treasurePicks[idx] + if record, found := d2datadict.TreasureClass[picked.Code]; found { + // the code is for a treasure class, we roll again using that TC + itemSlice := ig.ItemsFromTreasureClass(record) + for itemIdx := range itemSlice { + itemSlice[itemIdx].applyDropModifier(ig.rollDropModifier(tcr)) + itemSlice[itemIdx].generateAllProperties() + itemSlice[itemIdx].updateItemAttributes() + result = append(result, itemSlice[itemIdx]) + } + } else { + // the code is not for a treasure class, but for an item + item := ig.ItemFromTreasure(picked) + if item != nil { + item.applyDropModifier(ig.rollDropModifier(tcr)) + item.generateAllProperties() + item.updateItemAttributes() + result = append(result, item) + } + } + } + + return result +} + +// ItemFromTreasure rolls for a ig.rand.m item using the Treasure struct (from d2datadict) +func (ig *ItemGenerator) ItemFromTreasure(treasure *d2datadict.Treasure) *Item { + result := &Item{ + rand: rand.New(rand.NewSource(ig.Seed)), + } + + // in this case, the treasure code is a code used by an ItemCommonRecord + commonRecord := d2datadict.CommonItems[treasure.Code] + if commonRecord != nil { + result.CommonCode = commonRecord.Code + return result + } + + // next, we check if the treasure code is a generic type like `armo` + equivList := d2datadict.ItemEquivalenciesByTypeCode[treasure.Code] + if equivList != nil { + result.CommonCode = equivList[ig.rand.Intn(len(equivList))].Code + return result + } + + // in this case, the treasure code is something like `armo23` and needs to + // be resolved to ItemCommonRecords for armors with levels 23,24,25 + matches := resolveDynamicTreasureCode(treasure.Code) + if matches != nil { + numItems := len(matches) + if numItems < 1 { + return nil + } + + result.CommonCode = matches[ig.rand.Intn(numItems)].Code + + return result + } + + return nil +} + +func resolveDynamicTreasureCode(code string) []*d2datadict.ItemCommonRecord { + numericComponent := getNumericComponent(code) + stringComponent := getStringComponent(code) + + if stringComponent == goldItemCodeWithMult { + // todo need to do something with the numeric component (the gold multiplier) + stringComponent = goldItemCode + } + + result := make([]*d2datadict.ItemCommonRecord, 0) + equivList := d2datadict.ItemEquivalenciesByTypeCode[stringComponent] + + for idx := range equivList { + record := equivList[idx] + minLevel := numericComponent + maxLevel := minLevel + DynamicItemLevelRange + + if record.Level >= minLevel && record.Level < maxLevel { + result = append(result, record) + } + } + + return result +} + +func getStringComponent(code string) string { + re := regexp.MustCompile(`\d+`) + return string(re.ReplaceAll([]byte(code), []byte(""))) +} + +func getNumericComponent(code string) int { + result := 0 + + re := regexp.MustCompile(`[^\d]`) + numStr := string(re.ReplaceAll([]byte(code), []byte(""))) + + if number, err := strconv.ParseInt(numStr, 10, 32); err == nil { + result = int(number) + } + + return result +} diff --git a/d2core/d2item/diablo2item/item_property.go b/d2core/d2item/diablo2item/item_property.go new file mode 100644 index 00000000..8f2e5001 --- /dev/null +++ b/d2core/d2item/diablo2item/item_property.go @@ -0,0 +1,370 @@ +package diablo2item + +import ( + "math/rand" + + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2data/d2datadict" + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2stats" + "github.com/OpenDiablo2/OpenDiablo2/d2core/d2stats/diablo2stats" +) + +const ( + noValue = iota + oneValue + twoValue + threeValue +) + +const ( + skillTabsPerClass = 3 +) + +// these come from properties.txt, the types of functions that properties can use to evaluate args +const ( + fnNone = iota + fnValuesToStat + fnArmorPercent + fnRepeatPreviousWithMinMax // repeat only with min and max + fnUnused + fnDamageMin + fnDamageMax + fnDamagePercent + fnSpeedRelated + fnRepeatPreviousWithParamMinMax // repeat with param, man, and max + fnClassSkillTab + fnProcs + fnRandomSkill + fnMaxDurability + fnNumSockets + fnStatMin + fnStatMax + fnStatParam + fnTimeRelated + fnChargeRelated + fnIndestructable + fnClassSkills + fnSingleSkill + fnEthereal + fnStateApplyToTarget +) + +// PropertyType describes what kind of property this is +type PropertyType int + +// Property types +// Not all properties contain stats, some are just used to compute a value +// examples are: +// min/max +// % damage +// indestructable and etheral flags +const ( + PropertyComputeStats = iota // for properties that do compute stats + PropertyComputeInteger // for properties that compute an integer value + PropertyComputeBoolean // for properties that compute a boolean +) + +const ( + fnRandClassSkill = 36 +) + +// Property is an item property. Properties act as stat initializers, as well as +// item attribute initializers. A good example of this is for the `Ethereal` property, +// which DOES have a stat, but the stat is actually non-printable as far as the record +// in itemstatcosts.txt is concerned. The behavior of displaying `Ethereal` on an item +// in diablo 2 is hardcoded into whatever handled displaying item descriptions, not +// what was generating stat descriptions (this is a guess, though). +// Another example in min/max damage properties, which do NOT have stats! +type Property struct { + record *d2datadict.PropertyRecord + stats []d2stats.Stat + PropertyType PropertyType + + // the inputValues that were passed initially when calling `NewProperty` + inputParams []int + + // some properties are statless and used only for computing a value + computedInt int + computedBool bool +} + +func (p *Property) init() *Property { + p.stats = make([]d2stats.Stat, 0) + + // some property functions need to be able to repeat last function + // this is for properties with multiple stats that want to repeat the same + // initialization step with the same min/max params + var lastFnCalled int + + var stat d2stats.Stat + + for idx := range p.record.Stats { + if p.record.Stats[idx] == nil { + continue + } + + stat, lastFnCalled = p.eval(idx, lastFnCalled) + + // some property stats don't actually have a stat + // but they have functions on the first stat entry + if stat != nil { + p.stats = append(p.stats, stat) + } + } + + return p +} + +// eval will attempt to create a stat, and will return the function id that was last run. +// this is because some of the properties have a func index which indicates that it should +// repeat the previous fn with the same parameters, but for a different stat. +func (p *Property) eval(propStatIdx, previousFnID int) (stat d2stats.Stat, funcID int) { + pStatRecord := p.record.Stats[propStatIdx] + iscRecord := d2datadict.ItemStatCosts[pStatRecord.StatCode] + + funcID = pStatRecord.FunctionID + + switch funcID { + case fnRepeatPreviousWithMinMax, fnRepeatPreviousWithParamMinMax: + funcID = previousFnID + fallthrough + case fnValuesToStat, fnSpeedRelated, fnMaxDurability, fnNumSockets, + fnStatMin, fnStatMax, fnSingleSkill, fnArmorPercent: + p.PropertyType = PropertyComputeStats + stat = p.fnValuesToStat(iscRecord) + case fnDamageMin, fnDamageMax, fnDamagePercent: + p.PropertyType = PropertyComputeInteger + p.computedInt = p.fnComputeInteger() + case fnClassSkillTab: + p.PropertyType = PropertyComputeStats + stat = p.fnClassSkillTab(iscRecord) + case fnProcs: + p.PropertyType = PropertyComputeStats + stat = p.fnProcs(iscRecord) + case fnRandomSkill: + p.PropertyType = PropertyComputeStats + stat = p.fnRandomSkill(iscRecord) + case fnStatParam: + p.PropertyType = PropertyComputeStats + stat = p.fnStatParam(iscRecord) + case fnChargeRelated: + p.PropertyType = PropertyComputeStats + stat = p.fnChargeRelated(iscRecord) + case fnIndestructable, fnEthereal: + p.PropertyType = PropertyComputeBoolean + p.computedBool = p.fnBoolean() + case fnClassSkills: + p.PropertyType = PropertyComputeStats + stat = p.fnClassSkills(pStatRecord, iscRecord) + case fnStateApplyToTarget: + p.PropertyType = PropertyComputeStats + stat = p.fnStateApplyToTarget(iscRecord) + case fnRandClassSkill: + p.PropertyType = PropertyComputeStats + stat = p.fnRandClassSkill(iscRecord) + case fnNone, fnUnused, fnTimeRelated: + default: + } + + return stat, funcID +} + +// fnValuesToStat Applies a value to a stat, can use SetX parameter. +func (p *Property) fnValuesToStat(iscRecord *d2datadict.ItemStatCostRecord) d2stats.Stat { + // the only special case to handle for this function is for + // property "color", which corresponds to ISC record "item_lightcolor" + // I'm not yet sure how to handle this special case... it is likely + // and index into one of the colors in colors.txt + var min, max int + + var propParam, statValue float64 + + switch len(p.inputParams) { + case noValue, oneValue: + return nil + case twoValue: + min, max = p.inputParams[0], p.inputParams[1] + case threeValue: + propParam = float64(p.inputParams[0]) + min, max = p.inputParams[1], p.inputParams[2] + default: + min, max = p.inputParams[0], p.inputParams[1] + } + + if max < min { + min, max = max, min + } + + statValue = float64(rand.Intn(max-min+1) + min) + + return diablo2stats.NewStat(iscRecord.Name, statValue, propParam) +} + +// fnComputeInteger Dmg-min related ??? +func (p *Property) fnComputeInteger() int { + var min, max int + + switch len(p.inputParams) { + case noValue, oneValue: + return 0 + default: + min, max = p.inputParams[0], p.inputParams[1] + } + + statValue := rand.Intn(max-min+1) + min + + return statValue +} + +// fnClassSkillTab skilltab skill group ??? +func (p *Property) fnClassSkillTab(iscRecord *d2datadict.ItemStatCostRecord) d2stats.Stat { + // from here: https://d2mods.info/forum/kb/viewarticle?a=45 + // Amazon + // 0 - Bow & Crossbow + // 1 - Passive & Magic + // 2 - Spear & Javelin + // Sorceress + // 3 - Fire + // 4 - Lightning + // 5 - Cold + // Necromancer + // 6 - Curses + // 7 - Poison & Bone + // 8 - Summoning + // Paladin + // 9 - Offensive Auras + // 10 - Combat Skills + // 11 - Defensive Auras + // Barbarian + // 12 - Masteries + // 13 - Combat Skills + // 14 - Warcries + // Druid + // 15 - Summoning + // 16 - Shapeshifting + // 17 - Elemental + // Assassin + // 18 - Traps + // 19 - Shadow Disciplines + // 20 - Martial Arts + param, min, max := p.inputParams[0], p.inputParams[1], p.inputParams[2] + skillTabIdx := float64(param % skillTabsPerClass) + classIdx := float64(param / skillTabsPerClass) + level := float64(rand.Intn(max-min+1) + min) + + return diablo2stats.NewStat(iscRecord.Name, level, classIdx, skillTabIdx) +} + +// fnProcs event-based skills ??? +func (p *Property) fnProcs(iscRecord *d2datadict.ItemStatCostRecord) d2stats.Stat { + var skillID, chance, skillLevel float64 + + switch len(p.inputParams) { + case noValue, oneValue, twoValue: + return nil + default: + skillID = float64(p.inputParams[0]) + chance = float64(p.inputParams[1]) + skillLevel = float64(p.inputParams[2]) + } + + return diablo2stats.NewStat(iscRecord.Name, chance, skillLevel, skillID) +} + +// fnRandomSkill random selection of parameters for parameter-based stat ??? +func (p *Property) fnRandomSkill(iscRecord *d2datadict.ItemStatCostRecord) d2stats.Stat { + var skillLevel, skillID float64 + + invalidHeroIndex := -1.0 + + switch len(p.inputParams) { + case noValue, oneValue, twoValue: + return nil + default: + skillLevel = float64(p.inputParams[0]) + min, max := p.inputParams[1], p.inputParams[2] + skillID = float64(rand.Intn(max-min+1) + min) + } + + return diablo2stats.NewStat(iscRecord.Name, skillLevel, skillID, invalidHeroIndex) +} + +// fnStatParam use param field only +func (p *Property) fnStatParam(iscRecord *d2datadict.ItemStatCostRecord) d2stats.Stat { + switch len(p.inputParams) { + case noValue: + return nil + default: + val := float64(p.inputParams[0]) + return diablo2stats.NewStat(iscRecord.Name, val) + } +} + +// fnChargeRelated Related to charged item. +func (p *Property) fnChargeRelated(iscRecord *d2datadict.ItemStatCostRecord) d2stats.Stat { + var lvl, skill, charges float64 + + switch len(p.inputParams) { + case noValue, oneValue, twoValue: + return nil + default: + lvl = float64(p.inputParams[2]) + skill = float64(p.inputParams[0]) + charges = float64(p.inputParams[1]) + + return diablo2stats.NewStat(iscRecord.Name, lvl, skill, charges, charges) + } +} + +// fnIndestructable Simple boolean stuff. Use by indestruct. +func (p *Property) fnBoolean() bool { + var min, max int + + switch len(p.inputParams) { + case noValue, oneValue: + return false + default: + min, max = p.inputParams[0], p.inputParams[1] + } + + statValue := rand.Intn(max-min+1) + min + + return statValue > 0 +} + +// fnClassSkills Add to group of skills, group determined by stat ID, uses ValX parameter. +func (p *Property) fnClassSkills( + propStatRecord *d2datadict.PropertyStatRecord, + iscRecord *d2datadict.ItemStatCostRecord, +) d2stats.Stat { + // in order 0..6 + // Amazon + // Sorceress + // Necromancer + // Paladin + // Druid + // Assassin + var min, max, classIdx int + + switch len(p.inputParams) { + case noValue, oneValue: + return nil + default: + min, max = p.inputParams[0], p.inputParams[1] + } + + statValue := rand.Intn(max-min+1) + min + classIdx = propStatRecord.Value + + return diablo2stats.NewStat(iscRecord.Name, float64(statValue), float64(classIdx)) +} + +// fnStateApplyToTarget property applied to character or target monster ??? +func (p *Property) fnStateApplyToTarget(iscRecord *d2datadict.ItemStatCostRecord) d2stats.Stat { + // todo need to implement states + return nil +} + +// fnRandClassSkill property applied to character or target monster ??? +func (p *Property) fnRandClassSkill(iscRecord *d2datadict.ItemStatCostRecord) d2stats.Stat { + return nil +} diff --git a/d2core/d2item/diablo2item/item_property_test.go b/d2core/d2item/diablo2item/item_property_test.go new file mode 100644 index 00000000..9e234637 --- /dev/null +++ b/d2core/d2item/diablo2item/item_property_test.go @@ -0,0 +1,608 @@ +package diablo2item + +import ( + "fmt" + "math/rand" + "regexp" + "testing" + "time" + + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2data/d2datadict" + "github.com/OpenDiablo2/OpenDiablo2/d2common/d2enum" +) + +//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: 1, + DescStrPos: "to Strength", + DescStrNeg: "to Strength", + }, + "dexterity": { + Name: "dexterity", + DescFnID: 1, + DescVal: 1, + DescStrPos: "to Dexterity", + DescStrNeg: "to Dexterity", + }, + "vitality": { + Name: "vitality", + DescFnID: 1, + DescVal: 1, + DescStrPos: "to Vitality", + DescStrNeg: "to Vitality", + }, + "energy": { + Name: "energy", + DescFnID: 1, + DescVal: 1, + DescStrPos: "to Energy", + DescStrNeg: "to Energy", + }, + "hpregen": { + Name: "hpregen", + DescFnID: 1, + DescVal: 2, + DescStrPos: "Replenish Life", + DescStrNeg: "Drain Life", + }, + "toblock": { + Name: "toblock", + DescFnID: 2, + DescVal: 1, + DescStrPos: "Increased Chance of Blocking", + DescStrNeg: "Increased Chance of Blocking", + }, + "item_absorblight_percent": { + Name: "item_absorblight_percent", + DescFnID: 2, + DescVal: 2, + DescStrPos: "Lightning Absorb", + DescStrNeg: "Lightning Absorb", + }, + "item_maxdurability_percent": { + Name: "item_maxdurability_percent", + DescFnID: 2, + DescVal: 2, + DescStrPos: "Increase Maximum Durability", + DescStrNeg: "Increase Maximum Durability", + }, + "item_restinpeace": { + Name: "item_restinpeace", + DescFnID: 3, + DescVal: 0, + DescStrPos: "Slain Monsters Rest in Peace", + DescStrNeg: "Slain Monsters Rest in Peace", + }, + "normal_damage_reduction": { + Name: "normal_damage_reduction", + DescFnID: 3, + DescVal: 2, + DescStrPos: "Damage Reduced by", + DescStrNeg: "Damage Reduced by", + }, + "poisonresist": { + Name: "poisonresist", + DescFnID: 4, + DescVal: 2, + DescStrPos: "Poison Resist", + DescStrNeg: "Poison Resist", + }, + "item_fastermovevelocity": { + Name: "item_fastermovevelocity", + DescFnID: 4, + DescVal: 1, + DescStrPos: "Faster Run/Walk", + DescStrNeg: "Faster Run/Walk", + }, + "item_howl": { + Name: "item_howl", + DescFnID: 5, + DescVal: 2, + DescStrPos: "Hit Causes Monster to Flee", + DescStrNeg: "Hit Causes Monster to Flee", + }, + "item_hp_perlevel": { + Name: "item_hp_perlevel", + DescFnID: 6, + DescVal: 1, + DescStrPos: "to Life", + DescStrNeg: "to Life", + DescStr2: "(Based on Character Level)", + }, + "item_resist_ltng_perlevel": { + Name: "item_resist_ltng_perlevel", + DescFnID: 7, + DescVal: 2, + DescStrPos: "Lightning Resist", + DescStrNeg: "Lightning Resist", + DescStr2: "(Based on Character Level)", + }, + "item_find_magic_perlevel": { + Name: "item_find_magic_perlevel", + DescFnID: 7, + DescVal: 1, + 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: 1, + DescStrPos: "Enhanced Defense", + DescStrNeg: "Enhanced Defense", + DescStr2: "(Based on Character Level)", + }, + "item_regenstamina_perlevel": { + Name: "item_regenstamina_perlevel", + DescFnID: 8, + DescVal: 2, + DescStrPos: "Heal Stamina Plus", + DescStrNeg: "Heal Stamina Plus", + DescStr2: "(Based on Character Level)", + }, + "item_thorns_perlevel": { + Name: "item_thorns_perlevel", + DescFnID: 9, + DescVal: 2, + 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: 1, + DescStrPos: "Repairs %v durability per second", + DescStrNeg: "Repairs %v durability per second", + DescStr2: "", + }, + "item_stupidity": { + Name: "item_stupidity", + DescFnID: 12, + DescVal: 2, + DescStrPos: "Hit Blinds Target", + DescStrNeg: "Hit Blinds Target", + }, + "item_addclassskills": { + Name: "item_addclassskills", + DescFnID: 13, + DescVal: 1, + }, + "item_addskill_tab": { + Name: "item_addskill_tab", + DescFnID: 14, + DescVal: 1, + }, + "item_skillonattack": { + Name: "item_skillonattack", + DescFnID: 15, + DescVal: 1, + 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: 1, + DescStrPos: "Level %d %s Aura When Equipped", + DescStrNeg: "Level %d %s Aura When Equipped", + }, + "item_fractionaltargetac": { + Name: "item_fractionaltargetac", + DescFnID: 20, + DescVal: 1, + DescStrPos: "Target Defense", + DescStrNeg: "Target Defense", + }, + "attack_vs_montype": { + Name: "item_fractionaltargetac", + DescFnID: 22, + DescVal: 1, + DescStrPos: "to Attack Rating versus", + DescStrNeg: "to Attack Rating versus", + }, + "item_reanimate": { + Name: "item_reanimate", + DescFnID: 23, + DescVal: 2, + DescStrPos: "Reanimate as:", + DescStrNeg: "Reanimate as:", + }, + "item_charged_skill": { + Name: "item_charged_skill", + DescFnID: 24, + DescVal: 2, + DescStrPos: "(%d/%d Charges)", + DescStrNeg: "(%d/%d Charges)", + }, + "item_singleskill": { + Name: "item_singleskill", + DescFnID: 27, + DescVal: 0, + }, + "item_nonclassskill": { + Name: "item_nonclassskill", + DescFnID: 28, + DescVal: 2, + DescStrPos: "(%d/%d Charges)", + DescStrNeg: "(%d/%d Charges)", + }, + "item_armor_percent": { + Name: "item_armor_percent", + DescFnID: 4, + DescVal: 1, + DescStrPos: "Enhanced Defense", + DescStrNeg: "Enhanced Defense", + }, + "item_fastercastrate": { + Name: "item_fastercastrate", + DescFnID: 4, + DescVal: 1, + DescStrPos: "Faster Cast Rate", + DescStrNeg: "Faster Cast Rate", + }, + "item_skillonlevelup": { + Name: "item_skillonlevelup", + DescFnID: 15, + DescVal: 0, + DescStrPos: "%d%% Chance to cast level %d %s when you Level-Up", + DescStrNeg: "%d%% Chance to cast level %d %s when you Level-Up", + }, + "item_numsockets": { + Name: "item_numsockets", + }, + "poisonmindam": { + Name: "poisonmindam", + DescFnID: 1, + DescVal: 1, + DescStrPos: "to Minimum Poison Damage", + DescStrNeg: "to Minimum Poison Damage", + }, + "poisonmaxdam": { + Name: "poisonmaxdam", + DescFnID: 1, + DescVal: 1, + DescStrPos: "to Maximum Poison Damage", + DescStrNeg: "to Maximum Poison Damage", + }, + "poisonlength": { + Name: "poisonlength", + }, + } + + 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}, + } + + properties := map[string]*d2datadict.PropertyRecord{ + "allstats": { + Code: "allstats", + Stats: [7]*d2datadict.PropertyStatRecord{ + {FunctionID: 1, StatCode: "strength"}, + {FunctionID: 3, StatCode: "dexterity"}, + {FunctionID: 3, StatCode: "vitality"}, + {FunctionID: 3, StatCode: "energy"}, + }, + }, + "ac%": { + Code: "ac%", + Stats: [7]*d2datadict.PropertyStatRecord{ + {FunctionID: 2, StatCode: "item_armor_percent"}, + }, + }, + // dmg-min, dmg-max, dmg%, indestruct, and ethereal do not yield stats. + // these properties are used specifically to compute a value. + "dmg-min": { + Code: "dmg-min", + Stats: [7]*d2datadict.PropertyStatRecord{ + {FunctionID: 5}, + }, + }, + "dmg-max": { + Code: "dmg-max", + Stats: [7]*d2datadict.PropertyStatRecord{ + {FunctionID: 6}, + }, + }, + "dmg%": { + Code: "dmg%", + Stats: [7]*d2datadict.PropertyStatRecord{ + {FunctionID: 7}, + }, + }, + "cast1": { + Code: "cast1", + Stats: [7]*d2datadict.PropertyStatRecord{ + {FunctionID: 8, StatCode: "item_fastercastrate"}, + }, + }, + "skilltab": { + Code: "skilltab", + Stats: [7]*d2datadict.PropertyStatRecord{ + {FunctionID: 10, StatCode: "item_addskill_tab"}, + }, + }, + "levelup-skill": { + Code: "levelup-skill", + Stats: [7]*d2datadict.PropertyStatRecord{ + {FunctionID: 11, StatCode: "item_skillonlevelup"}, + }, + }, + "skill-rand": { + Code: "skill-rand", + Stats: [7]*d2datadict.PropertyStatRecord{ + {FunctionID: 12, StatCode: "item_singleskill"}, + }, + }, + "dur%": { + Code: "dur%", + Stats: [7]*d2datadict.PropertyStatRecord{ + {FunctionID: 13, StatCode: "item_maxdurability_percent"}, + }, + }, + "sock": { + Code: "sock", + Stats: [7]*d2datadict.PropertyStatRecord{ + {FunctionID: 14, StatCode: "item_numsockets"}, + }, + }, + "dmg-pois": { + Code: "dmg-pois", + Stats: [7]*d2datadict.PropertyStatRecord{ + {FunctionID: 15, StatCode: "poisonmindam"}, + {FunctionID: 16, StatCode: "poisonmaxdam"}, + {FunctionID: 17, StatCode: "poisonlength"}, + }, + }, + "charged": { + Code: "charged", + Stats: [7]*d2datadict.PropertyStatRecord{ + {FunctionID: 19, StatCode: "item_charged_skill"}, + }, + }, + "indestruct": { + Code: "indestruct", + Stats: [7]*d2datadict.PropertyStatRecord{ + {FunctionID: 20}, + }, + }, + "pal": { + Code: "pal", + Stats: [7]*d2datadict.PropertyStatRecord{ + {FunctionID: 21, StatCode: "item_addclassskills", Value: 3}, + }, + }, + "oskill": { + Code: "oskill", + Stats: [7]*d2datadict.PropertyStatRecord{ + {FunctionID: 22, StatCode: "item_nonclassskill"}, + }, + }, + "ethereal": { + Code: "ethereal", + Stats: [7]*d2datadict.PropertyStatRecord{ + {FunctionID: 23}, + }, + }, + } + + d2datadict.ItemStatCosts = itemStatCosts + d2datadict.CharStats = charStats + d2datadict.SkillDetails = skillDetails + d2datadict.MonStats = monStats + d2datadict.Properties = properties +} + +func TestNewProperty(t *testing.T) { //nolint:funlen it's mostly test-case definitions + rand.Seed(time.Now().UTC().UnixNano()) + + tests := []struct { + propKey string + inputValues []int + expectNumStats int + expectStr []string + }{ + { // fnId 1 + 3 + "allstats", + []int{1, 10}, + 4, + []string{ + "+# to Strength", + "+# to Dexterity", + "+# to Vitality", + "+# to Energy", + }, + }, + { // fnId 2 + "ac%", + []int{1, 10}, + 1, + []string{"+#% Enhanced Defense"}, + }, + { // fnId 5 + // dmg-min, dmg-max, dmg%, indestructable, and ethereal dont have stats! + "dmg-min", + []int{1, 10}, + 0, + []string{""}, + }, + { // fnId 6 + // dmg-min, dmg-max, dmg%, indestructable, and ethereal dont have stats! + "dmg-max", + []int{1, 10}, + 0, + []string{""}, + }, + { // fnId 7 + // dmg-min, dmg-max, dmg%, indestructable, and ethereal dont have stats! + "dmg%", + []int{1, 10}, + 0, + []string{""}, + }, + { // fnId 8 + "cast1", + []int{1, 10}, + 1, + []string{"+#% Faster Cast Rate"}, + }, + { + "indestruct", + []int{0, 1}, + 0, + []string{""}, + }, + { + "ethereal", + []int{0, 1}, + 0, + []string{""}, + }, + { // fnId 10 + "skilltab", + []int{10, 1, 3}, + 1, + []string{"+# to Offensive Auras (Paladin Only)"}, + }, + { // fnId 11 + "levelup-skill", + []int{64, 100, 3}, + 1, + []string{"#% Chance to cast level # Frozen Orb when you Level-Up"}, + }, + { // fnId 12 + "skill-rand", + []int{10, 64, 64}, + 1, + []string{"+# to Frozen Orb"}, + }, + { // fnId 13 + "dur%", + []int{1, 10}, + 1, + []string{"Increase Maximum Durability +#%"}, + }, + { // fnId 14 + "sock", + []int{0, 6}, + 1, + []string{""}, + }, + { // fnId 15, 16, 17 + "dmg-pois", + []int{100, 5, 10}, + 3, + []string{ + "+# to Minimum Poison Damage", + "+# to Maximum Poison Damage", + "", // length, non-printing + }, + }, + { // fnId 19 + "charged", + []int{64, 20, 10}, + 1, + []string{"Level # Frozen Orb (#/# Charges)"}, + }, + { // fnId 21 + "pal", + []int{1, 5}, + 1, + []string{"+# to Paladin Skill Levels"}, + }, + { // fnId 22 + "oskill", + []int{64, 1, 5}, + 1, + []string{"+# to Frozen Orb"}, + }, + } + + numericToken := "#" + re := regexp.MustCompile(`\d+`) + + for testIdx := range tests { + test := &tests[testIdx] + prop := NewProperty(test.propKey, test.inputValues...) + + if prop == nil { + t.Error("property is nil") + continue + } + + infoFmt := "\r\nProperty `%s`, arguments %v" + infoStr := fmt.Sprintf(infoFmt, prop.record.Code, test.inputValues) + fmt.Println(infoStr) + + if len(prop.stats) != test.expectNumStats { + errFmt := "unexpected property stat count: want %v, have %v" + t.Errorf(errFmt, test.expectNumStats, len(prop.stats)) + + continue + } + + switch prop.PropertyType { + case PropertyComputeBoolean: + fmtStr := "\tGot: [Non-printing boolean property] [Bool Value: %v]" + got := fmt.Sprintf(fmtStr, prop.computedBool) + fmt.Println(got) + case PropertyComputeInteger: + fmtStr := "\tGot: [Non-printing integer property] [Int Value: %v]" + got := fmt.Sprintf(fmtStr, prop.computedInt) + fmt.Println(got) + case PropertyComputeStats: + for statIdx := range prop.stats { + stat := prop.stats[statIdx] + expectStr := test.expectStr[statIdx] + statStr := stat.String() + stripped := string(re.ReplaceAll([]byte(statStr), []byte(numericToken))) + + if expectStr == "" { + statFmt := "[Non-printing stat] Code: %v, inputValues: %+v" + + vals := stat.Values() + valInts := make([]int, len(vals)) + + for idx := range vals { + valInts[idx] = vals[idx].Int() + } + + statStr = fmt.Sprintf(statFmt, stat.Name(), valInts) + got := fmt.Sprintf("\tGot: %s", statStr) + fmt.Println(got) + } else { + got := fmt.Sprintf("\tGot: %s", statStr) + fmt.Println(got) + } + + if stripped != expectStr { + expected := fmt.Sprintf("\tExpected: %s", test.expectStr) + t.Error(expected) + } + } + } + } +} diff --git a/d2core/d2item/doc.go b/d2core/d2item/doc.go new file mode 100644 index 00000000..c403842d --- /dev/null +++ b/d2core/d2item/doc.go @@ -0,0 +1,3 @@ +// Package d2item provides a generic interface for what the OpenDiablo2 +// engine considers to be an item. +package d2item diff --git a/d2core/d2item/equipper.go b/d2core/d2item/equipper.go new file mode 100644 index 00000000..d73dca81 --- /dev/null +++ b/d2core/d2item/equipper.go @@ -0,0 +1,6 @@ +package d2item + +type Equipper interface { + EquippedItems() []Item + CarriedItems() []Item +} diff --git a/d2core/d2item/item.go b/d2core/d2item/item.go new file mode 100644 index 00000000..0198a3d7 --- /dev/null +++ b/d2core/d2item/item.go @@ -0,0 +1,11 @@ +package d2item + +// Item describes all types of item that can be placed in the +// player inventory grid (not just things that can be equipped!) +type Item interface { + Context() StatContext + SetContext(StatContext) + + Name() string + Description() string +} diff --git a/d2core/d2stats/diablo2stats/stat.go b/d2core/d2stats/diablo2stats/stat.go index b75cbdef..29fac985 100644 --- a/d2core/d2stats/diablo2stats/stat.go +++ b/d2core/d2stats/diablo2stats/stat.go @@ -50,13 +50,20 @@ type diablo2Stat struct { // depending on the stat record, sets up the proper number of values, // as well as set up the stat value number types, value combination types, and // the value stringer functions used -func (s *diablo2Stat) init(numbers ...float64) { +func (s *diablo2Stat) init(numbers ...float64) {//nolint:funlen doesn't make sense to split if s.record == nil { return } //nolint:gomdn introducing a const for these would be worse switch s.record.DescFnID { + case 0: + // special case for poisonlength, or other stats, which have a + // 0-value descfnID field but need to store values + s.values = make([]d2stats.StatValue, len(numbers)) + for idx := range s.values { + s.values[idx] = NewValue(intVal, sum).SetStringer(stringerIntSigned) + } case 1: // +31 to Strength // Replenish Life +20 || Drain Life -8 @@ -167,7 +174,7 @@ func (s *diablo2Stat) init(numbers ...float64) { } for idx := range numbers { - if idx > len(s.values) { + if idx > len(s.values)-1 { break } @@ -222,6 +229,10 @@ func (s *diablo2Stat) Clone() d2stats.Stat { dstVal.SetFloat(srcVal.Float()) } + if len(clone.values) < len(s.values) { + clone.values = make([]d2stats.StatValue, len(s.values)) + } + clone.values[idx] = dstVal } @@ -561,6 +572,12 @@ func (s *diablo2Stat) descFn24() string { } func (s *diablo2Stat) descFn27() string { + // property "skill-rand" will try to make an instance with an invalid hero index + // in this case, we use descfn 28 + if s.values[2].Int() == -1 { + return s.descFn28() + } + amount, skill, hero := s.values[0], s.values[1], s.values[2] return fmt.Sprintf(fourComponentStr, amount, "to", skill, hero) diff --git a/d2core/d2stats/diablo2stats/stat_value_stringers.go b/d2core/d2stats/diablo2stats/stat_value_stringers.go index b78d9ec7..0603e836 100644 --- a/d2core/d2stats/diablo2stats/stat_value_stringers.go +++ b/d2core/d2stats/diablo2stats/stat_value_stringers.go @@ -13,15 +13,15 @@ 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 getHeroMap() []d2enum.Hero { + return []d2enum.Hero{ + d2enum.HeroAmazon, + d2enum.HeroSorceress, + d2enum.HeroNecromancer, + d2enum.HeroPaladin, + d2enum.HeroBarbarian, + d2enum.HeroDruid, + d2enum.HeroAssassin, } }