5.8 KiB
Weapon OD rating — compute, store, filter
Date: 2026-07-15 Status: Approved
Problem
The inventory search has weak weapon/wand support. Mag-Tools (and UtilityBelt's port of it, the "retail comparison" / OD feature) classifies weapons by OD ("over damage") — how much better a weapon's optimally-tinked output is than the best possible retail loot weapon of its class and wield tier. Users want to filter weapons by OD.
Authoritative algorithm (researched 2026-07-15)
Source: UtilityBelt/UtilityBelt/Tools/ItemInfo.cs (sections titled "Slightly
modified MagTools item description classes", OD display at lines ~631-647) +
UtilityBelt/UtilityBelt/Lib/ItemInfoHelper/{MiscCalcs,WeaponMods,Dictionaries}.cs
in this workspace. WeaponMods.cs holds the best-values table: rows keyed by
(Skill, Mastery, MultiStrike, WieldReq) with columns
MaxDmg, MaxVar, MaxDmgMod, MaxElementalDmgBonus, MaxElementalDmgVsMonsters
(sourced from acpedia; ~330 rows covering heavy/light/finesse/two-handed melee
subtypes, bow/xbow/thrown, war/void casters).
Raw item keys (verified present in our item_raw_data):
- IntValues:
159EquipSkill,353Mastery/WeaponType,160WieldReqValue,218103842MaxDamage,204ElementalDmgBonus,171Tinks,179Imbued (bitmask, nonzero = imbued),131Material. original_json->DoubleValues:167772171Variance,167772174DamageBonus (missile dmg modifier, e.g. 2.73 = 173%),152ElementalDmgVsMonsters (wands),167772169SalvageWorkmanship.original_json->Spells/ActiveSpells: innate and active spell id arrays.
Buffed values (GetBuffedIntValueKey / GetBuffedDoubleValueKey semantics):
value + Σ bonus(innate Spells) − Σ change(ActiveSpells), using the spell-effect
subset from UB Dictionaries.cs: MaxDamage — Blood Drinker VI 1616:+20,
Infected Caress 2096:+22, Incant. BD 5183/4395:+24, Minor/Major/Epic/Legendary
Blood Thirst 2598:+2/2586:+4/4661:+7/6089:+10, Prodigal BD 3688:+300 (bonus =
change for all these); ElemVsMonsters (double) — Spirit Drinker VI 3258:+.06,
Infected Spirit Caress 3259:+.07, Incant. SD 5182/4414:+.08, Minor/Major/Epic/
Legendary Spirit Thirst 3251:+.01/3250:+.03/4670:+.05/6098:+.07.
MultiStrike weapons: WeaponType (key 47 in UB code = our int key 47? no —
UB reads LongValueKey 47 = WeaponType master id; the multistrike check is:
IntValues[47] ∈ {160,166,486} or (47==4 and mastery 353==11)). Verify key
47 exists in our data during implementation; if absent, derive from mastery
subtype comments in the table (msdagger/mssword/cleaver rows are the
MultiStrike=1 rows).
Formulas (OD, higher = better; retail max = 0):
- Melee (object_class 1):
varianceTinks = round(log(MaxVar_t / variance) / log(0.8), 2);OD = buffedMaxDmg − varianceTinks − MaxDmg_t. - Missile (object_class 9):
dmgMod = buffedDmgBonus·100 − 100;arrowMaxby mastery: bow(8)=40, xbow(9)=53, thrown(10)=42;remainingTinks: 10 − tinks, −1 more if not imbued, floor 0 (loot-gen only, see gate below; UB nuance: if tinks==0, remainingTinks=9);buffedDmg = buffedMaxDmg; if ≤10 then +24;maxMod = (MaxDmgMod_t + 100 + 36)/100;OD = (1 + (dmgMod + 4·remainingTinks)/100) · (buffedElemBonus + buffedDmg + arrowMax) / maxMod − (MaxElemBonus_t + 24 + arrowMax). - Wand (object_class 31, skill 34 war / 43 void):
OD = (buffedElemVsMonsters − MaxElemVsMonsters_t) · 100(percentage points; e.g. live Frost Baton: (1.40 − 1.18)·100 = +22). - Gate: OD only for loot-gen items (
SalvageWorkmanship > 0); otherwise NULL. Table lookup miss (unknown skill/mastery/wieldreq combo) → NULL. Round stored OD to 2 decimals.
Decisions (user-approved)
- Raw OD number with a
min OD ≥filter (not a bucketed 0-10 scale). - All classes: melee, missile, wands.
- Computed at ingest, stored on
items.od_rating DOUBLE PRECISION NULL, with a one-off backfill for existing rows.
Backend (inventory-go)
- New
od.go: best-values table ported from UBWeaponMods.cs(verify row count and spot-check values against the source file), buffed-value helpers,computeOD(raw map[string]any) (float64, bool). TDD with golden values (incl. the live Frost Baton +22 case). processItemsetsitems["od_rating"](or omits when NULL) — the dynamic insert in ingest.go picks it up automatically.schema.go: addod_rating DOUBLE PRECISIONto the items DDL (fresh installs). Live DB gets a manualALTER TABLE items ADD COLUMN IF NOT EXISTS od_rating double precision(prod runs SKIP_SCHEMA_INIT=true).- Search:
i.od_ratingadded to the CTE;min_od(>=) andmax_od(<=) params; sort keyod. NULL od never matches the filters (SQL NULL semantics) and sorts NULLS LAST/FIRST as usual. - Backfill:
POST /admin/backfill-od— iterates items of object_class 1/9/31 joined to item_raw_data, recomputes, UPDATEsod_rating; returns counts. Service is internal-only (127.0.0.1:8772 / compose network).
Frontend
RATING_DEFSgains{param:'min_od', label:'Weapon OD', common:true}— flows through the existing ratings record, sidebar inputs, chips, and URL state with no new plumbing.COLUMNSgains{key:'od_rating', label:'OD', sortKey:'od', defaultVisible:false}; cell renders signed 2-decimal (+3.25/-1.29),—for null.InvItem.od_rating?: number | null; DetailPanel row "OD" for weapons.
Verification
- Go unit tests (golden formula values + table spot checks) gate the image build as usual.
- Live: backfill count > 0;
min_od=20returns the Frost Baton; melee/missile spot checks against UB in-game values if available; sort by OD desc.
Out of scope
- Bucketed OD tiers (can be derived later).
- The old suitbuilder page.
- Recomputing OD when spell/enchant state changes intra-session (ingest updates overwrite it naturally via the debounced item updates).