MosswartOverlord/docs/superpowers/plans/2026-07-15-weapon-od-rating.md
Erik f92d158fd0 docs(plan): weapon OD rating implementation plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 07:29:10 +02:00

20 KiB
Raw Blame History

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"] 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.

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

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:

	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

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:

// 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, 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:
      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

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.tsx cell(), 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; delete static/_build after.

  • 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.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.