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,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·100100 = 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")
}
}