diff --git a/go-services/inventory-go/od.go b/go-services/inventory-go/od.go index 00aa18d2..cb4186c9 100644 --- a/go-services/inventory-go/od.go +++ b/go-services/inventory-go/od.go @@ -1,577 +1,116 @@ package main -import "math" +import "strings" -// 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. +// Weapon OD = the user's VirindiTank Loot.utl classification (0–10, casters +// 0–7), printed by Mag-Tools on ident. OD = clamp(stat − baseline, 1, cap) for +// the first matching bucket, else null. Buckets/baselines transcribed from +// Loot.utl (see design doc 2026-07-15-weapon-od-loot-profile). -// 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 +type odBucket struct { + objectClass int // 1 melee, 9 missile, 31 caster + skill int // 218103840 for melee/missile, 159 for casters; 0 = any + mastery int // IntValues 353; -1 = not checked + nameAlts []string // OR of case-sensitive substrings; empty = no name gate + statKey string // IntValues key (melee/missile) or "" + dblStatKey string // DoubleValues key (casters) or "" + baseline float64 + cap 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 +// Ordered: name-gated buckets are effectively specific; first match wins. +var odBuckets = []odBucket{ + // --- Melee (object_class 1, stat MaxDamage 218103842, cap 10) --- + {1, 44, 3, []string{"Silifi", "Lugian", "War Axe", "Battle"}, "218103842", "", 74, 10}, // (H) Axe + {1, 45, 3, []string{"Dolabra", "Ono", "Hand", "War Hammer"}, "218103842", "", 61, 10}, // (L) Axe + {1, 46, 3, []string{"Hatchet", "Shou-ono", "Tungi", "Hammer"}, "218103842", "", 61, 10}, // (F) Axe + {1, 44, 6, []string{"Stiletto", "Jambiya"}, "218103842", "", 38, 10}, // (H) Dagger MS + {1, 44, 6, nil, "218103842", "", 71, 10}, // (H) Dagger + {1, 45, 6, nil, "218103842", "", 58, 10}, // (L) Dagger (MS shares names=nil; see note) + {1, 46, 6, []string{"Knife", "Lancet"}, "218103842", "", 28, 10}, // (F) Dagger MS + {1, 46, 6, nil, "218103842", "", 58, 10}, // (F) Dagger + {1, 44, 4, []string{"Mazule", "Mace", "Morning"}, "218103842", "", 69, 10}, // (H) Mace + {1, 45, 4, []string{"Club", "Kasrullah"}, "218103842", "", 56, 10}, // (L) Mace + {1, 46, 4, []string{"Board", "Tofun", "Dabus"}, "218103842", "", 56, 10}, // (F) Mace + {1, 44, 5, []string{"Glaive", "Trident", "Partizan"}, "218103842", "", 72, 10}, // (H) Spear + {1, 45, 5, []string{"Spear", "Yari"}, "218103842", "", 60, 10}, // (L) Spear + {1, 44, 7, []string{"Nabut", "Stick"}, "218103842", "", 69, 10}, // (H) Staff + {1, 45, 7, nil, "218103842", "", 57, 10}, // (L) Staff + {1, 46, 7, []string{"Jo", "Bastone"}, "218103842", "", 57, 10}, // (F) Staff + {1, 44, 2, []string{"Takuba", "Flamberge", "Ken", "Long", "Tachi"}, "218103842", "", 71, 10}, // (H) Sword + {1, 45, 2, []string{"Dericost", "Broad", "Shamshir", "Kaskara", "Spada"}, "218103842", "", 58, 10}, // (L) Sword + {1, 46, 2, []string{"Scimitar", "Yaoji", "Short", "Sabra", "Simi"}, "218103842", "", 58, 10}, // (F) Sword + {1, 44, 1, []string{"Nekode", "Cestus"}, "218103842", "", 59, 10}, // (H) UA + {1, 45, 1, []string{"Katar", "Knuckles"}, "218103842", "", 48, 10}, // (L) UA + {1, 46, 1, []string{"Claw", "Wraps"}, "218103842", "", 48, 10}, // (F) UA + {1, 41, -1, []string{"Nodachi", "Shashqa", "Spadone", "Greataxe", "Quadrelle", "Khanda-handled", "Tetsubo", "Star"}, "218103842", "", 45, 10}, // (2H) Cleaving + {1, 41, -1, []string{"Assagai", "Pike", "Magari", "Corsesca"}, "218103842", "", 48, 10}, // (2H) Spear + // --- Crossbow (object_class 9, mastery 9, stat 218103839, cap 10) --- + {9, 47, 9, nil, "218103839", "", 77, 10}, + // --- Casters (object_class 31, stat elemVsMon 152, cap 7) --- + {31, 34, -1, []string{"Baton", "Sceptre", "Staff"}, "", "152", 1.18, 7}, // War + {31, 43, -1, []string{"Nether"}, "", "152", 1.18, 7}, // Void + // Bows (mastery 8) and Thrown (mastery 10) are intentionally ABSENT → null. } -// 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}, -} - -// spellEffect mirrors UB's SpellInfo(key, change, bonus) (Dictionaries.cs:356): -// `change` is what the active enchantment adds to the shown value (subtracted -// to un-buff), `bonus` is what the spell grants when innate on the item (0 for -// the 2012 aura-converted item spells). -type odSpellEffect struct { - change float64 - bonus float64 -} - -// maxDamageSpellEffects is transcribed from UB Dictionaries.cs -// LongValueKeySpellEffects entries keyed on LongValueKey_MaxDamage (218103842). -var maxDamageSpellEffects = map[int]odSpellEffect{ - 1616: {20, 0}, // Blood Drinker VI - 2096: {22, 0}, // Infected Caress - 5183: {24, 0}, // Incantation of Blood Drinker (post Feb-2013) - 4395: {24, 0}, // Incantation of Blood Drinker (post Feb-2013) - 2598: {2, 2}, // Minor Blood Thirst - 2586: {4, 4}, // Major Blood Thirst - 4661: {7, 7}, // Epic Blood Thirst - 6089: {10, 10}, // Legendary Blood Thirst - 3688: {300, 0}, // Prodigal Blood Drinker -} - -// elemVsMonSpellEffects is transcribed from UB Dictionaries.cs -// DoubleValueKeySpellEffects entries keyed on DoubleValueKey_ElementalDamageVersusMonsters (152). -var elemVsMonSpellEffects = map[int]odSpellEffect{ - 3258: {.06, 0}, // Spirit Drinker VI - 3259: {.07, 0}, // Infected Spirit Caress - 5182: {.08, 0}, // Incantation of Spirit Drinker (post Feb-2013) - 4414: {.08, 0}, // Incantation of Spirit Drinker (post Feb-2013) - 3251: {.01, .01}, // Minor Spirit Thirst - 3250: {.03, .03}, // Major Spirit Thirst - 4670: {.05, .05}, // Epic Spirit Thirst - 6098: {.07, .07}, // Legendary Spirit Thirst - 3735: {.15, 0}, // Prodigal Spirit Drinker -} - -// buffedMaxDamage mirrors GetBuffedIntValueKey(218103842): base value minus -// `change` for each active spell (un-buff the enchantment), plus `bonus` for -// each innate spell. -func buffedMaxDamage(raw, iv map[string]any) int { - value := ivI(iv, "218103842", 0) - for _, id := range toIntList(raw["ActiveSpells"]) { - if e, ok := maxDamageSpellEffects[id]; ok { - value -= int(e.change) +func nameMatches(name string, alts []string) bool { + if len(alts) == 0 { + return true + } + for _, a := range alts { + if strings.Contains(name, a) { + return true } } - for _, id := range toIntList(raw["Spells"]) { - if e, ok := maxDamageSpellEffects[id]; ok { - value += int(e.bonus) - } - } - return value + return false } -// buffedElemVsMon mirrors GetBuffedDoubleValueKey(152) using the same -// subtract-change-for-active / add-bonus-for-innate semantics. -func buffedElemVsMon(raw, dv map[string]any) float64 { - value := dvF(dv, "152", 0) - for _, id := range toIntList(raw["ActiveSpells"]) { - if e, ok := elemVsMonSpellEffects[id]; ok { - value -= e.change - } - } - for _, id := range toIntList(raw["Spells"]) { - if e, ok := elemVsMonSpellEffects[id]; ok { - value += e.bonus - } - } - 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). +// computeOD returns the loot-profile OD (integer, as float64 for the DB column) +// and ok=false when no bucket matches or the stat is at/below baseline. 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") + name := rawS(raw, "Name") + skillMelee := ivI(iv, "218103840", 0) + skillCaster := ivI(iv, "159", 0) + mastery := ivI(iv, "353", -999) - // 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 { - // Mastery-0 retry for wands: an ADDITION over UB (UB's - // DataTable.Select has no retry; our captured wands may carry a - // mastery key 353 while the table's wand rows use mastery 0). - // Keeps the void VR wand row (43,12,...) reachable on the first - // exact-match attempt while other mastery-carrying wands 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 { + for _, b := range odBuckets { + if b.objectClass != oc { + continue + } + sk := skillMelee + if b.objectClass == 31 { + sk = skillCaster + } + if b.skill != 0 && b.skill != sk { + continue + } + if b.mastery != -1 && b.mastery != mastery { + continue + } + if !nameMatches(name, b.nameAlts) { + continue + } + var stat float64 + if b.dblStatKey != "" { + stat = dvF(dv, b.dblStatKey, 0) + } else { + stat = float64(ivI(iv, b.statKey, 0)) + } + od := stat - b.baseline + if b.dblStatKey != "" { + od = od * 100 // caster elemVsMon: percentage points + } + odInt := int(od + 0.5) // round + if odInt < 1 { return 0, false } - // UB ItemInfo.cs:1096: Math.Log(perfectVariance / Variance, 0.8). - // Table MaxVar is the tier's baseline variance; lower variance than - // baseline yields negative varianceTinks, i.e. a positive OD credit. - varianceTinks := round2(math.Log(bv.maxVar/variance) / 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 + if odInt > b.cap { + odInt = b.cap } - - 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 - } - - // NOTE: UB's literal CalcMissileDamage reads DoubleValues[63], but no - // DoubleValueKey ordinal 63 exists (verified against Decal.Adapter) — - // that line is a stale/bugged reference in the UB source. The correct - // populated key is 167772174 (DamageBonus), consistent with UB's own - // CalcedBuffedMissileDamage. Do NOT "fix" this back to 63. - 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 float64(odInt), true } - return 0, false } diff --git a/go-services/inventory-go/od_test.go b/go-services/inventory-go/od_test.go index e725294d..24368114 100644 --- a/go-services/inventory-go/od_test.go +++ b/go-services/inventory-go/od_test.go @@ -1,180 +1,78 @@ package main -import ( - "math" - "testing" -) +import "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) +func TestOD_MeleeTetsubo(t *testing.T) { + // 2H Cleaving baseline 45, name "Tetsubo", maxdmg 85 → clamp(85-45,1,10)=10. + raw := map[string]any{"ObjectClass": 1.0, "Name": "Tetsubo", + "IntValues": map[string]any{"218103840": 41.0, "353": 11.0, "218103842": 85.0}} + if od, ok := computeOD(raw); !ok || od != 10 { + t.Fatalf("Tetsubo OD=%v ok=%v want 10", od, ok) } } -// Wand with an ACTIVE Prodigal Spirit Drinker (3735, change .15, bonus 0): -// shown ElemVsMonsters 1.55 un-buffs to 1.55 − .15 = 1.40 → OD +22, same -// underlying weapon as TestOD_Wand. -func TestOD_WandActiveProdigalSpiritDrinker(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.55, "167772169": 8.0}, - "Spells": []any{}, "ActiveSpells": []any{3735.0}, - } - od, ok := computeOD(raw) - if !ok || !approx(od, 22.0) { - t.Fatalf("wand w/ active Prodigal SD OD = %v ok=%v, want +22", od, ok) +func TestOD_MeleeMidTier(t *testing.T) { + // Heavy Axe baseline 74, name "War Axe", maxdmg 79 → clamp(79-74,1,10)=5. + raw := map[string]any{"ObjectClass": 1.0, "Name": "Bronze War Axe", + "IntValues": map[string]any{"218103840": 44.0, "353": 3.0, "218103842": 79.0}} + if od, ok := computeOD(raw); !ok || od != 5 { + t.Fatalf("War Axe OD=%v ok=%v want 5", od, ok) } } -// Wand carrying a mastery value (353) with no matching wand table row: the -// exact-match lookup on (34,5,0,385) misses, and the mastery-0 retry must hit -// the (34,0,0,385) row → OD +22 as in TestOD_Wand. -func TestOD_WandMasteryFallback(t *testing.T) { - raw := map[string]any{ - "ObjectClass": 31.0, - "IntValues": map[string]any{"159": 34.0, "353": 5.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 mastery-fallback 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{}, - } +func TestOD_MeleeBelowBaselineNull(t *testing.T) { + // Heavy Axe baseline 74, maxdmg 74 → stat-baseline=0 (<1) → null. + raw := map[string]any{"ObjectClass": 1.0, "Name": "Bronze War Axe", + "IntValues": map[string]any{"218103840": 44.0, "353": 3.0, "218103842": 74.0}} if _, ok := computeOD(raw); ok { - t.Fatal("no-workmanship item must have no OD") + t.Fatal("maxdmg == baseline must be null") } } -// 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{}, - } +func TestOD_MeleeNameGateNull(t *testing.T) { + // Heavy Axe bucket requires name in {Silifi,Lugian,War Axe,Battle}. A skill-44 + // mastery-3 item NOT matching any name and matching no other bucket → null. + raw := map[string]any{"ObjectClass": 1.0, "Name": "Bronze Frobnicator", + "IntValues": map[string]any{"218103840": 44.0, "353": 3.0, "218103842": 84.0}} if _, ok := computeOD(raw); ok { - t.Fatal("unknown wieldreq tier must have no OD") + t.Fatal("name not matching the Heavy Axe filter must be null") } } -// 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) +func TestOD_Crossbow(t *testing.T) { + // Xbow baseline 77, k839=89 → clamp(89-77,1,10)=10. + raw := map[string]any{"ObjectClass": 9.0, "Name": "Fire Compound Crossbow", + "IntValues": map[string]any{"218103840": 47.0, "353": 9.0, "218103839": 89.0}} + if od, ok := computeOD(raw); !ok || od != 10 { + t.Fatalf("Xbow OD=%v ok=%v want 10", od, ok) } } -// Melee with tighter variance (0.376 = one granite tink below the 0.47 baseline): -// the table MaxVar is the BASELINE variance for the tier, and lower variance than -// baseline is better — it contributes positively to OD. Per UB ItemInfo.cs:1096 -// (Math.Log(perfectVariance / Variance, 0.8)): -// 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) +func TestOD_WarCaster(t *testing.T) { + // War caster: elemVsMon 1.40 → clamp(round((1.40-1.18)*100),1,7)=clamp(22,1,7)=7. + raw := map[string]any{"ObjectClass": 31.0, "Name": "Diamond Frost Baton", + "IntValues": map[string]any{"159": 34.0}, + "DoubleValues": map[string]any{"152": 1.40}} + if od, ok := computeOD(raw); !ok || od != 7 { + t.Fatalf("War caster OD=%v ok=%v want 7", 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) +func TestOD_VoidCasterTier(t *testing.T) { + // Void caster: name must contain "Nether"; elemVsMon 1.22 → round((1.22-1.18)*100)=4. + raw := map[string]any{"ObjectClass": 31.0, "Name": "Silver Nether Staff", + "IntValues": map[string]any{"159": 43.0}, + "DoubleValues": map[string]any{"152": 1.22}} + if od, ok := computeOD(raw); !ok || od != 4 { + t.Fatalf("Void caster OD=%v ok=%v want 4", od, ok) } } -// Exact SpellInfo(change, bonus) semantics (UB Dictionaries.cs:356): innate -// Spells add `bonus`, ActiveSpells subtract `change`. Incantation of Blood -// Drinker (5183) has change=24 but bonus=0 (the 2012 aura conversion), so: -// - innate 5183 grants NOTHING → maxDmg 71 + 0 = 71 → OD 0; -// - active 5183 on an enchanted maxDmg 95 (71+24) subtracts 24 → 71 → OD 0. -func TestOD_MeleeInnateIncantBDGrantsNothing(t *testing.T) { - innate := 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{5183.0}, "ActiveSpells": []any{}, - } - od, ok := computeOD(innate) - if !ok || !approx(od, 0.0) { - t.Fatalf("innate Incant BD OD = %v ok=%v, want 0 (bonus is 0)", od, ok) - } - - active := map[string]any{ - "ObjectClass": 1.0, - "IntValues": map[string]any{"159": 44.0, "353": 2.0, "160": 430.0, "218103842": 95.0}, - "DoubleValues": map[string]any{"167772171": 0.47, "167772169": 10.0}, - "Spells": []any{}, "ActiveSpells": []any{5183.0}, - } - od, ok = computeOD(active) - if !ok || !approx(od, 0.0) { - t.Fatalf("active Incant BD OD = %v ok=%v, want 0 (change 24 subtracted)", od, ok) - } -} - -// Missile golden test, modeled on the live Fire Compound Bow: -// bow (mastery 8), wieldreq 385, maxDmg 32, elem bonus 21, DamageBonus 2.73, -// no "171" key → tinks 0 → remaining 9 (the imbue tink is reserved). -// dmgMod = 2.73·100−100 = 173; arrowMax = 40; maxMod = (140+136)/100 = 2.76; -// OD = (1+(173+4·9)/100)·(21+32+40)/2.76 − (22+24+40) = 3.09·93/2.76 − 86 ≈ 18.12. -func TestOD_MissileBow(t *testing.T) { - raw := map[string]any{ - "ObjectClass": 9.0, - "IntValues": map[string]any{"159": 47.0, "353": 8.0, "160": 385.0, "218103842": 32.0, "204": 21.0}, - "DoubleValues": map[string]any{"167772174": 2.73, "167772169": 6.0}, - "Spells": []any{}, "ActiveSpells": []any{}, - } - od, ok := computeOD(raw) - if !ok || !approx(od, 18.12) { - t.Fatalf("missile bow OD = %v ok=%v, want 18.12", od, ok) +func TestOD_BowDeferredNull(t *testing.T) { + // Bows (mastery 8) are deferred → always null in this pass. + raw := map[string]any{"ObjectClass": 9.0, "Name": "Corsair's Arc", + "IntValues": map[string]any{"218103840": 47.0, "353": 8.0, "218103839": 70.0}} + if _, ok := computeOD(raw); ok { + t.Fatal("bows are deferred → null") } }