20 KiB
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):
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"]aremap[string]any(JSON number values arefloat64);raw["Spells"]/raw["ActiveSpells"]are arrays. Helpersbag,ivI,dvF,toIntListalready exist inprocess.go. - Int keys:
159EquipSkill,353Mastery,160WieldReqValue,218103842MaxDamage,204ElementalDmgBonus,171Tinks,179Imbued (nonzero ⇒ imbued),131Material,47WeaponType(masterid). - Double keys (in DoubleValues):
167772171Variance,167772174DamageBonus,152ElementalDamageVsMonsters,167772169SalvageWorkmanship. 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.
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(patternTestOD). -
Step 3: Implement
od.go. Structure:- A
bestValuestruct{maxDmg, maxVar, maxDmgMod, maxElemBonus, maxElemVsMon float64}and a lookupmap[bestKey]bestValuekeyed bybestKey{skill, mastery, multiStrike, wieldReq int}, transcribed fromUtilityBelt/.../WeaponMods.cs— EVERY row (heavy/light/finesse/two-handed/missile/wand regions). Column order in the sourceRows.Addis(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 fillMaxElementalDmgBonus(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 eachRows.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 innateraw["Spells"]− Σ bonus overraw["ActiveSpells"], usingmaxDamageSpellBonus.buffedElemVsMon(raw, dv):dvF(dv,"152",0)+ Σ over Spells − Σ over ActiveSpells usingelemVsMonSpellBonus.- MultiStrike detection:
iv[47] ∈ {160,166,486}OR (iv[47]==4ANDiv[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) > 0else(0,false). - Look up
bestValueby{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):
arrowMaxby 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}whereimbued := 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 localround2(x float64) float64=math.Round(x*100)/100.
- A
-
Step 4: Run tests, verify PASS (pattern
TestOD), then whole package (-run .) green. -
Step 5: Commit
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(inprocessItem, after theitemsmap 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:
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 addod_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
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) andgo-services/inventory-go/main.go(register route) -
Step 1: Add handler in ingest.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:
mux.HandleFunc("POST /admin/backfill-od", srv.handleBackfillOD)
-
Step 3: Build/test green (
-run .). -
Step 4: Commit
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, addi.od_rating,to the selected columns (neari.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) usesstrconv.ParseFloatandleFilters(~line 230) uses int-onlyqInt. OD can be negative and fractional, so:- Add
min_odto thegeFiltersslice:{"min_od", "od_rating"}(float path — correct). - Do NOT add
max_odtoleFilters(int-only would truncate negatives/decimals). Instead add a float block right after the existingmin_attack_bonusblock (~line 239), mirroring it:if v := q.Get("max_od"); v != "" { if n, err := strconv.ParseFloat(v, 64); err == nil { conds = append(conds, "od_rating <= "+ab.add(n)) } }
- Add
-
Step 3: Add the sort key. In
sortMapping, add"od": "od_rating",. -
Step 4: Build/test green (
-run .). -
Step 5: Commit
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):
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):
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:
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:
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:
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:
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):
{ param: 'min_od', label: 'Weapon OD', common: true },
In COLUMNS, add after max_damage:
{ key: 'od_rating', label: 'OD', sortKey: 'od', defaultVisible: false },
- Step 3: ResultsTable cell rendering. In
ResultsTable.tsxcell(), add a case so OD shows signed with 2 decimals and—for null:
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:
{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; deletestatic/_buildafter. -
Step 6: Commit
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.shfrom 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_odis a float filter (negative values valid) — placed in the ParseFloatgeFilterspath, not the intleFilterspath.max_odlikewise should accept floats: ifleFiltersis int-only (qInt), addmax_odvia the floatmin_attack_bonus-style manual block instead. Implementer: checkleFilters's parse path and use a float path formax_od.