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

14 KiB
Raw Blame History

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:

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):
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):

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:
    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:
      {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:
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.