docs: spec + plan to recompute OD from VTank loot profile (0-10 scale)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
cc403240d6
commit
7a11122a26
2 changed files with 380 additions and 0 deletions
303
docs/superpowers/plans/2026-07-15-weapon-od-loot-profile.md
Normal file
303
docs/superpowers/plans/2026-07-15-weapon-od-loot-profile.md
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
# 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 0–10 (casters 0–7) 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 (0–10, casters
|
||||
// 0–7), 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 0–10 / 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.
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
# Weapon OD — recompute from the VTank loot profile (0–10 scale)
|
||||
|
||||
**Date:** 2026-07-15
|
||||
**Status:** Approved (user directed the rebuild)
|
||||
|
||||
## Problem
|
||||
|
||||
The shipped OD (`od.go`) ports UtilityBelt's unbounded "over-retail damage"
|
||||
float (−16 … +43). The user's actual in-game OD is a **0–10** (casters 0–7)
|
||||
integer they see on ident. Investigation (this session) proved:
|
||||
|
||||
- Mag-Tools has **no** OD calculation. Its `ItemInfoPrinter` prints the item
|
||||
info **plus the matching VirindiTank loot-rule name** (`GetLootRuleInfoFromItemInfo`).
|
||||
- The user's `VirindiTank\Loot.utl` defines a ladder of rules named
|
||||
`(H) Axe (OD +10)`, `(OD +9)`, … per weapon bucket. Mag-Tools prints whichever
|
||||
matched — **that** is the OD the user sees.
|
||||
|
||||
The thresholds are the same across all the user's profiles (user-confirmed), so
|
||||
one table drives every weapon and caster.
|
||||
|
||||
## The rule (decoded from Loot.utl)
|
||||
|
||||
Each bucket is `OD = clamp(stat − baseline, 1, cap)`, where the bucket is matched
|
||||
by **weapon skill + mastery + (optional) weapon-name filter**, and `stat` is a
|
||||
raw captured value. Ladders step by 1 per tier.
|
||||
|
||||
- **Melee** (object_class 1): `stat = MaxDamage` (int key `218103842`), cap 10.
|
||||
27 buckets (skill `218103840`, mastery `353`, name filter, baseline) — see the
|
||||
table in the plan. Example: 2H Cleaving baseline 45 → Tetsubo (maxdmg 85) = OD
|
||||
**+10** (matches in-game); Heavy Axe baseline 74.
|
||||
- **Crossbow** (object_class 9, mastery `353`=9): `stat = int key 218103839`
|
||||
("effective missile damage"), baseline **77**, cap 10 (OD+10=87 … OD+1=78,
|
||||
verified on Fire Compound Crossbow k839=89 → +10).
|
||||
- **Casters** (object_class 31): `stat = ElementalDamageVersusMonsters` (double
|
||||
key `152`), baseline **1.18**, ×100, cap **7**. War bucket: skill `159`=34,
|
||||
name `Baton|Sceptre|Staff`; Void bucket: skill 43, name `Nether`. OD+7 at
|
||||
elemVsMon 1.25, OD+1 at 1.19. Frost Baton (1.40) → +7.
|
||||
- **No match / stat below baseline+1 → NULL** (item shows no OD, exactly like
|
||||
in-game when no OD rule matches).
|
||||
|
||||
## Deferred: Bows & Thrown (mastery 8 / 10)
|
||||
|
||||
Their OD rules threshold a **computed decimal** damage stat (bow OD+10 = 78.6,
|
||||
thrown = 85.3) on a key not reliably identifiable from the profile alone (bow
|
||||
`218103839` values 45–70 don't reach 78.6; the rule uses a DoubleValKeyGE the
|
||||
`.utl` encodes indirectly). To avoid showing a *wrong* number (the very problem
|
||||
we're fixing), **bows and thrown get NULL OD** in this pass. Reopened once the
|
||||
user supplies one in-game bow OD reading to calibrate the stat.
|
||||
|
||||
## Design
|
||||
|
||||
- **`od.go` rewritten** around an ordered bucket table (extracted from
|
||||
`Loot.utl`, committed as data): `{objectClass, skill, mastery, nameAlts[],
|
||||
statKey, baseline, cap}`. `computeOD` finds the first bucket whose
|
||||
objectClass+skill+mastery+name match, then `OD = clamp(stat − baseline, 1,
|
||||
cap)`; returns null if none match or stat ≤ baseline. Name match = any
|
||||
alternative is a substring of the item name (VTank substring semantics).
|
||||
- **Type**: `od_rating` stays `DOUBLE PRECISION NULL` but now holds an integer
|
||||
1–10 (or 1–7). Frontend already renders it; drop the `+`/decimals for the new
|
||||
integer (show plain `7`, `10`, `—`).
|
||||
- Search `min_od`/`max_od`/sort unchanged. Backfill via existing
|
||||
`POST /admin/backfill-od`.
|
||||
- The best-values table, spell-effect dicts, and variance math in the old
|
||||
`od.go` are **deleted** (wrong metric).
|
||||
|
||||
## Testing
|
||||
|
||||
Go golden tests from real items: Tetsubo → 10, Frost Baton → 7, Fire Compound
|
||||
Crossbow → 10, a mid melee → its expected tier, a below-baseline weapon → null,
|
||||
a bow → null (deferred). Then live backfill + spot-checks; the column must read
|
||||
0–10/0–7 and the user confirms against ident.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Bows/thrown OD (deferred, null).
|
||||
- The loot-rule name filters are reproduced as substring lists; exotic renamed
|
||||
items may differ from a live VTank match — acceptable.
|
||||
Loading…
Add table
Add a link
Reference in a new issue