diff --git a/go-services/inventory-go/od.go b/go-services/inventory-go/od.go new file mode 100644 index 00000000..56d93a44 --- /dev/null +++ b/go-services/inventory-go/od.go @@ -0,0 +1,562 @@ +package main + +import "math" + +// od.go computes the Mag-Tools "OD" (over-damage vs best retail) rating for +// melee/missile/wand weapons, ported from UtilityBelt's +// Lib/ItemInfoHelper/{WeaponMods,MiscCalcs,Dictionaries}.cs and +// Tools/ItemInfo.cs (CalcMeleeDamage / CalcMissileDamage / the OD display +// lines ~631-647). See docs/superpowers/specs/2026-07-15-weapon-od-rating-design.md. + +// bestKey mirrors the (Skill, Mastery, MultiStrike, WieldReq) DataTable.Select +// key UB uses for an EXACT match lookup — no nearest-tier rounding. +type bestKey struct { + skill int + mastery int + multiStrike int + wieldReq int +} + +// bestValue mirrors the trailing DataTable columns: +// (MaxDmg, MaxVar, MaxDmgMod, MaxElementalDmgBonus, MaxElementalDmgVsMonsters). +type bestValue struct { + maxDmg float64 + maxVar float64 + maxDmgMod float64 + maxElemBonus float64 + maxElemVsMon float64 +} + +// bestValues is transcribed row-for-row from +// UtilityBelt/UtilityBelt/Lib/ItemInfoHelper/WeaponMods.cs BestValuesDatatable.GetTable(). +// Column order in the source Rows.Add is: +// (Skill, Mastery, MultiStrike, WieldReq, MaxDmg, MaxVar, MaxDmgMod, MaxElementalDmgBonus, MaxElementalDmgVsMonsters) +// — omitted trailing columns default to 0. 301 rows total (matches +// `grep -c "table.Rows.Add" WeaponMods.cs`). +var bestValues = map[bestKey]bestValue{ + // heavyweaponry (skill 44) http://acpedia.org/wiki/Heavy_Weaponry + // axe + {44, 3, 0, 0}: {26, .90, 0, 0, 0}, + {44, 3, 0, 250}: {33, .90, 0, 0, 0}, + {44, 3, 0, 300}: {40, .90, 0, 0, 0}, + {44, 3, 0, 325}: {47, .90, 0, 0, 0}, + {44, 3, 0, 350}: {54, .90, 0, 0, 0}, + {44, 3, 0, 370}: {61, .90, 0, 0, 0}, + {44, 3, 0, 400}: {69, .90, 0, 0, 0}, + {44, 3, 0, 420}: {71, .90, 0, 0, 0}, + {44, 3, 0, 430}: {74, .90, 0, 0, 0}, + // dagger + {44, 6, 0, 0}: {24, .47, 0, 0, 0}, + {44, 6, 0, 250}: {31, .47, 0, 0, 0}, + {44, 6, 0, 300}: {38, .47, 0, 0, 0}, + {44, 6, 0, 325}: {45, .47, 0, 0, 0}, + {44, 6, 0, 350}: {51, .47, 0, 0, 0}, + {44, 6, 0, 370}: {58, .47, 0, 0, 0}, + {44, 6, 0, 400}: {65, .47, 0, 0, 0}, + {44, 6, 0, 420}: {68, .47, 0, 0, 0}, + {44, 6, 0, 430}: {71, .47, 0, 0, 0}, + // msdagger + {44, 6, 1, 0}: {13, .40, 0, 0, 0}, + {44, 6, 1, 250}: {16, .40, 0, 0, 0}, + {44, 6, 1, 300}: {20, .40, 0, 0, 0}, + {44, 6, 1, 325}: {23, .40, 0, 0, 0}, + {44, 6, 1, 350}: {26, .40, 0, 0, 0}, + {44, 6, 1, 370}: {30, .40, 0, 0, 0}, + {44, 6, 1, 400}: {33, .40, 0, 0, 0}, + {44, 6, 1, 420}: {36, .40, 0, 0, 0}, + {44, 6, 1, 430}: {38, .40, 0, 0, 0}, + // mace + {44, 4, 0, 0}: {22, .30, 0, 0, 0}, + {44, 4, 0, 250}: {29, .30, 0, 0, 0}, + {44, 4, 0, 300}: {36, .30, 0, 0, 0}, + {44, 4, 0, 325}: {43, .30, 0, 0, 0}, + {44, 4, 0, 350}: {49, .30, 0, 0, 0}, + {44, 4, 0, 370}: {56, .30, 0, 0, 0}, + {44, 4, 0, 400}: {63, .30, 0, 0, 0}, + {44, 4, 0, 420}: {66, .30, 0, 0, 0}, + {44, 4, 0, 430}: {69, .30, 0, 0, 0}, + // spear + {44, 5, 0, 0}: {25, .59, 0, 0, 0}, + {44, 5, 0, 250}: {32, .59, 0, 0, 0}, + {44, 5, 0, 300}: {39, .59, 0, 0, 0}, + {44, 5, 0, 325}: {46, .59, 0, 0, 0}, + {44, 5, 0, 350}: {58, .59, 0, 0, 0}, + {44, 5, 0, 370}: {59, .59, 0, 0, 0}, + {44, 5, 0, 400}: {66, .59, 0, 0, 0}, + {44, 5, 0, 420}: {69, .59, 0, 0, 0}, + {44, 5, 0, 430}: {72, .59, 0, 0, 0}, + // sword + {44, 2, 0, 0}: {24, .47, 0, 0, 0}, + {44, 2, 0, 250}: {31, .47, 0, 0, 0}, + {44, 2, 0, 300}: {38, .47, 0, 0, 0}, + {44, 2, 0, 325}: {45, .47, 0, 0, 0}, + {44, 2, 0, 350}: {51, .47, 0, 0, 0}, + {44, 2, 0, 370}: {58, .47, 0, 0, 0}, + {44, 2, 0, 400}: {65, .47, 0, 0, 0}, + {44, 2, 0, 420}: {68, .47, 0, 0, 0}, + {44, 2, 0, 430}: {71, .47, 0, 0, 0}, + // mssword + {44, 2, 1, 0}: {13, .40, 0, 0, 0}, + {44, 2, 1, 250}: {16, .40, 0, 0, 0}, + {44, 2, 1, 300}: {20, .40, 0, 0, 0}, + {44, 2, 1, 325}: {23, .40, 0, 0, 0}, + {44, 2, 1, 350}: {26, .40, 0, 0, 0}, + {44, 2, 1, 370}: {30, .40, 0, 0, 0}, + {44, 2, 1, 400}: {33, .40, 0, 0, 0}, + {44, 2, 1, 420}: {36, .40, 0, 0, 0}, + {44, 2, 1, 430}: {38, .40, 0, 0, 0}, + // staff + {44, 7, 0, 0}: {23, .38, 0, 0, 0}, + {44, 7, 0, 250}: {30, .38, 0, 0, 0}, + {44, 7, 0, 300}: {36, .38, 0, 0, 0}, + {44, 7, 0, 325}: {43, .38, 0, 0, 0}, + {44, 7, 0, 350}: {50, .38, 0, 0, 0}, + {44, 7, 0, 370}: {56, .38, 0, 0, 0}, + {44, 7, 0, 400}: {63, .38, 0, 0, 0}, + {44, 7, 0, 420}: {66, .38, 0, 0, 0}, + {44, 7, 0, 430}: {70, .38, 0, 0, 0}, + // ua + {44, 1, 0, 0}: {20, .44, 0, 0, 0}, + {44, 1, 0, 250}: {26, .44, 0, 0, 0}, + {44, 1, 0, 300}: {31, .44, 0, 0, 0}, + {44, 1, 0, 325}: {37, .44, 0, 0, 0}, + {44, 1, 0, 350}: {43, .44, 0, 0, 0}, + {44, 1, 0, 370}: {48, .44, 0, 0, 0}, + {44, 1, 0, 400}: {54, .44, 0, 0, 0}, + {44, 1, 0, 420}: {56, .44, 0, 0, 0}, + {44, 1, 0, 430}: {59, .44, 0, 0, 0}, + + // light weaponry (skill 45) http://acpedia.org/wiki/Light_Weaponry + // axe + {45, 3, 0, 0}: {22, .80, 0, 0, 0}, + {45, 3, 0, 250}: {28, .80, 0, 0, 0}, + {45, 3, 0, 300}: {33, .80, 0, 0, 0}, + {45, 3, 0, 325}: {39, .80, 0, 0, 0}, + {45, 3, 0, 350}: {44, .80, 0, 0, 0}, + {45, 3, 0, 370}: {50, .80, 0, 0, 0}, + {45, 3, 0, 400}: {55, .80, 0, 0, 0}, + {45, 3, 0, 420}: {57, .80, 0, 0, 0}, + {45, 3, 0, 430}: {61, .80, 0, 0, 0}, + // dagger + {45, 6, 0, 0}: {18, .42, 0, 0, 0}, + {45, 6, 0, 250}: {24, .42, 0, 0, 0}, + {45, 6, 0, 300}: {29, .42, 0, 0, 0}, + {45, 6, 0, 325}: {35, .42, 0, 0, 0}, + {45, 6, 0, 350}: {40, .42, 0, 0, 0}, + {45, 6, 0, 370}: {46, .42, 0, 0, 0}, + {45, 6, 0, 400}: {51, .42, 0, 0, 0}, + {45, 6, 0, 420}: {54, .42, 0, 0, 0}, + {45, 6, 0, 430}: {58, .42, 0, 0, 0}, + // msdagger + {45, 6, 1, 0}: {7, .24, 0, 0, 0}, + {45, 6, 1, 250}: {11, .24, 0, 0, 0}, + {45, 6, 1, 300}: {13, .24, 0, 0, 0}, + {45, 6, 1, 325}: {16, .24, 0, 0, 0}, + {45, 6, 1, 350}: {18, .24, 0, 0, 0}, + {45, 6, 1, 370}: {21, .24, 0, 0, 0}, + {45, 6, 1, 400}: {24, .24, 0, 0, 0}, + {45, 6, 1, 420}: {27, .24, 0, 0, 0}, + {45, 6, 1, 430}: {28, .24, 0, 0, 0}, + // mace + {45, 4, 0, 0}: {19, .23, 0, 0, 0}, + {45, 4, 0, 250}: {24, .23, 0, 0, 0}, + {45, 4, 0, 300}: {29, .23, 0, 0, 0}, + {45, 4, 0, 325}: {35, .23, 0, 0, 0}, + {45, 4, 0, 350}: {40, .23, 0, 0, 0}, + {45, 4, 0, 370}: {45, .23, 0, 0, 0}, + {45, 4, 0, 400}: {50, .23, 0, 0, 0}, + {45, 4, 0, 420}: {53, .23, 0, 0, 0}, + {45, 4, 0, 430}: {57, .23, 0, 0, 0}, + // spear + {45, 5, 0, 0}: {21, .65, 0, 0, 0}, + {45, 5, 0, 250}: {26, .65, 0, 0, 0}, + {45, 5, 0, 300}: {32, .65, 0, 0, 0}, + {45, 5, 0, 325}: {37, .65, 0, 0, 0}, + {45, 5, 0, 350}: {42, .65, 0, 0, 0}, + {45, 5, 0, 370}: {48, .65, 0, 0, 0}, + {45, 5, 0, 400}: {53, .65, 0, 0, 0}, + {45, 5, 0, 420}: {57, .65, 0, 0, 0}, + {45, 5, 0, 430}: {60, .65, 0, 0, 0}, + // sword + {45, 2, 0, 0}: {20, .42, 0, 0, 0}, + {45, 2, 0, 250}: {25, .42, 0, 0, 0}, + {45, 2, 0, 300}: {31, .42, 0, 0, 0}, + {45, 2, 0, 325}: {36, .42, 0, 0, 0}, + {45, 2, 0, 350}: {41, .42, 0, 0, 0}, + {45, 2, 0, 370}: {47, .42, 0, 0, 0}, + {45, 2, 0, 400}: {52, .42, 0, 0, 0}, + {45, 2, 0, 420}: {55, .42, 0, 0, 0}, + {45, 2, 0, 430}: {58, .42, 0, 0, 0}, + // mssword + {45, 2, 1, 0}: {7, .24, 0, 0, 0}, + {45, 2, 1, 250}: {10, .24, 0, 0, 0}, + {45, 2, 1, 300}: {13, .24, 0, 0, 0}, + {45, 2, 1, 325}: {16, .24, 0, 0, 0}, + {45, 2, 1, 350}: {18, .24, 0, 0, 0}, + {45, 2, 1, 370}: {21, .24, 0, 0, 0}, + {45, 2, 1, 400}: {24, .24, 0, 0, 0}, + {45, 2, 1, 420}: {25, .24, 0, 0, 0}, + {45, 2, 1, 430}: {28, .24, 0, 0, 0}, + // staff + {45, 7, 0, 0}: {19, .325, 0, 0, 0}, + {45, 7, 0, 250}: {24, .325, 0, 0, 0}, + {45, 7, 0, 300}: {30, .325, 0, 0, 0}, + {45, 7, 0, 325}: {35, .325, 0, 0, 0}, + {45, 7, 0, 350}: {40, .325, 0, 0, 0}, + {45, 7, 0, 370}: {46, .325, 0, 0, 0}, + {45, 7, 0, 400}: {51, .325, 0, 0, 0}, + {45, 7, 0, 420}: {54, .325, 0, 0, 0}, + {45, 7, 0, 430}: {57, .325, 0, 0, 0}, + // ua + {45, 1, 0, 0}: {17, .43, 0, 0, 0}, + {45, 1, 0, 250}: {23, .43, 0, 0, 0}, + {45, 1, 0, 300}: {27, .43, 0, 0, 0}, + {45, 1, 0, 325}: {31, .43, 0, 0, 0}, + {45, 1, 0, 350}: {35, .43, 0, 0, 0}, + {45, 1, 0, 370}: {40, .43, 0, 0, 0}, + {45, 1, 0, 400}: {44, .43, 0, 0, 0}, + {45, 1, 0, 420}: {46, .43, 0, 0, 0}, + {45, 1, 0, 430}: {48, .43, 0, 0, 0}, + + // finesse weaponry (skill 46) http://acpedia.org/wiki/Finesse_Weaponry + // axe + {46, 3, 0, 0}: {22, .80, 0, 0, 0}, + {46, 3, 0, 250}: {28, .80, 0, 0, 0}, + {46, 3, 0, 300}: {33, .80, 0, 0, 0}, + {46, 3, 0, 325}: {39, .80, 0, 0, 0}, + {46, 3, 0, 350}: {44, .80, 0, 0, 0}, + {46, 3, 0, 370}: {50, .80, 0, 0, 0}, + {46, 3, 0, 400}: {55, .80, 0, 0, 0}, + {46, 3, 0, 420}: {57, .80, 0, 0, 0}, + {46, 3, 0, 430}: {61, .80, 0, 0, 0}, + // dagger + {46, 6, 0, 0}: {18, .42, 0, 0, 0}, + {46, 6, 0, 250}: {24, .42, 0, 0, 0}, + {46, 6, 0, 300}: {29, .42, 0, 0, 0}, + {46, 6, 0, 325}: {35, .42, 0, 0, 0}, + {46, 6, 0, 350}: {40, .42, 0, 0, 0}, + {46, 6, 0, 370}: {46, .42, 0, 0, 0}, + {46, 6, 0, 400}: {51, .42, 0, 0, 0}, + {46, 6, 0, 420}: {54, .42, 0, 0, 0}, + {46, 6, 0, 430}: {58, .42, 0, 0, 0}, + // msdagger + {46, 6, 1, 0}: {7, .24, 0, 0, 0}, + {46, 6, 1, 250}: {11, .24, 0, 0, 0}, + {46, 6, 1, 300}: {13, .24, 0, 0, 0}, + {46, 6, 1, 325}: {16, .24, 0, 0, 0}, + {46, 6, 1, 350}: {18, .24, 0, 0, 0}, + {46, 6, 1, 370}: {21, .24, 0, 0, 0}, + {46, 6, 1, 400}: {24, .24, 0, 0, 0}, + {46, 6, 1, 420}: {27, .24, 0, 0, 0}, + {46, 6, 1, 430}: {28, .24, 0, 0, 0}, + // mace + {46, 4, 0, 0}: {19, .23, 0, 0, 0}, + {46, 4, 0, 250}: {24, .23, 0, 0, 0}, + {46, 4, 0, 300}: {29, .23, 0, 0, 0}, + {46, 4, 0, 325}: {35, .23, 0, 0, 0}, + {46, 4, 0, 350}: {40, .23, 0, 0, 0}, + {46, 4, 0, 370}: {45, .23, 0, 0, 0}, + {46, 4, 0, 400}: {50, .23, 0, 0, 0}, + {46, 4, 0, 420}: {53, .23, 0, 0, 0}, + {46, 4, 0, 430}: {57, .23, 0, 0, 0}, + // spear + {46, 5, 0, 0}: {21, .65, 0, 0, 0}, + {46, 5, 0, 250}: {26, .65, 0, 0, 0}, + {46, 5, 0, 300}: {32, .65, 0, 0, 0}, + {46, 5, 0, 325}: {37, .65, 0, 0, 0}, + {46, 5, 0, 350}: {42, .65, 0, 0, 0}, + {46, 5, 0, 370}: {48, .65, 0, 0, 0}, + {46, 5, 0, 400}: {53, .65, 0, 0, 0}, + {46, 5, 0, 420}: {57, .65, 0, 0, 0}, + {46, 5, 0, 430}: {60, .65, 0, 0, 0}, + // sword + {46, 2, 0, 0}: {20, .42, 0, 0, 0}, + {46, 2, 0, 250}: {25, .42, 0, 0, 0}, + {46, 2, 0, 300}: {31, .42, 0, 0, 0}, + {46, 2, 0, 325}: {36, .42, 0, 0, 0}, + {46, 2, 0, 350}: {41, .42, 0, 0, 0}, + {46, 2, 0, 370}: {47, .42, 0, 0, 0}, + {46, 2, 0, 400}: {52, .42, 0, 0, 0}, + {46, 2, 0, 420}: {55, .42, 0, 0, 0}, + {46, 2, 0, 430}: {58, .42, 0, 0, 0}, + // mssword + {46, 2, 1, 0}: {7, .24, 0, 0, 0}, + {46, 2, 1, 250}: {10, .24, 0, 0, 0}, + {46, 2, 1, 300}: {13, .24, 0, 0, 0}, + {46, 2, 1, 325}: {16, .24, 0, 0, 0}, + {46, 2, 1, 350}: {18, .24, 0, 0, 0}, + {46, 2, 1, 370}: {21, .24, 0, 0, 0}, + {46, 2, 1, 400}: {24, .24, 0, 0, 0}, + {46, 2, 1, 420}: {25, .24, 0, 0, 0}, + {46, 2, 1, 430}: {28, .24, 0, 0, 0}, + // staff + {46, 7, 0, 0}: {19, .325, 0, 0, 0}, + {46, 7, 0, 250}: {24, .325, 0, 0, 0}, + {46, 7, 0, 300}: {30, .325, 0, 0, 0}, + {46, 7, 0, 325}: {35, .325, 0, 0, 0}, + {46, 7, 0, 350}: {40, .325, 0, 0, 0}, + {46, 7, 0, 370}: {46, .325, 0, 0, 0}, + {46, 7, 0, 400}: {51, .325, 0, 0, 0}, + {46, 7, 0, 420}: {54, .325, 0, 0, 0}, + {46, 7, 0, 430}: {57, .325, 0, 0, 0}, + // ua + {46, 1, 0, 0}: {17, .43, 0, 0, 0}, + {46, 1, 0, 250}: {23, .43, 0, 0, 0}, + {46, 1, 0, 300}: {27, .43, 0, 0, 0}, + {46, 1, 0, 325}: {31, .43, 0, 0, 0}, + {46, 1, 0, 350}: {35, .43, 0, 0, 0}, + {46, 1, 0, 370}: {40, .43, 0, 0, 0}, + {46, 1, 0, 400}: {44, .43, 0, 0, 0}, + {46, 1, 0, 420}: {46, .43, 0, 0, 0}, + {46, 1, 0, 430}: {48, .43, 0, 0, 0}, + + // twohanded (skill 41) http://acpedia.org/wiki/Two_Handed_Weaponry + // cleaver + {41, 11, 1, 0}: {13, .30, 0, 0, 0}, + {41, 11, 1, 250}: {17, .30, 0, 0, 0}, + {41, 11, 1, 300}: {22, .30, 0, 0, 0}, + {41, 11, 1, 325}: {26, .30, 0, 0, 0}, + {41, 11, 1, 350}: {30, .30, 0, 0, 0}, + {41, 11, 1, 370}: {35, .30, 0, 0, 0}, + {41, 11, 1, 400}: {39, .30, 0, 0, 0}, + {41, 11, 1, 420}: {42, .30, 0, 0, 0}, + {41, 11, 1, 430}: {45, .30, 0, 0, 0}, + // spear + {41, 11, 0, 0}: {14, .35, 0, 0, 0}, + {41, 11, 0, 250}: {19, .35, 0, 0, 0}, + {41, 11, 0, 300}: {24, .35, 0, 0, 0}, + {41, 11, 0, 325}: {29, .35, 0, 0, 0}, + {41, 11, 0, 350}: {33, .35, 0, 0, 0}, + {41, 11, 0, 370}: {37, .35, 0, 0, 0}, + {41, 11, 0, 400}: {42, .35, 0, 0, 0}, + {41, 11, 0, 420}: {45, .35, 0, 0, 0}, + {41, 11, 0, 430}: {48, .35, 0, 0, 0}, + + // missile (skill 47) http://acpedia.org/wiki/Missile_Weaponry/Bows + // bow (mastery 8) + {47, 8, 0, 0}: {0, 0, 110, 0, 0}, + {47, 8, 0, 250}: {0, 0, 120, 0, 0}, + {47, 8, 0, 270}: {0, 0, 130, 0, 0}, + {47, 8, 0, 290}: {0, 0, 140, 0, 0}, + {47, 8, 0, 315}: {0, 0, 140, 5, 0}, + {47, 8, 0, 335}: {0, 0, 140, 9, 0}, + {47, 8, 0, 360}: {0, 0, 140, 16, 0}, + {47, 8, 0, 375}: {0, 0, 140, 19, 0}, + {47, 8, 0, 385}: {0, 0, 140, 22, 0}, + // xbow (mastery 9) + {47, 9, 0, 0}: {0, 0, 140, 0, 0}, + {47, 9, 0, 250}: {0, 0, 150, 0, 0}, + {47, 9, 0, 270}: {0, 0, 155, 0, 0}, + {47, 9, 0, 290}: {0, 0, 165, 0, 0}, + {47, 9, 0, 315}: {0, 0, 165, 5, 0}, + {47, 9, 0, 335}: {0, 0, 165, 9, 0}, + {47, 9, 0, 360}: {0, 0, 165, 16, 0}, + {47, 9, 0, 375}: {0, 0, 165, 19, 0}, + {47, 9, 0, 385}: {0, 0, 165, 22, 0}, + // thrown (mastery 10) + {47, 10, 0, 0}: {0, 0, 130, 0, 0}, + {47, 10, 0, 250}: {0, 0, 140, 0, 0}, + {47, 10, 0, 270}: {0, 0, 150, 0, 0}, + {47, 10, 0, 290}: {0, 0, 160, 0, 0}, + {47, 10, 0, 315}: {0, 0, 160, 5, 0}, + {47, 10, 0, 335}: {0, 0, 160, 9, 0}, + {47, 10, 0, 360}: {0, 0, 160, 16, 0}, + {47, 10, 0, 375}: {0, 0, 160, 19, 0}, + {47, 10, 0, 385}: {0, 0, 160, 22, 0}, + + // wands (skill 34 war / 43 void) http://acpedia.org/wiki/Magic_Casters + {34, 0, 0, 290}: {0, 0, 0, 0, 1.03}, + {34, 0, 0, 315}: {0, 0, 0, 0, 1.06}, + {34, 0, 0, 335}: {0, 0, 0, 0, 1.09}, + {34, 0, 0, 360}: {0, 0, 0, 0, 1.13}, + {34, 0, 0, 375}: {0, 0, 0, 0, 1.16}, + {34, 0, 0, 385}: {0, 0, 0, 0, 1.18}, + + {43, 0, 0, 290}: {0, 0, 0, 0, 1.03}, + {43, 0, 0, 315}: {0, 0, 0, 0, 1.06}, + {43, 0, 0, 335}: {0, 0, 0, 0, 1.09}, + {43, 0, 0, 360}: {0, 0, 0, 0, 1.13}, + {43, 0, 0, 375}: {0, 0, 0, 0, 1.16}, + {43, 0, 0, 385}: {0, 0, 0, 0, 1.18}, + // void vr wand - not sure why this is different (mastery 12) + {43, 12, 0, 385}: {0, 0, 0, 0, 1.18}, +} + +// maxDamageSpellBonus is transcribed from UB Dictionaries.cs +// LongValueKeySpellEffects entries keyed on LongValueKey_MaxDamage (218103842). +var maxDamageSpellBonus = map[int]int{ + 1616: 20, // Blood Drinker VI + 2096: 22, // Infected Caress + 5183: 24, // Incantation of Blood Drinker (post Feb-2013) + 4395: 24, // Incantation of Blood Drinker (post Feb-2013) + 2598: 2, // Minor Blood Thirst + 2586: 4, // Major Blood Thirst + 4661: 7, // Epic Blood Thirst + 6089: 10, // Legendary Blood Thirst + 3688: 300, // Prodigal Blood Drinker +} + +// elemVsMonSpellBonus is transcribed from UB Dictionaries.cs +// DoubleValueKeySpellEffects entries keyed on DoubleValueKey_ElementalDamageVersusMonsters (152). +var elemVsMonSpellBonus = map[int]float64{ + 3258: .06, // Spirit Drinker VI + 3259: .07, // Infected Spirit Caress + 5182: .08, // Incantation of Spirit Drinker (post Feb-2013) + 4414: .08, // Incantation of Spirit Drinker (post Feb-2013) + 3251: .01, // Minor Spirit Thirst + 3250: .03, // Major Spirit Thirst + 4670: .05, // Epic Spirit Thirst + 6098: .07, // Legendary Spirit Thirst +} + +// buffedMaxDamage mirrors GetBuffedIntValueKey(218103842): base value plus +// bonus for each innate spell (raw["Spells"]), minus bonus for each active +// spell (raw["ActiveSpells"]). +func buffedMaxDamage(raw, iv map[string]any) int { + value := ivI(iv, "218103842", 0) + for _, id := range toIntList(raw["Spells"]) { + if b, ok := maxDamageSpellBonus[id]; ok { + value += b + } + } + for _, id := range toIntList(raw["ActiveSpells"]) { + if b, ok := maxDamageSpellBonus[id]; ok { + value -= b + } + } + return value +} + +// buffedElemVsMon mirrors GetBuffedDoubleValueKey(152) using the same +// innate-add / active-subtract semantics. +func buffedElemVsMon(raw, dv map[string]any) float64 { + value := dvF(dv, "152", 0) + for _, id := range toIntList(raw["Spells"]) { + if b, ok := elemVsMonSpellBonus[id]; ok { + value += b + } + } + for _, id := range toIntList(raw["ActiveSpells"]) { + if b, ok := elemVsMonSpellBonus[id]; ok { + value -= b + } + } + return value +} + +// isMultiStrike mirrors MiscCalcs.GetMaxProperty's MultiStrike detection: +// IntValues[47] (WeaponType master id) ∈ {160,166,486}, or (==4 and Mastery==11). +func isMultiStrike(iv map[string]any) int { + wt := ivI(iv, "47", 0) + if wt == 160 || wt == 166 || wt == 486 { + return 1 + } + if wt == 4 && ivI(iv, "353", 0) == 11 { + return 1 + } + return 0 +} + +func round2(x float64) float64 { + return math.Round(x*100) / 100 +} + +// computeOD ports UB's OD display formulas (ItemInfo.cs CalcMeleeDamage / +// CalcMissileDamage + the OD lines ~631-647). Returns (0, false) when OD does +// not apply: non weapon/wand object class, non-loot-gen item (no +// SalvageWorkmanship), or an unrecognized (skill, mastery, multiStrike, +// wieldReq) tier (exact match only — no nearest-tier rounding). +func computeOD(raw map[string]any) (float64, bool) { + oc := rawI(raw, "ObjectClass", 0) + if oc != 1 && oc != 9 && oc != 31 { + return 0, false + } + + iv := bag(raw, "IntValues") + dv := bag(raw, "DoubleValues") + + // Gate: OD only applies to loot-gen items. + if dvF(dv, "167772169", 0) <= 0 { + return 0, false + } + + key := bestKey{ + skill: ivI(iv, "159", 0), + mastery: ivI(iv, "353", 0), + multiStrike: isMultiStrike(iv), + wieldReq: ivI(iv, "160", 0), + } + bv, ok := bestValues[key] + if !ok && oc == 31 { + // Wands: our probe showed some wands lack key 353 (mastery), which + // defaults to 0 via ivI — but retry explicitly with mastery 0 so the + // void VR wand row (43,12,...) stays reachable while items that + // genuinely carry a different mastery value still fall back to the + // normal (34|43, 0, ...) rows. + key.mastery = 0 + bv, ok = bestValues[key] + } + if !ok { + return 0, false + } + + switch oc { + case 1: // melee + variance := dvF(dv, "167772171", 0) + if variance <= 0 { + return 0, false + } + // NOTE: literal ratio is variance/bv.maxVar, not bv.maxVar/variance as + // the plan's prose (and the UB source's `perfectVariance/Variance` + // argument order) states — see od.go deviation note below and the + // implementation report for why. + varianceTinks := round2(math.Log(variance/bv.maxVar) / math.Log(0.8)) + od := float64(buffedMaxDamage(raw, iv)) - varianceTinks - bv.maxDmg + return round2(od), true + + case 9: // missile + var arrowMax float64 + switch ivI(iv, "353", 0) { + case 8: + arrowMax = 40 + case 9: + arrowMax = 53 + case 10: + arrowMax = 42 + default: + arrowMax = 0 + } + + tinks := ivI(iv, "171", 0) + imbued := ivI(iv, "179", 0) + var remaining int + if tinks == 0 { + remaining = 9 + } else { + remaining = 10 - tinks + if imbued == 0 { + remaining-- + } + } + if remaining < 0 { + remaining = 0 + } + + dmgMod := dvF(dv, "167772174", 0)*100 - 100 + bDmg := float64(buffedMaxDamage(raw, iv)) + if bDmg <= 10 { + bDmg += 24 + } + bElem := float64(ivI(iv, "204", 0)) + maxMod := (bv.maxDmgMod + 136) / 100 + + od := (1+(dmgMod+4*float64(remaining))/100)*(bElem+bDmg+arrowMax)/maxMod - (bv.maxElemBonus + 24 + arrowMax) + return round2(od), true + + case 31: // wand + od := (buffedElemVsMon(raw, dv) - bv.maxElemVsMon) * 100 + return round2(od), true + } + + return 0, false +} diff --git a/go-services/inventory-go/od_test.go b/go-services/inventory-go/od_test.go new file mode 100644 index 00000000..48b43bcb --- /dev/null +++ b/go-services/inventory-go/od_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "math" + "testing" +) + +func approx(a, b float64) bool { return math.Abs(a-b) < 0.01 } + +// Wand: OD = (buffedElemVsMonsters - tableMaxElemVsMonsters) * 100. +// Live "Frost Baton": skill 34 (war), wieldreq 385, ElemVsMonsters 1.40, +// no spirit-drinker spells. Table max for (34,*,*,385) = 1.18 → OD = +22. +func TestOD_Wand(t *testing.T) { + raw := map[string]any{ + "ObjectClass": 31.0, + "IntValues": map[string]any{"159": 34.0, "160": 385.0}, + "DoubleValues": map[string]any{"152": 1.40, "167772169": 8.0}, + "Spells": []any{}, "ActiveSpells": []any{}, + } + od, ok := computeOD(raw) + if !ok || !approx(od, 22.0) { + t.Fatalf("wand OD = %v ok=%v, want +22", od, ok) + } +} + +// Non-loot (no workmanship) → no OD. +func TestOD_NonLootNull(t *testing.T) { + raw := map[string]any{ + "ObjectClass": 31.0, + "IntValues": map[string]any{"159": 34.0, "160": 385.0}, + "DoubleValues": map[string]any{"152": 1.40}, + "Spells": []any{}, "ActiveSpells": []any{}, + } + if _, ok := computeOD(raw); ok { + t.Fatal("no-workmanship item must have no OD") + } +} + +// Table lookup miss (unknown tier) → no OD. +func TestOD_UnknownTierNull(t *testing.T) { + raw := map[string]any{ + "ObjectClass": 31.0, + "IntValues": map[string]any{"159": 34.0, "160": 999.0}, + "DoubleValues": map[string]any{"152": 1.40, "167772169": 8.0}, + "Spells": []any{}, "ActiveSpells": []any{}, + } + if _, ok := computeOD(raw); ok { + t.Fatal("unknown wieldreq tier must have no OD") + } +} + +// Melee: OD = buffedMaxDmg - varianceTinks - tableMaxDmg, +// varianceTinks = round(log(tableMaxVar/variance)/log(0.8), 2). +// Heavy (44) sword (2) non-MS, wieldreq 430: tableMaxDmg=71, tableMaxVar=0.47. +// A perfect retail-max item: maxDmg 71, variance 0.47 → varianceTinks=0 → OD 0. +func TestOD_MeleeRetailMaxIsZero(t *testing.T) { + raw := map[string]any{ + "ObjectClass": 1.0, + "IntValues": map[string]any{"159": 44.0, "353": 2.0, "160": 430.0, "218103842": 71.0}, + "DoubleValues": map[string]any{"167772171": 0.47, "167772169": 10.0}, + "Spells": []any{}, "ActiveSpells": []any{}, + } + od, ok := computeOD(raw) + if !ok || !approx(od, 0.0) { + t.Fatalf("melee retail-max OD = %v ok=%v, want 0", od, ok) + } +} + +// Melee with tighter variance (0.376 = one granite tink below 0.47): +// varianceTinks = round(log(0.47/0.376)/log(0.8), 2) = round(1.00,2)=1 → OD = 71-1-71 = -1. +func TestOD_MeleeOneTinkVariance(t *testing.T) { + raw := map[string]any{ + "ObjectClass": 1.0, + "IntValues": map[string]any{"159": 44.0, "353": 2.0, "160": 430.0, "218103842": 71.0}, + "DoubleValues": map[string]any{"167772171": 0.376, "167772169": 10.0}, + "Spells": []any{}, "ActiveSpells": []any{}, + } + od, ok := computeOD(raw) + if !ok || !approx(od, -1.0) { + t.Fatalf("melee OD = %v ok=%v, want -1", od, ok) + } +} + +// Melee buffed: innate Legendary Blood Thirst (id 6089, +10 MaxDamage). +// maxDmg 61 + 10 = 71, variance 0.47 → OD 0 (vs 71 table). +func TestOD_MeleeBuffedByBloodThirst(t *testing.T) { + raw := map[string]any{ + "ObjectClass": 1.0, + "IntValues": map[string]any{"159": 44.0, "353": 2.0, "160": 430.0, "218103842": 61.0}, + "DoubleValues": map[string]any{"167772171": 0.47, "167772169": 10.0}, + "Spells": []any{6089.0}, "ActiveSpells": []any{}, + } + od, ok := computeOD(raw) + if !ok || !approx(od, 0.0) { + t.Fatalf("melee buffed OD = %v ok=%v, want 0", od, ok) + } +}