MosswartOverlord/docs/superpowers/plans/2026-07-15-weapon-od-rating.md
2026-07-15 07:43:26 +02:00

388 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 <PATTERN> -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 BETTER than the 0.47 baseline):
// varianceTinks = round(log(0.47/0.376)/log(0.8), 2) = -1.00 → OD = 71-(-1)-71 = +1.
// (Table MaxVar is the type's baseline variance; lower variance = higher OD.)
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 <noreply@anthropic.com>`)
---
### 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":<n>,"updated":<m>}` 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 && <Row k="Weapon OD" v={item.od_rating > 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`.