From f92d158fd0fb81ad9d5c29ca369998fb39a681b4 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 15 Jul 2026 07:29:10 +0200 Subject: [PATCH] docs(plan): weapon OD rating implementation plan Co-Authored-By: Claude Fable 5 --- .../plans/2026-07-15-weapon-od-rating.md | 387 ++++++++++++++++++ 1 file changed, 387 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-15-weapon-od-rating.md diff --git a/docs/superpowers/plans/2026-07-15-weapon-od-rating.md b/docs/superpowers/plans/2026-07-15-weapon-od-rating.md new file mode 100644 index 00000000..dfd03249 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-weapon-od-rating.md @@ -0,0 +1,387 @@ +# 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`.