feat(inventory-go): recompute OD from VTank loot-profile ladder (0-10 / casters 0-7)

Replaces the UtilityBelt over-retail float with the user's actual in-game OD:
per-weapon-type MaxDamage/elemVsMon thresholds from Loot.utl. Bows/thrown
deferred (null) pending a computed-stat calibration. (L) Dagger MS bucket
folded into (L) Dagger (indistinguishable by captured fields).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-15 19:09:22 +02:00
parent 7a11122a26
commit cbc8785c47
2 changed files with 143 additions and 706 deletions

View file

@ -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 (010, casters
// 07), 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
}