From f75a236f54ff0597613cdf0b8519cd2f18eaf14c Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 15 Jul 2026 07:46:07 +0200 Subject: [PATCH] fix(inventory-go): OD variance orientation per UB source; exact SpellInfo change/bonus semantics; missile golden test Co-Authored-By: Claude Sonnet 5 --- go-services/inventory-go/od.go | 92 ++++++++++++++++------------- go-services/inventory-go/od_test.go | 59 ++++++++++++++++-- 2 files changed, 105 insertions(+), 46 deletions(-) diff --git a/go-services/inventory-go/od.go b/go-services/inventory-go/od.go index 56d93a44..f39ebcc4 100644 --- a/go-services/inventory-go/od.go +++ b/go-services/inventory-go/od.go @@ -382,63 +382,72 @@ var bestValues = map[bestKey]bestValue{ {43, 12, 0, 385}: {0, 0, 0, 0, 1.18}, } -// maxDamageSpellBonus is transcribed from UB Dictionaries.cs +// 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 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 +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 } -// elemVsMonSpellBonus is transcribed from UB Dictionaries.cs +// elemVsMonSpellEffects 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 +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 } -// buffedMaxDamage mirrors GetBuffedIntValueKey(218103842): base value plus -// bonus for each innate spell (raw["Spells"]), minus bonus for each active -// spell (raw["ActiveSpells"]). +// 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["Spells"]) { - if b, ok := maxDamageSpellBonus[id]; ok { - value += b + for _, id := range toIntList(raw["ActiveSpells"]) { + if e, ok := maxDamageSpellEffects[id]; ok { + value -= int(e.change) } } - for _, id := range toIntList(raw["ActiveSpells"]) { - if b, ok := maxDamageSpellBonus[id]; ok { - value -= b + for _, id := range toIntList(raw["Spells"]) { + if e, ok := maxDamageSpellEffects[id]; ok { + value += int(e.bonus) } } return value } // buffedElemVsMon mirrors GetBuffedDoubleValueKey(152) using the same -// innate-add / active-subtract semantics. +// 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["Spells"]) { - if b, ok := elemVsMonSpellBonus[id]; ok { - value += b + for _, id := range toIntList(raw["ActiveSpells"]) { + if e, ok := elemVsMonSpellEffects[id]; ok { + value -= e.change } } - for _, id := range toIntList(raw["ActiveSpells"]) { - if b, ok := elemVsMonSpellBonus[id]; ok { - value -= b + for _, id := range toIntList(raw["Spells"]) { + if e, ok := elemVsMonSpellEffects[id]; ok { + value += e.bonus } } return value @@ -506,11 +515,10 @@ func computeOD(raw map[string]any) (float64, bool) { 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)) + // 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 diff --git a/go-services/inventory-go/od_test.go b/go-services/inventory-go/od_test.go index 48b43bcb..2acaf8a8 100644 --- a/go-services/inventory-go/od_test.go +++ b/go-services/inventory-go/od_test.go @@ -66,8 +66,12 @@ func TestOD_MeleeRetailMaxIsZero(t *testing.T) { } } -// 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. +// 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, @@ -76,8 +80,8 @@ func TestOD_MeleeOneTinkVariance(t *testing.T) { "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) + if !ok || !approx(od, 1.0) { + t.Fatalf("melee OD = %v ok=%v, want +1", od, ok) } } @@ -95,3 +99,50 @@ func TestOD_MeleeBuffedByBloodThirst(t *testing.T) { t.Fatalf("melee buffed OD = %v ok=%v, want 0", 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) + } +}