diff --git a/docs/superpowers/specs/2026-07-15-weapon-od-rating-design.md b/docs/superpowers/specs/2026-07-15-weapon-od-rating-design.md new file mode 100644 index 00000000..d9d0e14b --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-weapon-od-rating-design.md @@ -0,0 +1,116 @@ +# 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: `159` EquipSkill, `353` Mastery/WeaponType, `160` WieldReqValue, + `218103842` MaxDamage, `204` ElementalDmgBonus, `171` Tinks, `179` Imbued + (bitmask, nonzero = imbued), `131` Material. +- `original_json->DoubleValues`: `167772171` Variance, `167772174` DamageBonus + (missile dmg modifier, e.g. 2.73 = 173%), `152` ElementalDmgVsMonsters + (wands), `167772169` SalvageWorkmanship. +- `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`; `arrowMax` by 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) + +1. **Raw OD number** with a `min OD ≥` filter (not a bucketed 0-10 scale). +2. **All classes**: melee, missile, wands. +3. **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 UB `WeaponMods.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). +- `processItem` sets `items["od_rating"]` (or omits when NULL) — the dynamic + insert in ingest.go picks it up automatically. +- `schema.go`: add `od_rating DOUBLE PRECISION` to the items DDL (fresh + installs). Live DB gets a manual + `ALTER TABLE items ADD COLUMN IF NOT EXISTS od_rating double precision` + (prod runs SKIP_SCHEMA_INIT=true). +- Search: `i.od_rating` added to the CTE; `min_od` (>=) and `max_od` (<=) + params; sort key `od`. 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, UPDATEs `od_rating`; returns counts. + Service is internal-only (127.0.0.1:8772 / compose network). + +## Frontend + +- `RATING_DEFS` gains `{param:'min_od', label:'Weapon OD', common:true}` — + flows through the existing ratings record, sidebar inputs, chips, and URL + state with no new plumbing. +- `COLUMNS` gains `{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=20` returns 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).