MosswartOverlord/docs/superpowers/plans/2026-07-15-weapon-od-loot-profile.md
Erik 7a11122a26 docs: spec + plan to recompute OD from VTank loot profile (0-10 scale)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 19:06:02 +02:00

303 lines
14 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 from Loot Profile — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Replace the OD calculation with the user's VTank loot-profile ladder so `od_rating` is the 010 (casters 07) integer they see on ident. Spec: `docs/superpowers/specs/2026-07-15-weapon-od-loot-profile-design.md`.
**Architecture:** Rewrite `go-services/inventory-go/od.go` around an ordered bucket table (below, extracted from `Loot.utl`). `OD = clamp(stat baseline, 1, cap)` for the first matching bucket. Backfill; the frontend renders integer OD.
**Tech Stack:** Go 1.25 (server-only tests in `golang:1.25-bookworm`), React (npm build).
**Working dir for git:** `C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord`
**Go test command:**
```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'"
```
**Item keys:** object_class from `raw["ObjectClass"]`; skill `IntValues["218103840"]` (melee/missile) / `IntValues["159"]` (casters); mastery `IntValues["353"]`; MaxDamage `IntValues["218103842"]`; missile dmg `IntValues["218103839"]`; elemVsMon `DoubleValues["152"]`; name `raw["Name"]`. Helpers `bag/ivI/dvF/rawI/rawS` exist in process.go.
---
### Task 1: Rewrite od.go with the loot-profile table (TDD)
**Files:** rewrite `go-services/inventory-go/od.go`; rewrite `go-services/inventory-go/od_test.go`.
- [ ] **Step 1: Write tests first** (`od_test.go`, replacing the old content):
```go
package main
import "testing"
func TestOD_MeleeTetsubo(t *testing.T) {
// 2H Cleaving baseline 45, name "Tetsubo", maxdmg 85 → clamp(85-45,1,10)=10.
raw := map[string]any{"ObjectClass": 1.0, "Name": "Tetsubo",
"IntValues": map[string]any{"218103840": 41.0, "353": 11.0, "218103842": 85.0}}
if od, ok := computeOD(raw); !ok || od != 10 {
t.Fatalf("Tetsubo OD=%v ok=%v want 10", od, ok)
}
}
func TestOD_MeleeMidTier(t *testing.T) {
// Heavy Axe baseline 74, name "War Axe", maxdmg 79 → clamp(79-74,1,10)=5.
raw := map[string]any{"ObjectClass": 1.0, "Name": "Bronze War Axe",
"IntValues": map[string]any{"218103840": 44.0, "353": 3.0, "218103842": 79.0}}
if od, ok := computeOD(raw); !ok || od != 5 {
t.Fatalf("War Axe OD=%v ok=%v want 5", od, ok)
}
}
func TestOD_MeleeBelowBaselineNull(t *testing.T) {
// Heavy Axe baseline 74, maxdmg 74 → stat-baseline=0 (<1) → null.
raw := map[string]any{"ObjectClass": 1.0, "Name": "Bronze War Axe",
"IntValues": map[string]any{"218103840": 44.0, "353": 3.0, "218103842": 74.0}}
if _, ok := computeOD(raw); ok {
t.Fatal("maxdmg == baseline must be null")
}
}
func TestOD_MeleeNameGateNull(t *testing.T) {
// Heavy Axe bucket requires name in {Silifi,Lugian,War Axe,Battle}. A skill-44
// mastery-3 item NOT matching any name and matching no other bucket → null.
raw := map[string]any{"ObjectClass": 1.0, "Name": "Bronze Frobnicator",
"IntValues": map[string]any{"218103840": 44.0, "353": 3.0, "218103842": 84.0}}
if _, ok := computeOD(raw); ok {
t.Fatal("name not matching the Heavy Axe filter must be null")
}
}
func TestOD_Crossbow(t *testing.T) {
// Xbow baseline 77, k839=89 → clamp(89-77,1,10)=10.
raw := map[string]any{"ObjectClass": 9.0, "Name": "Fire Compound Crossbow",
"IntValues": map[string]any{"218103840": 47.0, "353": 9.0, "218103839": 89.0}}
if od, ok := computeOD(raw); !ok || od != 10 {
t.Fatalf("Xbow OD=%v ok=%v want 10", od, ok)
}
}
func TestOD_WarCaster(t *testing.T) {
// War caster: elemVsMon 1.40 → clamp(round((1.40-1.18)*100),1,7)=clamp(22,1,7)=7.
raw := map[string]any{"ObjectClass": 31.0, "Name": "Diamond Frost Baton",
"IntValues": map[string]any{"159": 34.0},
"DoubleValues": map[string]any{"152": 1.40}}
if od, ok := computeOD(raw); !ok || od != 7 {
t.Fatalf("War caster OD=%v ok=%v want 7", od, ok)
}
}
func TestOD_VoidCasterTier(t *testing.T) {
// Void caster: name must contain "Nether"; elemVsMon 1.22 → round((1.22-1.18)*100)=4.
raw := map[string]any{"ObjectClass": 31.0, "Name": "Silver Nether Staff",
"IntValues": map[string]any{"159": 43.0},
"DoubleValues": map[string]any{"152": 1.22}}
if od, ok := computeOD(raw); !ok || od != 4 {
t.Fatalf("Void caster OD=%v ok=%v want 4", od, ok)
}
}
func TestOD_BowDeferredNull(t *testing.T) {
// Bows (mastery 8) are deferred → always null in this pass.
raw := map[string]any{"ObjectClass": 9.0, "Name": "Corsair's Arc",
"IntValues": map[string]any{"218103840": 47.0, "353": 8.0, "218103839": 70.0}}
if _, ok := computeOD(raw); ok {
t.Fatal("bows are deferred → null")
}
}
```
- [ ] **Step 2: Run → RED** (`-run TestOD`): fails (old computeOD signature returns float; these expect int). Build/logic failure expected.
- [ ] **Step 3: Rewrite `od.go`** (delete ALL old content — table, spell dicts, variance math — and replace):
```go
package main
import "strings"
// Weapon OD = the user's VirindiTank Loot.utl classification (010, casters
// 07), printed by Mag-Tools on ident. OD = clamp(stat baseline, 1, cap) for
// the first matching bucket, else null. Buckets/baselines transcribed from
// Loot.utl (see design doc 2026-07-15-weapon-od-loot-profile).
type odBucket struct {
objectClass int // 1 melee, 9 missile, 31 caster
skill int // 218103840 for melee/missile, 159 for casters; 0 = any
mastery int // IntValues 353; -1 = not checked
nameAlts []string // OR of case-sensitive substrings; empty = no name gate
statKey string // IntValues key (melee/missile) or ""
dblStatKey string // DoubleValues key (casters) or ""
baseline float64
cap int
}
// Ordered: name-gated buckets are effectively specific; first match wins.
var odBuckets = []odBucket{
// --- Melee (object_class 1, stat MaxDamage 218103842, cap 10) ---
{1, 44, 3, []string{"Silifi", "Lugian", "War Axe", "Battle"}, "218103842", "", 74, 10}, // (H) Axe
{1, 45, 3, []string{"Dolabra", "Ono", "Hand", "War Hammer"}, "218103842", "", 61, 10}, // (L) Axe
{1, 46, 3, []string{"Hatchet", "Shou-ono", "Tungi", "Hammer"}, "218103842", "", 61, 10}, // (F) Axe
{1, 44, 6, []string{"Stiletto", "Jambiya"}, "218103842", "", 38, 10}, // (H) Dagger MS
{1, 44, 6, nil, "218103842", "", 71, 10}, // (H) Dagger
{1, 45, 6, nil, "218103842", "", 58, 10}, // (L) Dagger (MS shares names=nil; see note)
{1, 46, 6, []string{"Knife", "Lancet"}, "218103842", "", 28, 10}, // (F) Dagger MS
{1, 46, 6, nil, "218103842", "", 58, 10}, // (F) Dagger
{1, 44, 4, []string{"Mazule", "Mace", "Morning"}, "218103842", "", 69, 10}, // (H) Mace
{1, 45, 4, []string{"Club", "Kasrullah"}, "218103842", "", 56, 10}, // (L) Mace
{1, 46, 4, []string{"Board", "Tofun", "Dabus"}, "218103842", "", 56, 10}, // (F) Mace
{1, 44, 5, []string{"Glaive", "Trident", "Partizan"}, "218103842", "", 72, 10}, // (H) Spear
{1, 45, 5, []string{"Spear", "Yari"}, "218103842", "", 60, 10}, // (L) Spear
{1, 44, 7, []string{"Nabut", "Stick"}, "218103842", "", 69, 10}, // (H) Staff
{1, 45, 7, nil, "218103842", "", 57, 10}, // (L) Staff
{1, 46, 7, []string{"Jo", "Bastone"}, "218103842", "", 57, 10}, // (F) Staff
{1, 44, 2, []string{"Takuba", "Flamberge", "Ken", "Long", "Tachi"}, "218103842", "", 71, 10}, // (H) Sword
{1, 45, 2, []string{"Dericost", "Broad", "Shamshir", "Kaskara", "Spada"}, "218103842", "", 58, 10}, // (L) Sword
{1, 46, 2, []string{"Scimitar", "Yaoji", "Short", "Sabra", "Simi"}, "218103842", "", 58, 10}, // (F) Sword
{1, 44, 1, []string{"Nekode", "Cestus"}, "218103842", "", 59, 10}, // (H) UA
{1, 45, 1, []string{"Katar", "Knuckles"}, "218103842", "", 48, 10}, // (L) UA
{1, 46, 1, []string{"Claw", "Wraps"}, "218103842", "", 48, 10}, // (F) UA
{1, 41, -1, []string{"Nodachi", "Shashqa", "Spadone", "Greataxe", "Quadrelle", "Khanda-handled", "Tetsubo", "Star"}, "218103842", "", 45, 10}, // (2H) Cleaving
{1, 41, -1, []string{"Assagai", "Pike", "Magari", "Corsesca"}, "218103842", "", 48, 10}, // (2H) Spear
// --- Crossbow (object_class 9, mastery 9, stat 218103839, cap 10) ---
{9, 47, 9, nil, "218103839", "", 77, 10},
// --- Casters (object_class 31, stat elemVsMon 152, cap 7) ---
{31, 34, -1, []string{"Baton", "Sceptre", "Staff"}, "", "152", 1.18, 7}, // War
{31, 43, -1, []string{"Nether"}, "", "152", 1.18, 7}, // Void
// Bows (mastery 8) and Thrown (mastery 10) are intentionally ABSENT → null.
}
func nameMatches(name string, alts []string) bool {
if len(alts) == 0 {
return true
}
for _, a := range alts {
if strings.Contains(name, a) {
return true
}
}
return false
}
// computeOD returns the loot-profile OD (integer, as float64 for the DB column)
// and ok=false when no bucket matches or the stat is at/below baseline.
func computeOD(raw map[string]any) (float64, bool) {
oc := rawI(raw, "ObjectClass", 0)
iv := bag(raw, "IntValues")
dv := bag(raw, "DoubleValues")
name := rawS(raw, "Name")
skillMelee := ivI(iv, "218103840", 0)
skillCaster := ivI(iv, "159", 0)
mastery := ivI(iv, "353", -999)
for _, b := range odBuckets {
if b.objectClass != oc {
continue
}
sk := skillMelee
if b.objectClass == 31 {
sk = skillCaster
}
if b.skill != 0 && b.skill != sk {
continue
}
if b.mastery != -1 && b.mastery != mastery {
continue
}
if !nameMatches(name, b.nameAlts) {
continue
}
var stat float64
if b.dblStatKey != "" {
stat = dvF(dv, b.dblStatKey, 0)
} else {
stat = float64(ivI(iv, b.statKey, 0))
}
od := stat - b.baseline
if b.dblStatKey != "" {
od = od * 100 // caster elemVsMon: percentage points
}
odInt := int(od + 0.5) // round
if odInt < 1 {
return 0, false
}
if odInt > b.cap {
odInt = b.cap
}
return float64(odInt), true
}
return 0, false
}
```
Note on the (L) Dagger MS bucket: the profile has an `(L) Dagger MS` (baseline 28) with no name filter, which would collide with `(L) Dagger` (baseline 58, also no name). Two nameless same-skill/mastery buckets can't be distinguished by our fields; the plan keeps only `(L) Dagger` (baseline 58) — the more conservative (higher-baseline, fewer false OD10s). Document this as a known minor gap in the commit message.
- [ ] **Step 4: Run → GREEN** (`-run TestOD`) all pass; then `-run .` whole package + `go vet ./...` clean.
- [ ] **Step 5: Commit**
```
feat(inventory-go): recompute OD from VTank loot-profile ladder (0-10 / casters 0-7)
Replaces the UtilityBelt over-retail float with the user's actual in-game OD:
per-weapon-type MaxDamage/elemVsMon thresholds from Loot.utl. Bows/thrown
deferred (null) pending a computed-stat calibration. (L) Dagger MS bucket
folded into (L) Dagger (indistinguishable by captured fields).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
```
---
### Task 2: Frontend integer rendering
**Files:** `frontend/src/components/inventory/ResultsTable.tsx`, `DetailPanel.tsx`.
- [ ] **Step 1:** In `ResultsTable.tsx` `cell()` `case 'od_rating'`, render integer:
```ts
case 'od_rating': {
const v = item.od_rating;
return v == null ? '—' : String(v);
}
```
- [ ] **Step 2:** In `DetailPanel.tsx`, change the OD row to plain integer:
```tsx
{item.od_rating != null && <Row k="Weapon OD" v={String(item.od_rating)} />}
```
- [ ] **Step 3:** Build `cd frontend && npm run build`; delete `static/_build`. Commit:
```
feat(frontend): render weapon OD as integer (0-10 loot-profile scale)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
```
---
### Task 3: Deploy backend + backfill + verify
- [ ] **Step 1:** Sync + test-gated build + recreate inventory-go (cutover override) — standard flow from CLAUDE.md.
- [ ] **Step 2:** Backfill: `ssh erik@overlord.snakedesert.se "curl -s -X POST http://127.0.0.1:8772/admin/backfill-od"`. Expect `{scanned, updated}`.
- [ ] **Step 3:** Verify distribution is 010 / casters ≤7 and spot-check known items:
```bash
ssh erik@overlord.snakedesert.se "docker exec inventory-db psql -U inventory_user -d inventory_db -tc \"SELECT min(od_rating),max(od_rating),count(*) FILTER (WHERE od_rating IS NOT NULL) FROM items WHERE object_class IN (1,9,31);\""
ssh erik@overlord.snakedesert.se "curl -s 'http://127.0.0.1:8770/inv/search/items?include_all_characters=true&weapon_only=true&min_od=1&sort_by=od&sort_dir=desc&limit=10' | python3 -c \"import json,sys;d=json.load(sys.stdin);[print(i.get('od_rating'),'|',i['name']) for i in d['items']]\""
```
Expect max = 10, casters max 7, Tetsubo = 10.
---
### Task 4: Deploy frontend + user verify
- [ ] `bash deploy-frontend.sh`, commit `static/`+`frontend/`, push, server `git pull` (handle the tar-sync working-tree blocker as before: verify blockers match origin, checkout/rm, re-pull).
- [ ] **User check:** ID a few weapons in-game and confirm the site's OD column matches the grey OD text (melee + crossbows + casters). Bows/thrown show `—` (deferred) — confirm that's acceptable and, if wanted, provide one bow OD reading to finish them.
---
## Self-review
- Melee 25 buckets + xbow + 2 casters implemented; bows/thrown null (documented).
- (L) Dagger MS folded into (L) Dagger — noted.
- Old variance/retail od.go fully deleted.
- od_rating stays the same column/param/sort; only values + rendering change.