docs(spec): weapon OD rating — compute at ingest, filter in search

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-15 07:26:32 +02:00
parent 71f235c8b1
commit 10f8d135e5

View file

@ -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).