# Weapon OD Rating Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Compute a Mag-Tools "OD" (over-damage vs best retail) rating for melee/missile/wand weapons at ingest, store it on `items.od_rating`, backfill existing rows, and expose `min_od`/`max_od` search filters + an OD column/detail row in the React inventory search. **Architecture:** New `inventory-go/od.go` ports UtilityBelt's OD calc (`UtilityBelt/UtilityBelt/Tools/ItemInfo.cs` + `Lib/ItemInfoHelper/{WeaponMods,MiscCalcs,Dictionaries}.cs`, all in this workspace). `processItem` calls it and writes `od_rating` into the `items` map (the dynamic `buildInsert` in `ingest.go` picks it up with no other change). A backfill endpoint recomputes over stored `item_raw_data.original_json`. Search adds `min_od`/`max_od`/sort. Frontend adds one rating + one column + one detail row. Spec: `docs/superpowers/specs/2026-07-15-weapon-od-rating-design.md`. **Tech Stack:** Go 1.25 (backend); React 19 + TS + Vite (frontend). Go has no local toolchain — tests run in a throwaway `golang:1.25-bookworm` container on `overlord.snakedesert.se`; the Dockerfile's `RUN go test ./...` gates the image build. Frontend build = `npm run build` locally. **Working dir for git:** `C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord` **Go test command (sync + run in container):** ```bash cd "C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord" && \ tar czf - go-services | ssh erik@overlord.snakedesert.se \ "rm -rf /tmp/tdd-verify && mkdir -p /tmp/tdd-verify && tar xzf - -C /tmp/tdd-verify" && \ ssh erik@overlord.snakedesert.se \ "docker run --rm -v /tmp/tdd-verify/go-services/inventory-go:/src -w /src golang:1.25-bookworm \ sh -c 'go mod tidy >/dev/null 2>&1; go test ./... -run -v 2>&1'" ``` **Key facts (verified against live data + source):** - Raw item passed to `processItem(raw)`: `raw["IntValues"]`, `raw["DoubleValues"]` are `map[string]any` (JSON number values are `float64`); `raw["Spells"]` / `raw["ActiveSpells"]` are arrays. Helpers `bag`, `ivI`, `dvF`, `toIntList` already exist in `process.go`. - Int keys: `159` EquipSkill, `353` Mastery, `160` WieldReqValue, `218103842` MaxDamage, `204` ElementalDmgBonus, `171` Tinks, `179` Imbued (nonzero ⇒ imbued), `131` Material, `47` WeaponType(masterid). - Double keys (in DoubleValues): `167772171` Variance, `167772174` DamageBonus, `152` ElementalDamageVsMonsters, `167772169` SalvageWorkmanship. - `object_class`: 1 melee, 9 missile, 31 wand/caster. - Best-values table + spell-effect dicts: transcribe from the workspace source files named above — DO NOT invent values. --- ### Task 1: OD calculation core (`od.go`) — TDD **Files:** - Create: `go-services/inventory-go/od.go` - Create: `go-services/inventory-go/od_test.go` - [ ] **Step 1: Write the failing tests.** Create `od_test.go`. Golden values are computed by hand from the formulas + the workspace table; verify each against the source before trusting. ```go package main import ( "math" "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) } } // 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{}, } if _, ok := computeOD(raw); ok { t.Fatal("no-workmanship item must have no OD") } } // 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{}, } if _, ok := computeOD(raw); ok { t.Fatal("unknown wieldreq tier must have no OD") } } // 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) } } // 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. 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) } } // 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) } } ``` - [ ] **Step 2: Run tests, verify they fail** with `undefined: computeOD` (pattern `TestOD`). - [ ] **Step 3: Implement `od.go`.** Structure: - A `bestValue` struct `{maxDmg, maxVar, maxDmgMod, maxElemBonus, maxElemVsMon float64}` and a lookup `map[bestKey]bestValue` keyed by `bestKey{skill, mastery, multiStrike, wieldReq int}`, transcribed from `UtilityBelt/.../WeaponMods.cs` — EVERY row (heavy/light/finesse/two-handed/missile/wand regions). Column order in the source `Rows.Add` is `(Skill, Mastery, MultiStrike, WieldReq, MaxDmg, MaxVar, MaxDmgMod, MaxElementalDmgBonus, MaxElementalDmgVsMonsters)`; rows that omit trailing columns default them to 0. Melee rows use `(skill,mastery,ms,wieldreq,maxdmg,maxvar)`; missile rows use `...,maxDmgMod(col7)=0? no` — READ the source: missile rows are `(47, mastery, 0, wieldreq, 0,0, MaxDmgMod-col? )`. Actually missile rows fill `MaxElementalDmgBonus` (col 8) — e.g. `table.Rows.Add(47, 8, 0, 0, 0, 0, 110, 0)` means MaxDmg=0,MaxVar=0,MaxDmgMod=110,MaxElemBonus=0. Wand rows fill col 9 (MaxElementalDmgVsMonsters), e.g. `(34,0,0,290,0,0,0,0,1.03)`. Map each `Rows.Add(...)` positionally onto the struct fields; verify with a couple of spot values in a test comment. - Spell-effect maps transcribed from `UtilityBelt/.../Dictionaries.cs`: - `maxDamageSpellBonus map[int]int`: 1616:20, 2096:22, 5183:24, 4395:24, 2598:2, 2586:4, 4661:7, 6089:10, 3688:300. - `elemVsMonSpellBonus map[int]float64`: 3258:.06, 3259:.07, 5182:.08, 4414:.08, 3251:.01, 3250:.03, 4670:.05, 6098:.07. - `buffedMaxDamage(raw, iv)`: `ivI(iv,"218103842",0)` + Σ bonus over innate `raw["Spells"]` − Σ bonus over `raw["ActiveSpells"]`, using `maxDamageSpellBonus`. - `buffedElemVsMon(raw, dv)`: `dvF(dv,"152",0)` + Σ over Spells − Σ over ActiveSpells using `elemVsMonSpellBonus`. - MultiStrike detection: `iv[47] ∈ {160,166,486}` OR (`iv[47]==4` AND `iv[353]==11`). (If key 47 absent, ms=0.) - `computeOD(raw) (float64, bool)`: - `oc := rawI(raw,"ObjectClass",0)`; only 1/9/31 proceed, else `(0,false)`. - Gate: `dvF(dv,"167772169",0) > 0` else `(0,false)`. - Look up `bestValue` by `{skill=ivI(iv,"159",0), mastery=ivI(iv,"353",0), multiStrike, wieldReq=ivI(iv,"160",0)}`; miss → `(0,false)`. - Melee (oc==1): `variance := dvF(dv,"167772171",0)`; if variance<=0 → `(0,false)`; `varianceTinks := round(math.Log(bv.maxVar/variance)/math.Log(0.8), 2)`; `od := float64(buffedMaxDamage) - varianceTinks - bv.maxDmg`. - Missile (oc==9): `arrowMax` by mastery 8→40,9→53,10→42 (else 0); `tinks := ivI(iv,"171",0)`; `remaining := 10 - tinks; if tinks==0 { remaining = 9 } else if imbued==0 { remaining-- }; if remaining<0 {remaining=0}` where `imbued := ivI(iv,"179",0)`; `dmgMod := dvF(dv,"167772174",0)*100 - 100`; `bDmg := float64(buffedMaxDamage); if bDmg<=10 { bDmg+=24 }`; `bElem := float64(ivI(iv,"204",0))` (no elem spell buff needed for missile per UB); `maxMod := (bv.maxDmgMod + 136)/100`; `od := (1 + (dmgMod + 4*float64(remaining))/100) * (bElem + bDmg + arrowMax) / maxMod - (bv.maxElemBonus + 24 + arrowMax)`. - Wand (oc==31): `od := (buffedElemVsMon - bv.maxElemVsMon) * 100`. - Return `round(od,2), true`. Provide a local `round2(x float64) float64` = `math.Round(x*100)/100`. - [ ] **Step 4: Run tests, verify PASS** (pattern `TestOD`), then whole package (`-run .`) green. - [ ] **Step 5: Commit** ```bash git add go-services/inventory-go/od.go go-services/inventory-go/od_test.go git commit -m "feat(inventory-go): Mag-Tools OD weapon rating calculation (od.go)" ``` (append `Co-Authored-By: Claude Sonnet 5 `) --- ### Task 2: Wire OD into ingest + schema **Files:** - Modify: `go-services/inventory-go/process.go` (in `processItem`, after the `items` map is built ~line 137, before combat) - Modify: `go-services/inventory-go/schema.go` (items table DDL) - [ ] **Step 1: Set od_rating in processItem.** After the `items := map[string]any{...}` literal closes, add: ```go if od, ok := computeOD(raw); ok { items["od_rating"] = od } ``` (When not ok, the key is absent → buildInsert omits it → column stays NULL. Correct.) - [ ] **Step 2: Add the column to schema.go.** Find the `CREATE TABLE ... items (` DDL and add `od_rating DOUBLE PRECISION,` alongside the other nullable numeric columns. (Fresh installs only; prod uses SKIP_SCHEMA_INIT and gets the manual ALTER in Task 5.) - [ ] **Step 3: Build/test** — full package green (`-run .`). No new test needed here (Task 1 covers the math; ingest wiring is exercised in Task 5 live). - [ ] **Step 4: Commit** ```bash git add go-services/inventory-go/process.go go-services/inventory-go/schema.go git commit -m "feat(inventory-go): store od_rating at ingest + schema column" ``` --- ### Task 3: Backfill endpoint **Files:** - Modify: `go-services/inventory-go/ingest.go` (add handler) and `go-services/inventory-go/main.go` (register route) - [ ] **Step 1: Add handler in ingest.go:** ```go // POST /admin/backfill-od — recompute od_rating for all weapon rows from stored // raw JSON. Internal-only (service is bound to loopback/compose). Idempotent. func (s *Server) handleBackfillOD(w http.ResponseWriter, r *http.Request) { ctx := r.Context() rows, err := s.pool.Query(ctx, `SELECT rd.item_id, rd.original_json FROM item_raw_data rd JOIN items i ON i.id = rd.item_id WHERE i.object_class IN (1,9,31)`) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()}) return } type upd struct { id int od float64 } var updates []upd scanned := 0 for rows.Next() { var id int var oj map[string]any if err := rows.Scan(&id, &oj); err != nil { rows.Close() writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()}) return } scanned++ if od, ok := computeOD(oj); ok { updates = append(updates, upd{id, od}) } } rows.Close() updated := 0 for _, u := range updates { if _, err := s.pool.Exec(ctx, "UPDATE items SET od_rating=$1 WHERE id=$2", u.od, u.id); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error(), "updated": updated}) return } updated++ } writeJSON(w, http.StatusOK, map[string]any{"scanned": scanned, "updated": updated}) } ``` Note: pgx scans a `jsonb` column into `map[string]any` directly; `computeOD` accepts exactly that shape (numbers as `float64`), same as ingest. - [ ] **Step 2: Register in main.go** next to the other `mux.HandleFunc("POST /...")` lines: ```go mux.HandleFunc("POST /admin/backfill-od", srv.handleBackfillOD) ``` - [ ] **Step 3: Build/test** green (`-run .`). - [ ] **Step 4: Commit** ```bash git add go-services/inventory-go/ingest.go go-services/inventory-go/main.go git commit -m "feat(inventory-go): POST /admin/backfill-od recompute endpoint" ``` --- ### Task 4: Search filter + sort + column **Files:** - Modify: `go-services/inventory-go/search.go` - [ ] **Step 1: Expose od_rating in the CTE.** In `cteSelect`, add `i.od_rating,` to the selected columns (near `i.value, i.burden`). It flows into the row maps and out to JSON automatically (queryRowsAsMaps). - [ ] **Step 2: Add the filters.** `geFilters` (search.go ~line 209) uses `strconv.ParseFloat` and `leFilters` (~line 230) uses int-only `qInt`. OD can be negative and fractional, so: - Add `min_od` to the `geFilters` slice: `{"min_od", "od_rating"}` (float path — correct). - Do NOT add `max_od` to `leFilters` (int-only would truncate negatives/decimals). Instead add a float block right after the existing `min_attack_bonus` block (~line 239), mirroring it: ```go if v := q.Get("max_od"); v != "" { if n, err := strconv.ParseFloat(v, 64); err == nil { conds = append(conds, "od_rating <= "+ab.add(n)) } } ``` - [ ] **Step 3: Add the sort key.** In `sortMapping`, add `"od": "od_rating",`. - [ ] **Step 4: Build/test** green (`-run .`). - [ ] **Step 5: Commit** ```bash git add go-services/inventory-go/search.go git commit -m "feat(inventory-go): min_od/max_od search filters + od sort" ``` --- ### Task 5: Deploy backend + backfill + live verify - [ ] **Step 1: Manual ALTER on the live inventory DB** (prod skips schema init): ```bash ssh erik@overlord.snakedesert.se "docker exec inventory-db psql -U inventory_user -d inventory_db -c 'ALTER TABLE items ADD COLUMN IF NOT EXISTS od_rating double precision;'" ``` - [ ] **Step 2: Sync + build + recreate inventory-go** (test-gated build): ```bash cd "C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord" && \ tar czf - go-services | ssh erik@overlord.snakedesert.se "tar xzf - -C /home/erik/MosswartOverlord/" ssh erik@overlord.snakedesert.se 'cd /home/erik/MosswartOverlord && \ export BUILD_VERSION="$(date -u +%Y.%-m.%-d.%H%M)-$(git rev-parse --short HEAD)" && \ docker compose -f docker-compose.yml -f go-services/docker-compose.go.yml build --build-arg BUILD_VERSION=$BUILD_VERSION inventory-go && \ docker compose -f docker-compose.yml -f go-services/docker-compose.go.yml -f go-services/docker-compose.cutover.yml up -d --no-deps inventory-go' ``` - [ ] **Step 3: Run the backfill:** ```bash ssh erik@overlord.snakedesert.se "curl -s -X POST http://127.0.0.1:8772/admin/backfill-od" ``` Expected: `{"scanned":,"updated":}` with m > 0. - [ ] **Step 4: Verify the known Frost Baton (+22) and a sort:** ```bash ssh erik@overlord.snakedesert.se "curl -s 'http://127.0.0.1:8772/search/items?include_all_characters=true&weapon_only=true&min_od=20&sort_by=od&sort_dir=desc&limit=10'" \ | python3 -c "import json,sys; d=json.load(sys.stdin); print('total:',d['total_count']); [print(i['name'],'|',i.get('object_class_name'),'|',i.get('od_rating')) for i in d['items']]" ``` Expected: results include the Frost Baton with od_rating ≈ 22; all listed od_rating ≥ 20, descending. - [ ] **Step 5: Sanity — melee spot check** (pick a heavy sword the user knows in-game; compare od_rating sign to its Mag-Tools OD). Non-weapon search unaffected: ```bash ssh erik@overlord.snakedesert.se "curl -s 'http://127.0.0.1:8772/search/items?include_all_characters=true&jewelry_only=true&limit=1' | python3 -c \"import json,sys;d=json.load(sys.stdin);print('jewelry total',d['total_count'])\"" ``` - [ ] **Step 6: Commit** (no code change; this task is operational). If a git commit is desired for the version bump it happens via the frontend push later. --- ### Task 6: Frontend — filter + column + detail row **Files:** - Modify: `frontend/src/components/inventory/constants.ts` - Modify: `frontend/src/components/inventory/types.ts` - Modify: `frontend/src/components/inventory/DetailPanel.tsx` - [ ] **Step 1: types.ts** — add to `InvItem`: ```ts od_rating?: number | null; ``` - [ ] **Step 2: constants.ts** — add an OD rating filter and column. In `RATING_DEFS`, add as the first entry (so it appears with the common ratings): ```ts { param: 'min_od', label: 'Weapon OD', common: true }, ``` In `COLUMNS`, add after `max_damage`: ```ts { key: 'od_rating', label: 'OD', sortKey: 'od', defaultVisible: false }, ``` - [ ] **Step 3: ResultsTable cell rendering.** In `ResultsTable.tsx` `cell()`, add a case so OD shows signed with 2 decimals and `—` for null: ```ts case 'od_rating': { const v = item.od_rating; if (v == null) return '—'; return v > 0 ? `+${v.toFixed(2)}` : v.toFixed(2); } ``` (Place it alongside the other explicit cases, before `default`.) - [ ] **Step 4: DetailPanel.tsx** — add an OD row (only when present) after Max damage: ```tsx {item.od_rating != null && 0 ? `+${item.od_rating.toFixed(2)}` : item.od_rating.toFixed(2)} />} ``` - [ ] **Step 5: Build green:** `cd "C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord/frontend" && npm run build`; delete `static/_build` after. - [ ] **Step 6: Commit** ```bash git add frontend/src/components/inventory/ git commit -m "feat(frontend): weapon OD rating filter, column, and detail row" ``` Note: `min_od` chip label comes free via `RATING_DEFS` in `ActiveChips` ("Weapon OD ≥ N"); the rating sidebar input is auto-generated from `RATING_DEFS`. No changes needed in FilterSidebar/ActiveChips. --- ### Task 7: Deploy frontend + final verify - [ ] **Step 1:** `bash deploy-frontend.sh` from repo root. - [ ] **Step 2:** `git add static/ frontend/ && git commit -m "build(frontend): deploy weapon OD rating" && git push origin master` - [ ] **Step 3:** `ssh erik@overlord.snakedesert.se "cd /home/erik/MosswartOverlord && git pull --ff-only origin master"` - [ ] **Step 4: User verification** (login-gated, ask the user): open `/?view=inventory`, select Weapons, enter Weapon OD ≥ 10, confirm high-OD weapons appear; add the OD column via the picker; sort by OD; click a weapon and confirm the OD row in the detail panel; confirm a known good weapon's OD roughly matches its in-game Mag-Tools OD. --- ## Self-review notes - Spec coverage: melee/missile/wand formulas (Task 1), ingest+schema (2), backfill (3), filters+sort (4), deploy+backfill (5), UI (6), deploy (7). All spec bullets covered. - The best-values table + spell dicts are transcribed from named in-repo source files, not invented — reviewer must diff against those files. - `min_od` is a float filter (negative values valid) — placed in the ParseFloat `geFilters` path, not the int `leFilters` path. `max_od` likewise should accept floats: if `leFilters` is int-only (`qInt`), add `max_od` via the float `min_attack_bonus`-style manual block instead. Implementer: check `leFilters`'s parse path and use a float path for `max_od`.