Compare commits

..

12 commits

Author SHA1 Message Date
Erik
d5febaabda build(frontend): deploy weapon OD rating
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 08:23:07 +02:00
Erik
1dd95819c7 feat(frontend): weapon OD rating filter, column, and detail row
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 08:22:54 +02:00
Erik
fb6b54f9bd feat(inventory-go): min_od/max_od search filters + od sort
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 08:05:03 +02:00
Erik
3c0a8d9de8 feat(inventory-go): POST /admin/backfill-od recompute endpoint
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 08:04:00 +02:00
Erik
e07a9459cd feat(inventory-go): store od_rating at ingest + schema column
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 08:03:57 +02:00
Erik
9b2f28d942 fix(inventory-go): add Prodigal Spirit Drinker effect; document dmgMod key substitution; test wand mastery fallback
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 08:00:22 +02:00
Erik
99f394d1ef docs(spec): OD — add missing Prodigal Spirit Drinker effect, document dmgMod key substitution
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 07:58:36 +02:00
Erik
f75a236f54 fix(inventory-go): OD variance orientation per UB source; exact SpellInfo change/bonus semantics; missile golden test
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 07:46:07 +02:00
Erik
16077633c5 docs: correct OD spec/plan — variance orientation per UB source, SpellInfo change/bonus semantics
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 07:43:26 +02:00
Erik
231e6e799f feat(inventory-go): Mag-Tools OD weapon rating calculation (od.go)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 07:39:49 +02:00
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
Erik
10f8d135e5 docs(spec): weapon OD rating — compute at ingest, filter in search
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 07:26:32 +02:00
26 changed files with 1360 additions and 17 deletions

View file

@ -0,0 +1,388 @@
# 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):**
```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'"
```
**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.
```go
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 BETTER than the 0.47 baseline):
// varianceTinks = round(log(0.47/0.376)/log(0.8), 2) = -1.00 → OD = 71-(-1)-71 = +1.
// (Table MaxVar is the type's baseline variance; lower variance = higher OD.)
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**
```bash
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:
```go
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**
```bash
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:**
```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:
```go
mux.HandleFunc("POST /admin/backfill-od", srv.handleBackfillOD)
```
- [ ] **Step 3: Build/test** green (`-run .`).
- [ ] **Step 4: Commit**
```bash
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:
```go
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**
```bash
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):
```bash
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):
```bash
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:**
```bash
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:**
```bash
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:
```bash
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`:
```ts
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):
```ts
{ param: 'min_od', label: 'Weapon OD', common: true },
```
In `COLUMNS`, add after `max_damage`:
```ts
{ 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:
```ts
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:
```tsx
{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**
```bash
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`.

View file

@ -0,0 +1,132 @@
# Weapon OD rating — compute, store, filter
**Date:** 2026-07-15
**Status:** Approved
## Problem
The inventory search has weak weapon/wand support. Mag-Tools (and UtilityBelt's
port of it, the "retail comparison" / OD feature) classifies weapons by **OD
("over damage")** — how much better a weapon's optimally-tinked output is than
the best possible *retail* loot weapon of its class and wield tier. Users want
to filter weapons by OD.
## Authoritative algorithm (researched 2026-07-15)
Source: `UtilityBelt/UtilityBelt/Tools/ItemInfo.cs` (sections titled "Slightly
modified MagTools item description classes", OD display at lines ~631-647) +
`UtilityBelt/UtilityBelt/Lib/ItemInfoHelper/{MiscCalcs,WeaponMods,Dictionaries}.cs`
in this workspace. WeaponMods.cs holds the **best-values table**: rows keyed by
`(Skill, Mastery, MultiStrike, WieldReq)` with columns
`MaxDmg, MaxVar, MaxDmgMod, MaxElementalDmgBonus, MaxElementalDmgVsMonsters`
(sourced from acpedia; ~330 rows covering heavy/light/finesse/two-handed melee
subtypes, bow/xbow/thrown, war/void casters).
Raw item keys (verified present in our `item_raw_data`):
- IntValues: `159` EquipSkill, `353` Mastery/WeaponType, `160` WieldReqValue,
`218103842` MaxDamage, `204` ElementalDmgBonus, `171` Tinks, `179` Imbued
(bitmask, nonzero = imbued), `131` Material.
- `original_json->DoubleValues`: `167772171` Variance, `167772174` DamageBonus
(missile dmg modifier, e.g. 2.73 = 173%), `152` ElementalDmgVsMonsters
(wands), `167772169` SalvageWorkmanship.
- `original_json->Spells` / `ActiveSpells`: innate and active spell id arrays.
**Buffed values** (GetBuffedIntValueKey / GetBuffedDoubleValueKey semantics):
value + Σ **bonus**(innate Spells) − Σ **change**(ActiveSpells). Each spell
entry is a {change, bonus} pair (`SpellInfo(key, change, bonus=0)`,
Dictionaries.cs:356). Old-style item spells were converted to player auras in
2012, so their innate **bonus is 0** — only their active-enchant change is
subtracted; the Blood/Spirit Thirst cantrips have bonus == change:
- MaxDamage — 1616 BD VI {20,0}, 2096 Infected Caress {22,0}, 5183/4395
Incant. BD {24,0}, 2598 Minor BT {2,2}, 2586 Major BT {4,4}, 4661 Epic BT
{7,7}, 6089 Legendary BT {10,10}, 3688 Prodigal BD {300,0}.
- ElemVsMonsters — 3258 SD VI {.06,0}, 3259 Infected Spirit Caress {.07,0},
5182/4414 Incant. SD {.08,0}, 3251 Minor ST {.01,.01}, 3250 Major ST
{.03,.03}, 4670 Epic ST {.05,.05}, 6098 Legendary ST {.07,.07},
3735 Prodigal Spirit Drinker {.15,0}.
**Missile dmgMod key**: UB's literal CalcMissileDamage reads `DoubleValues[63]`,
but no DoubleValueKey ordinal 63 exists in Decal — the populated key is
`167772174` (DamageBonus), consistent with UB's own CalcedBuffedMissileDamage.
We use 167772174 deliberately.
**Melee variance orientation**: the table's MaxVar is the type's *baseline*
variance; a weapon with LOWER variance than baseline yields a NEGATIVE
varianceTinks (log ratio < 1 over log 0.8) and therefore a HIGHER OD —
`varianceTinks = round(log(MaxVar_t / variance)/log(0.8), 2)` exactly as
ItemInfo.cs:1096.
**MultiStrike** weapons: WeaponType (key 47 in UB code = our int key 47? no —
UB reads LongValueKey 47 = WeaponType master id; the multistrike check is:
`IntValues[47] ∈ {160,166,486}` or (`47==4` and mastery `353==11`)). Verify key
47 exists in our data during implementation; if absent, derive from mastery
subtype comments in the table (msdagger/mssword/cleaver rows are the
MultiStrike=1 rows).
Formulas (OD, higher = better; retail max = 0):
- **Melee** (object_class 1):
`varianceTinks = round(log(MaxVar_t / variance) / log(0.8), 2)`;
`OD = buffedMaxDmg − varianceTinks − MaxDmg_t`.
- **Missile** (object_class 9):
`dmgMod = buffedDmgBonus·100 − 100`; `arrowMax` by mastery: bow(8)=40,
xbow(9)=53, thrown(10)=42; `remainingTinks`: 10 − tinks, −1 more if not
imbued, floor 0 (loot-gen only, see gate below; UB nuance: if tinks==0,
remainingTinks=9);
`buffedDmg = buffedMaxDmg; if ≤10 then +24`;
`maxMod = (MaxDmgMod_t + 100 + 36)/100`;
`OD = (1 + (dmgMod + 4·remainingTinks)/100) · (buffedElemBonus + buffedDmg + arrowMax) / maxMod − (MaxElemBonus_t + 24 + arrowMax)`.
- **Wand** (object_class 31, skill 34 war / 43 void):
`OD = (buffedElemVsMonsters − MaxElemVsMonsters_t) · 100` (percentage points;
e.g. live Frost Baton: (1.40 − 1.18)·100 = +22).
- **Gate**: OD only for loot-gen items (`SalvageWorkmanship > 0`); otherwise
NULL. Table lookup miss (unknown skill/mastery/wieldreq combo) → NULL.
Round stored OD to 2 decimals.
## Decisions (user-approved)
1. **Raw OD number** with a `min OD ≥` filter (not a bucketed 0-10 scale).
2. **All classes**: melee, missile, wands.
3. **Computed at ingest**, stored on `items.od_rating DOUBLE PRECISION NULL`,
with a one-off backfill for existing rows.
## Backend (inventory-go)
- New `od.go`: best-values table ported from UB `WeaponMods.cs` (verify row
count and spot-check values against the source file), buffed-value helpers,
`computeOD(raw map[string]any) (float64, bool)`. TDD with golden values
(incl. the live Frost Baton +22 case).
- `processItem` sets `items["od_rating"]` (or omits when NULL) — the dynamic
insert in ingest.go picks it up automatically.
- `schema.go`: add `od_rating DOUBLE PRECISION` to the items DDL (fresh
installs). Live DB gets a manual
`ALTER TABLE items ADD COLUMN IF NOT EXISTS od_rating double precision`
(prod runs SKIP_SCHEMA_INIT=true).
- Search: `i.od_rating` added to the CTE; `min_od` (>=) and `max_od` (<=)
params; sort key `od`. NULL od never matches the filters (SQL NULL
semantics) and sorts NULLS LAST/FIRST as usual.
- Backfill: `POST /admin/backfill-od` — iterates items of object_class 1/9/31
joined to item_raw_data, recomputes, UPDATEs `od_rating`; returns counts.
Service is internal-only (127.0.0.1:8772 / compose network).
## Frontend
- `RATING_DEFS` gains `{param:'min_od', label:'Weapon OD', common:true}` —
flows through the existing ratings record, sidebar inputs, chips, and URL
state with no new plumbing.
- `COLUMNS` gains `{key:'od_rating', label:'OD', sortKey:'od', defaultVisible:false}`;
cell renders signed 2-decimal (`+3.25` / `-1.29`), `—` for null.
- `InvItem.od_rating?: number | null`; DetailPanel row "OD" for weapons.
## Verification
- Go unit tests (golden formula values + table spot checks) gate the image
build as usual.
- Live: backfill count > 0; `min_od=20` returns the Frost Baton; melee/missile
spot checks against UB in-game values if available; sort by OD desc.
## Out of scope
- Bucketed OD tiers (can be derived later).
- The old suitbuilder page.
- Recomputing OD when spell/enchant state changes intra-session (ingest
updates overwrite it naturally via the debounced item updates).

View file

@ -21,6 +21,7 @@ export function DetailPanel({ item, onClose }: { item: InvItem; onClose: () => v
<Row k="Workmanship" v={item.workmanship ?? '—'} /> <Row k="Workmanship" v={item.workmanship ?? '—'} />
{item.armor_level != null && item.armor_level > 0 && <Row k="Armor" v={item.armor_level} />} {item.armor_level != null && item.armor_level > 0 && <Row k="Armor" v={item.armor_level} />}
{item.max_damage != null && item.max_damage > 0 && <Row k="Max damage" v={item.max_damage} />} {item.max_damage != null && item.max_damage > 0 && <Row k="Max damage" v={item.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)} />}
{item.condition_percent != null && <Row k="Condition" v={`${item.condition_percent}%`} />} {item.condition_percent != null && <Row k="Condition" v={`${item.condition_percent}%`} />}
{item.item_set_name && <Row k="Set" v={item.item_set_name} />} {item.item_set_name && <Row k="Set" v={item.item_set_name} />}
{(item.is_bonded || item.is_attuned) && <Row k="Binding" v={[item.is_bonded && 'Bonded', item.is_attuned && 'Attuned'].filter(Boolean).join(', ')} />} {(item.is_bonded || item.is_attuned) && <Row k="Binding" v={[item.is_bonded && 'Bonded', item.is_attuned && 'Attuned'].filter(Boolean).join(', ')} />}

View file

@ -23,6 +23,11 @@ function cell(item: InvItem, key: string): React.ReactNode {
return item.value != null ? item.value.toLocaleString() : '—'; return item.value != null ? item.value.toLocaleString() : '—';
case 'last_updated': case 'last_updated':
return item.last_updated ? item.last_updated.slice(0, 16).replace('T', ' ') : '—'; return item.last_updated ? item.last_updated.slice(0, 16).replace('T', ' ') : '—';
case 'od_rating': {
const v = item.od_rating;
if (v == null) return '—';
return v > 0 ? `+${v.toFixed(2)}` : v.toFixed(2);
}
default: { default: {
const v = (item as any)[key]; const v = (item as any)[key];
return v == null || v === -1 ? '—' : String(v); return v == null || v === -1 ? '—' : String(v);

View file

@ -53,6 +53,7 @@ export const WEAPON_TYPES: Array<{ value: string; label: string }> = [
export interface RatingDef { param: string; label: string; common?: boolean; } export interface RatingDef { param: string; label: string; common?: boolean; }
export const RATING_DEFS: RatingDef[] = [ export const RATING_DEFS: RatingDef[] = [
{ param: 'min_od', label: 'Weapon OD', common: true },
{ param: 'min_damage_rating', label: 'Damage rating', common: true }, { param: 'min_damage_rating', label: 'Damage rating', common: true },
{ param: 'min_crit_damage_rating', label: 'Crit damage', common: true }, { param: 'min_crit_damage_rating', label: 'Crit damage', common: true },
{ param: 'min_heal_boost_rating', label: 'Heal boost', common: true }, { param: 'min_heal_boost_rating', label: 'Heal boost', common: true },
@ -89,6 +90,7 @@ export const COLUMNS: ColumnDef[] = [
{ key: 'spell_names', label: 'Spells / Cantrips', sortKey: 'spell_names', defaultVisible: true }, { key: 'spell_names', label: 'Spells / Cantrips', sortKey: 'spell_names', defaultVisible: true },
{ key: 'armor_level', label: 'Armor', sortKey: 'armor', defaultVisible: false }, { key: 'armor_level', label: 'Armor', sortKey: 'armor', defaultVisible: false },
{ key: 'max_damage', label: 'Max Dmg', sortKey: 'damage', defaultVisible: false }, { key: 'max_damage', label: 'Max Dmg', sortKey: 'damage', defaultVisible: false },
{ key: 'od_rating', label: 'OD', sortKey: 'od', defaultVisible: false },
{ key: 'workmanship', label: 'Work', sortKey: 'workmanship', defaultVisible: false }, { key: 'workmanship', label: 'Work', sortKey: 'workmanship', defaultVisible: false },
{ key: 'item_set_name', label: 'Set', sortKey: 'item_set', defaultVisible: false }, { key: 'item_set_name', label: 'Set', sortKey: 'item_set', defaultVisible: false },
{ key: 'object_class_name', label: 'Type', sortKey: 'item_type_name', defaultVisible: false }, { key: 'object_class_name', label: 'Type', sortKey: 'item_type_name', defaultVisible: false },

View file

@ -15,6 +15,7 @@ export interface InvItem {
item_set_name?: string; item_set_name?: string;
armor_level: number | null; armor_level: number | null;
max_damage: number | null; max_damage: number | null;
od_rating?: number | null;
condition_percent: number | null; condition_percent: number | null;
object_class_name?: string; object_class_name?: string;
material_name?: string; material_name?: string;

View file

@ -245,6 +245,49 @@ func (s *Server) handleDeleteItem(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"status": "deleted", "item_id": itemID}) writeJSON(w, http.StatusOK, map[string]any{"status": "deleted", "item_id": itemID})
} }
// 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})
}
func parseNaiveTime(s string) time.Time { func parseNaiveTime(s string) time.Time {
if s == "" { if s == "" {
return time.Now().UTC() return time.Now().UTC()

View file

@ -100,6 +100,7 @@ func main() {
mux.HandleFunc("POST /process-inventory", srv.handleProcessInventory) mux.HandleFunc("POST /process-inventory", srv.handleProcessInventory)
mux.HandleFunc("POST /inventory/{character_name}/item", srv.handleUpsertItem) mux.HandleFunc("POST /inventory/{character_name}/item", srv.handleUpsertItem)
mux.HandleFunc("DELETE /inventory/{character_name}/item/{item_id}", srv.handleDeleteItem) mux.HandleFunc("DELETE /inventory/{character_name}/item/{item_id}", srv.handleDeleteItem)
mux.HandleFunc("POST /admin/backfill-od", srv.handleBackfillOD)
// Suitbuilder (port of suitbuilder.py router, mounted at /suitbuilder). // Suitbuilder (port of suitbuilder.py router, mounted at /suitbuilder).
mux.HandleFunc("POST /suitbuilder/search", srv.handleSuitSearch) mux.HandleFunc("POST /suitbuilder/search", srv.handleSuitSearch)
mux.HandleFunc("GET /suitbuilder/characters", srv.handleSuitCharacters) mux.HandleFunc("GET /suitbuilder/characters", srv.handleSuitCharacters)

View file

@ -0,0 +1,577 @@
package main
import "math"
// od.go computes the Mag-Tools "OD" (over-damage vs best retail) rating for
// melee/missile/wand weapons, ported from UtilityBelt's
// Lib/ItemInfoHelper/{WeaponMods,MiscCalcs,Dictionaries}.cs and
// Tools/ItemInfo.cs (CalcMeleeDamage / CalcMissileDamage / the OD display
// lines ~631-647). See docs/superpowers/specs/2026-07-15-weapon-od-rating-design.md.
// bestKey mirrors the (Skill, Mastery, MultiStrike, WieldReq) DataTable.Select
// key UB uses for an EXACT match lookup — no nearest-tier rounding.
type bestKey struct {
skill int
mastery int
multiStrike int
wieldReq int
}
// bestValue mirrors the trailing DataTable columns:
// (MaxDmg, MaxVar, MaxDmgMod, MaxElementalDmgBonus, MaxElementalDmgVsMonsters).
type bestValue struct {
maxDmg float64
maxVar float64
maxDmgMod float64
maxElemBonus float64
maxElemVsMon float64
}
// bestValues is transcribed row-for-row from
// UtilityBelt/UtilityBelt/Lib/ItemInfoHelper/WeaponMods.cs BestValuesDatatable.GetTable().
// Column order in the source Rows.Add is:
// (Skill, Mastery, MultiStrike, WieldReq, MaxDmg, MaxVar, MaxDmgMod, MaxElementalDmgBonus, MaxElementalDmgVsMonsters)
// — omitted trailing columns default to 0. 301 rows total (matches
// `grep -c "table.Rows.Add" WeaponMods.cs`).
var bestValues = map[bestKey]bestValue{
// heavyweaponry (skill 44) http://acpedia.org/wiki/Heavy_Weaponry
// axe
{44, 3, 0, 0}: {26, .90, 0, 0, 0},
{44, 3, 0, 250}: {33, .90, 0, 0, 0},
{44, 3, 0, 300}: {40, .90, 0, 0, 0},
{44, 3, 0, 325}: {47, .90, 0, 0, 0},
{44, 3, 0, 350}: {54, .90, 0, 0, 0},
{44, 3, 0, 370}: {61, .90, 0, 0, 0},
{44, 3, 0, 400}: {69, .90, 0, 0, 0},
{44, 3, 0, 420}: {71, .90, 0, 0, 0},
{44, 3, 0, 430}: {74, .90, 0, 0, 0},
// dagger
{44, 6, 0, 0}: {24, .47, 0, 0, 0},
{44, 6, 0, 250}: {31, .47, 0, 0, 0},
{44, 6, 0, 300}: {38, .47, 0, 0, 0},
{44, 6, 0, 325}: {45, .47, 0, 0, 0},
{44, 6, 0, 350}: {51, .47, 0, 0, 0},
{44, 6, 0, 370}: {58, .47, 0, 0, 0},
{44, 6, 0, 400}: {65, .47, 0, 0, 0},
{44, 6, 0, 420}: {68, .47, 0, 0, 0},
{44, 6, 0, 430}: {71, .47, 0, 0, 0},
// msdagger
{44, 6, 1, 0}: {13, .40, 0, 0, 0},
{44, 6, 1, 250}: {16, .40, 0, 0, 0},
{44, 6, 1, 300}: {20, .40, 0, 0, 0},
{44, 6, 1, 325}: {23, .40, 0, 0, 0},
{44, 6, 1, 350}: {26, .40, 0, 0, 0},
{44, 6, 1, 370}: {30, .40, 0, 0, 0},
{44, 6, 1, 400}: {33, .40, 0, 0, 0},
{44, 6, 1, 420}: {36, .40, 0, 0, 0},
{44, 6, 1, 430}: {38, .40, 0, 0, 0},
// mace
{44, 4, 0, 0}: {22, .30, 0, 0, 0},
{44, 4, 0, 250}: {29, .30, 0, 0, 0},
{44, 4, 0, 300}: {36, .30, 0, 0, 0},
{44, 4, 0, 325}: {43, .30, 0, 0, 0},
{44, 4, 0, 350}: {49, .30, 0, 0, 0},
{44, 4, 0, 370}: {56, .30, 0, 0, 0},
{44, 4, 0, 400}: {63, .30, 0, 0, 0},
{44, 4, 0, 420}: {66, .30, 0, 0, 0},
{44, 4, 0, 430}: {69, .30, 0, 0, 0},
// spear
{44, 5, 0, 0}: {25, .59, 0, 0, 0},
{44, 5, 0, 250}: {32, .59, 0, 0, 0},
{44, 5, 0, 300}: {39, .59, 0, 0, 0},
{44, 5, 0, 325}: {46, .59, 0, 0, 0},
{44, 5, 0, 350}: {58, .59, 0, 0, 0},
{44, 5, 0, 370}: {59, .59, 0, 0, 0},
{44, 5, 0, 400}: {66, .59, 0, 0, 0},
{44, 5, 0, 420}: {69, .59, 0, 0, 0},
{44, 5, 0, 430}: {72, .59, 0, 0, 0},
// sword
{44, 2, 0, 0}: {24, .47, 0, 0, 0},
{44, 2, 0, 250}: {31, .47, 0, 0, 0},
{44, 2, 0, 300}: {38, .47, 0, 0, 0},
{44, 2, 0, 325}: {45, .47, 0, 0, 0},
{44, 2, 0, 350}: {51, .47, 0, 0, 0},
{44, 2, 0, 370}: {58, .47, 0, 0, 0},
{44, 2, 0, 400}: {65, .47, 0, 0, 0},
{44, 2, 0, 420}: {68, .47, 0, 0, 0},
{44, 2, 0, 430}: {71, .47, 0, 0, 0},
// mssword
{44, 2, 1, 0}: {13, .40, 0, 0, 0},
{44, 2, 1, 250}: {16, .40, 0, 0, 0},
{44, 2, 1, 300}: {20, .40, 0, 0, 0},
{44, 2, 1, 325}: {23, .40, 0, 0, 0},
{44, 2, 1, 350}: {26, .40, 0, 0, 0},
{44, 2, 1, 370}: {30, .40, 0, 0, 0},
{44, 2, 1, 400}: {33, .40, 0, 0, 0},
{44, 2, 1, 420}: {36, .40, 0, 0, 0},
{44, 2, 1, 430}: {38, .40, 0, 0, 0},
// staff
{44, 7, 0, 0}: {23, .38, 0, 0, 0},
{44, 7, 0, 250}: {30, .38, 0, 0, 0},
{44, 7, 0, 300}: {36, .38, 0, 0, 0},
{44, 7, 0, 325}: {43, .38, 0, 0, 0},
{44, 7, 0, 350}: {50, .38, 0, 0, 0},
{44, 7, 0, 370}: {56, .38, 0, 0, 0},
{44, 7, 0, 400}: {63, .38, 0, 0, 0},
{44, 7, 0, 420}: {66, .38, 0, 0, 0},
{44, 7, 0, 430}: {70, .38, 0, 0, 0},
// ua
{44, 1, 0, 0}: {20, .44, 0, 0, 0},
{44, 1, 0, 250}: {26, .44, 0, 0, 0},
{44, 1, 0, 300}: {31, .44, 0, 0, 0},
{44, 1, 0, 325}: {37, .44, 0, 0, 0},
{44, 1, 0, 350}: {43, .44, 0, 0, 0},
{44, 1, 0, 370}: {48, .44, 0, 0, 0},
{44, 1, 0, 400}: {54, .44, 0, 0, 0},
{44, 1, 0, 420}: {56, .44, 0, 0, 0},
{44, 1, 0, 430}: {59, .44, 0, 0, 0},
// light weaponry (skill 45) http://acpedia.org/wiki/Light_Weaponry
// axe
{45, 3, 0, 0}: {22, .80, 0, 0, 0},
{45, 3, 0, 250}: {28, .80, 0, 0, 0},
{45, 3, 0, 300}: {33, .80, 0, 0, 0},
{45, 3, 0, 325}: {39, .80, 0, 0, 0},
{45, 3, 0, 350}: {44, .80, 0, 0, 0},
{45, 3, 0, 370}: {50, .80, 0, 0, 0},
{45, 3, 0, 400}: {55, .80, 0, 0, 0},
{45, 3, 0, 420}: {57, .80, 0, 0, 0},
{45, 3, 0, 430}: {61, .80, 0, 0, 0},
// dagger
{45, 6, 0, 0}: {18, .42, 0, 0, 0},
{45, 6, 0, 250}: {24, .42, 0, 0, 0},
{45, 6, 0, 300}: {29, .42, 0, 0, 0},
{45, 6, 0, 325}: {35, .42, 0, 0, 0},
{45, 6, 0, 350}: {40, .42, 0, 0, 0},
{45, 6, 0, 370}: {46, .42, 0, 0, 0},
{45, 6, 0, 400}: {51, .42, 0, 0, 0},
{45, 6, 0, 420}: {54, .42, 0, 0, 0},
{45, 6, 0, 430}: {58, .42, 0, 0, 0},
// msdagger
{45, 6, 1, 0}: {7, .24, 0, 0, 0},
{45, 6, 1, 250}: {11, .24, 0, 0, 0},
{45, 6, 1, 300}: {13, .24, 0, 0, 0},
{45, 6, 1, 325}: {16, .24, 0, 0, 0},
{45, 6, 1, 350}: {18, .24, 0, 0, 0},
{45, 6, 1, 370}: {21, .24, 0, 0, 0},
{45, 6, 1, 400}: {24, .24, 0, 0, 0},
{45, 6, 1, 420}: {27, .24, 0, 0, 0},
{45, 6, 1, 430}: {28, .24, 0, 0, 0},
// mace
{45, 4, 0, 0}: {19, .23, 0, 0, 0},
{45, 4, 0, 250}: {24, .23, 0, 0, 0},
{45, 4, 0, 300}: {29, .23, 0, 0, 0},
{45, 4, 0, 325}: {35, .23, 0, 0, 0},
{45, 4, 0, 350}: {40, .23, 0, 0, 0},
{45, 4, 0, 370}: {45, .23, 0, 0, 0},
{45, 4, 0, 400}: {50, .23, 0, 0, 0},
{45, 4, 0, 420}: {53, .23, 0, 0, 0},
{45, 4, 0, 430}: {57, .23, 0, 0, 0},
// spear
{45, 5, 0, 0}: {21, .65, 0, 0, 0},
{45, 5, 0, 250}: {26, .65, 0, 0, 0},
{45, 5, 0, 300}: {32, .65, 0, 0, 0},
{45, 5, 0, 325}: {37, .65, 0, 0, 0},
{45, 5, 0, 350}: {42, .65, 0, 0, 0},
{45, 5, 0, 370}: {48, .65, 0, 0, 0},
{45, 5, 0, 400}: {53, .65, 0, 0, 0},
{45, 5, 0, 420}: {57, .65, 0, 0, 0},
{45, 5, 0, 430}: {60, .65, 0, 0, 0},
// sword
{45, 2, 0, 0}: {20, .42, 0, 0, 0},
{45, 2, 0, 250}: {25, .42, 0, 0, 0},
{45, 2, 0, 300}: {31, .42, 0, 0, 0},
{45, 2, 0, 325}: {36, .42, 0, 0, 0},
{45, 2, 0, 350}: {41, .42, 0, 0, 0},
{45, 2, 0, 370}: {47, .42, 0, 0, 0},
{45, 2, 0, 400}: {52, .42, 0, 0, 0},
{45, 2, 0, 420}: {55, .42, 0, 0, 0},
{45, 2, 0, 430}: {58, .42, 0, 0, 0},
// mssword
{45, 2, 1, 0}: {7, .24, 0, 0, 0},
{45, 2, 1, 250}: {10, .24, 0, 0, 0},
{45, 2, 1, 300}: {13, .24, 0, 0, 0},
{45, 2, 1, 325}: {16, .24, 0, 0, 0},
{45, 2, 1, 350}: {18, .24, 0, 0, 0},
{45, 2, 1, 370}: {21, .24, 0, 0, 0},
{45, 2, 1, 400}: {24, .24, 0, 0, 0},
{45, 2, 1, 420}: {25, .24, 0, 0, 0},
{45, 2, 1, 430}: {28, .24, 0, 0, 0},
// staff
{45, 7, 0, 0}: {19, .325, 0, 0, 0},
{45, 7, 0, 250}: {24, .325, 0, 0, 0},
{45, 7, 0, 300}: {30, .325, 0, 0, 0},
{45, 7, 0, 325}: {35, .325, 0, 0, 0},
{45, 7, 0, 350}: {40, .325, 0, 0, 0},
{45, 7, 0, 370}: {46, .325, 0, 0, 0},
{45, 7, 0, 400}: {51, .325, 0, 0, 0},
{45, 7, 0, 420}: {54, .325, 0, 0, 0},
{45, 7, 0, 430}: {57, .325, 0, 0, 0},
// ua
{45, 1, 0, 0}: {17, .43, 0, 0, 0},
{45, 1, 0, 250}: {23, .43, 0, 0, 0},
{45, 1, 0, 300}: {27, .43, 0, 0, 0},
{45, 1, 0, 325}: {31, .43, 0, 0, 0},
{45, 1, 0, 350}: {35, .43, 0, 0, 0},
{45, 1, 0, 370}: {40, .43, 0, 0, 0},
{45, 1, 0, 400}: {44, .43, 0, 0, 0},
{45, 1, 0, 420}: {46, .43, 0, 0, 0},
{45, 1, 0, 430}: {48, .43, 0, 0, 0},
// finesse weaponry (skill 46) http://acpedia.org/wiki/Finesse_Weaponry
// axe
{46, 3, 0, 0}: {22, .80, 0, 0, 0},
{46, 3, 0, 250}: {28, .80, 0, 0, 0},
{46, 3, 0, 300}: {33, .80, 0, 0, 0},
{46, 3, 0, 325}: {39, .80, 0, 0, 0},
{46, 3, 0, 350}: {44, .80, 0, 0, 0},
{46, 3, 0, 370}: {50, .80, 0, 0, 0},
{46, 3, 0, 400}: {55, .80, 0, 0, 0},
{46, 3, 0, 420}: {57, .80, 0, 0, 0},
{46, 3, 0, 430}: {61, .80, 0, 0, 0},
// dagger
{46, 6, 0, 0}: {18, .42, 0, 0, 0},
{46, 6, 0, 250}: {24, .42, 0, 0, 0},
{46, 6, 0, 300}: {29, .42, 0, 0, 0},
{46, 6, 0, 325}: {35, .42, 0, 0, 0},
{46, 6, 0, 350}: {40, .42, 0, 0, 0},
{46, 6, 0, 370}: {46, .42, 0, 0, 0},
{46, 6, 0, 400}: {51, .42, 0, 0, 0},
{46, 6, 0, 420}: {54, .42, 0, 0, 0},
{46, 6, 0, 430}: {58, .42, 0, 0, 0},
// msdagger
{46, 6, 1, 0}: {7, .24, 0, 0, 0},
{46, 6, 1, 250}: {11, .24, 0, 0, 0},
{46, 6, 1, 300}: {13, .24, 0, 0, 0},
{46, 6, 1, 325}: {16, .24, 0, 0, 0},
{46, 6, 1, 350}: {18, .24, 0, 0, 0},
{46, 6, 1, 370}: {21, .24, 0, 0, 0},
{46, 6, 1, 400}: {24, .24, 0, 0, 0},
{46, 6, 1, 420}: {27, .24, 0, 0, 0},
{46, 6, 1, 430}: {28, .24, 0, 0, 0},
// mace
{46, 4, 0, 0}: {19, .23, 0, 0, 0},
{46, 4, 0, 250}: {24, .23, 0, 0, 0},
{46, 4, 0, 300}: {29, .23, 0, 0, 0},
{46, 4, 0, 325}: {35, .23, 0, 0, 0},
{46, 4, 0, 350}: {40, .23, 0, 0, 0},
{46, 4, 0, 370}: {45, .23, 0, 0, 0},
{46, 4, 0, 400}: {50, .23, 0, 0, 0},
{46, 4, 0, 420}: {53, .23, 0, 0, 0},
{46, 4, 0, 430}: {57, .23, 0, 0, 0},
// spear
{46, 5, 0, 0}: {21, .65, 0, 0, 0},
{46, 5, 0, 250}: {26, .65, 0, 0, 0},
{46, 5, 0, 300}: {32, .65, 0, 0, 0},
{46, 5, 0, 325}: {37, .65, 0, 0, 0},
{46, 5, 0, 350}: {42, .65, 0, 0, 0},
{46, 5, 0, 370}: {48, .65, 0, 0, 0},
{46, 5, 0, 400}: {53, .65, 0, 0, 0},
{46, 5, 0, 420}: {57, .65, 0, 0, 0},
{46, 5, 0, 430}: {60, .65, 0, 0, 0},
// sword
{46, 2, 0, 0}: {20, .42, 0, 0, 0},
{46, 2, 0, 250}: {25, .42, 0, 0, 0},
{46, 2, 0, 300}: {31, .42, 0, 0, 0},
{46, 2, 0, 325}: {36, .42, 0, 0, 0},
{46, 2, 0, 350}: {41, .42, 0, 0, 0},
{46, 2, 0, 370}: {47, .42, 0, 0, 0},
{46, 2, 0, 400}: {52, .42, 0, 0, 0},
{46, 2, 0, 420}: {55, .42, 0, 0, 0},
{46, 2, 0, 430}: {58, .42, 0, 0, 0},
// mssword
{46, 2, 1, 0}: {7, .24, 0, 0, 0},
{46, 2, 1, 250}: {10, .24, 0, 0, 0},
{46, 2, 1, 300}: {13, .24, 0, 0, 0},
{46, 2, 1, 325}: {16, .24, 0, 0, 0},
{46, 2, 1, 350}: {18, .24, 0, 0, 0},
{46, 2, 1, 370}: {21, .24, 0, 0, 0},
{46, 2, 1, 400}: {24, .24, 0, 0, 0},
{46, 2, 1, 420}: {25, .24, 0, 0, 0},
{46, 2, 1, 430}: {28, .24, 0, 0, 0},
// staff
{46, 7, 0, 0}: {19, .325, 0, 0, 0},
{46, 7, 0, 250}: {24, .325, 0, 0, 0},
{46, 7, 0, 300}: {30, .325, 0, 0, 0},
{46, 7, 0, 325}: {35, .325, 0, 0, 0},
{46, 7, 0, 350}: {40, .325, 0, 0, 0},
{46, 7, 0, 370}: {46, .325, 0, 0, 0},
{46, 7, 0, 400}: {51, .325, 0, 0, 0},
{46, 7, 0, 420}: {54, .325, 0, 0, 0},
{46, 7, 0, 430}: {57, .325, 0, 0, 0},
// ua
{46, 1, 0, 0}: {17, .43, 0, 0, 0},
{46, 1, 0, 250}: {23, .43, 0, 0, 0},
{46, 1, 0, 300}: {27, .43, 0, 0, 0},
{46, 1, 0, 325}: {31, .43, 0, 0, 0},
{46, 1, 0, 350}: {35, .43, 0, 0, 0},
{46, 1, 0, 370}: {40, .43, 0, 0, 0},
{46, 1, 0, 400}: {44, .43, 0, 0, 0},
{46, 1, 0, 420}: {46, .43, 0, 0, 0},
{46, 1, 0, 430}: {48, .43, 0, 0, 0},
// twohanded (skill 41) http://acpedia.org/wiki/Two_Handed_Weaponry
// cleaver
{41, 11, 1, 0}: {13, .30, 0, 0, 0},
{41, 11, 1, 250}: {17, .30, 0, 0, 0},
{41, 11, 1, 300}: {22, .30, 0, 0, 0},
{41, 11, 1, 325}: {26, .30, 0, 0, 0},
{41, 11, 1, 350}: {30, .30, 0, 0, 0},
{41, 11, 1, 370}: {35, .30, 0, 0, 0},
{41, 11, 1, 400}: {39, .30, 0, 0, 0},
{41, 11, 1, 420}: {42, .30, 0, 0, 0},
{41, 11, 1, 430}: {45, .30, 0, 0, 0},
// spear
{41, 11, 0, 0}: {14, .35, 0, 0, 0},
{41, 11, 0, 250}: {19, .35, 0, 0, 0},
{41, 11, 0, 300}: {24, .35, 0, 0, 0},
{41, 11, 0, 325}: {29, .35, 0, 0, 0},
{41, 11, 0, 350}: {33, .35, 0, 0, 0},
{41, 11, 0, 370}: {37, .35, 0, 0, 0},
{41, 11, 0, 400}: {42, .35, 0, 0, 0},
{41, 11, 0, 420}: {45, .35, 0, 0, 0},
{41, 11, 0, 430}: {48, .35, 0, 0, 0},
// missile (skill 47) http://acpedia.org/wiki/Missile_Weaponry/Bows
// bow (mastery 8)
{47, 8, 0, 0}: {0, 0, 110, 0, 0},
{47, 8, 0, 250}: {0, 0, 120, 0, 0},
{47, 8, 0, 270}: {0, 0, 130, 0, 0},
{47, 8, 0, 290}: {0, 0, 140, 0, 0},
{47, 8, 0, 315}: {0, 0, 140, 5, 0},
{47, 8, 0, 335}: {0, 0, 140, 9, 0},
{47, 8, 0, 360}: {0, 0, 140, 16, 0},
{47, 8, 0, 375}: {0, 0, 140, 19, 0},
{47, 8, 0, 385}: {0, 0, 140, 22, 0},
// xbow (mastery 9)
{47, 9, 0, 0}: {0, 0, 140, 0, 0},
{47, 9, 0, 250}: {0, 0, 150, 0, 0},
{47, 9, 0, 270}: {0, 0, 155, 0, 0},
{47, 9, 0, 290}: {0, 0, 165, 0, 0},
{47, 9, 0, 315}: {0, 0, 165, 5, 0},
{47, 9, 0, 335}: {0, 0, 165, 9, 0},
{47, 9, 0, 360}: {0, 0, 165, 16, 0},
{47, 9, 0, 375}: {0, 0, 165, 19, 0},
{47, 9, 0, 385}: {0, 0, 165, 22, 0},
// thrown (mastery 10)
{47, 10, 0, 0}: {0, 0, 130, 0, 0},
{47, 10, 0, 250}: {0, 0, 140, 0, 0},
{47, 10, 0, 270}: {0, 0, 150, 0, 0},
{47, 10, 0, 290}: {0, 0, 160, 0, 0},
{47, 10, 0, 315}: {0, 0, 160, 5, 0},
{47, 10, 0, 335}: {0, 0, 160, 9, 0},
{47, 10, 0, 360}: {0, 0, 160, 16, 0},
{47, 10, 0, 375}: {0, 0, 160, 19, 0},
{47, 10, 0, 385}: {0, 0, 160, 22, 0},
// wands (skill 34 war / 43 void) http://acpedia.org/wiki/Magic_Casters
{34, 0, 0, 290}: {0, 0, 0, 0, 1.03},
{34, 0, 0, 315}: {0, 0, 0, 0, 1.06},
{34, 0, 0, 335}: {0, 0, 0, 0, 1.09},
{34, 0, 0, 360}: {0, 0, 0, 0, 1.13},
{34, 0, 0, 375}: {0, 0, 0, 0, 1.16},
{34, 0, 0, 385}: {0, 0, 0, 0, 1.18},
{43, 0, 0, 290}: {0, 0, 0, 0, 1.03},
{43, 0, 0, 315}: {0, 0, 0, 0, 1.06},
{43, 0, 0, 335}: {0, 0, 0, 0, 1.09},
{43, 0, 0, 360}: {0, 0, 0, 0, 1.13},
{43, 0, 0, 375}: {0, 0, 0, 0, 1.16},
{43, 0, 0, 385}: {0, 0, 0, 0, 1.18},
// void vr wand - not sure why this is different (mastery 12)
{43, 12, 0, 385}: {0, 0, 0, 0, 1.18},
}
// spellEffect mirrors UB's SpellInfo(key, change, bonus) (Dictionaries.cs:356):
// `change` is what the active enchantment adds to the shown value (subtracted
// to un-buff), `bonus` is what the spell grants when innate on the item (0 for
// the 2012 aura-converted item spells).
type odSpellEffect struct {
change float64
bonus float64
}
// maxDamageSpellEffects is transcribed from UB Dictionaries.cs
// LongValueKeySpellEffects entries keyed on LongValueKey_MaxDamage (218103842).
var maxDamageSpellEffects = map[int]odSpellEffect{
1616: {20, 0}, // Blood Drinker VI
2096: {22, 0}, // Infected Caress
5183: {24, 0}, // Incantation of Blood Drinker (post Feb-2013)
4395: {24, 0}, // Incantation of Blood Drinker (post Feb-2013)
2598: {2, 2}, // Minor Blood Thirst
2586: {4, 4}, // Major Blood Thirst
4661: {7, 7}, // Epic Blood Thirst
6089: {10, 10}, // Legendary Blood Thirst
3688: {300, 0}, // Prodigal Blood Drinker
}
// elemVsMonSpellEffects is transcribed from UB Dictionaries.cs
// DoubleValueKeySpellEffects entries keyed on DoubleValueKey_ElementalDamageVersusMonsters (152).
var elemVsMonSpellEffects = map[int]odSpellEffect{
3258: {.06, 0}, // Spirit Drinker VI
3259: {.07, 0}, // Infected Spirit Caress
5182: {.08, 0}, // Incantation of Spirit Drinker (post Feb-2013)
4414: {.08, 0}, // Incantation of Spirit Drinker (post Feb-2013)
3251: {.01, .01}, // Minor Spirit Thirst
3250: {.03, .03}, // Major Spirit Thirst
4670: {.05, .05}, // Epic Spirit Thirst
6098: {.07, .07}, // Legendary Spirit Thirst
3735: {.15, 0}, // Prodigal Spirit Drinker
}
// buffedMaxDamage mirrors GetBuffedIntValueKey(218103842): base value minus
// `change` for each active spell (un-buff the enchantment), plus `bonus` for
// each innate spell.
func buffedMaxDamage(raw, iv map[string]any) int {
value := ivI(iv, "218103842", 0)
for _, id := range toIntList(raw["ActiveSpells"]) {
if e, ok := maxDamageSpellEffects[id]; ok {
value -= int(e.change)
}
}
for _, id := range toIntList(raw["Spells"]) {
if e, ok := maxDamageSpellEffects[id]; ok {
value += int(e.bonus)
}
}
return value
}
// buffedElemVsMon mirrors GetBuffedDoubleValueKey(152) using the same
// subtract-change-for-active / add-bonus-for-innate semantics.
func buffedElemVsMon(raw, dv map[string]any) float64 {
value := dvF(dv, "152", 0)
for _, id := range toIntList(raw["ActiveSpells"]) {
if e, ok := elemVsMonSpellEffects[id]; ok {
value -= e.change
}
}
for _, id := range toIntList(raw["Spells"]) {
if e, ok := elemVsMonSpellEffects[id]; ok {
value += e.bonus
}
}
return value
}
// isMultiStrike mirrors MiscCalcs.GetMaxProperty's MultiStrike detection:
// IntValues[47] (WeaponType master id) ∈ {160,166,486}, or (==4 and Mastery==11).
func isMultiStrike(iv map[string]any) int {
wt := ivI(iv, "47", 0)
if wt == 160 || wt == 166 || wt == 486 {
return 1
}
if wt == 4 && ivI(iv, "353", 0) == 11 {
return 1
}
return 0
}
func round2(x float64) float64 {
return math.Round(x*100) / 100
}
// computeOD ports UB's OD display formulas (ItemInfo.cs CalcMeleeDamage /
// CalcMissileDamage + the OD lines ~631-647). Returns (0, false) when OD does
// not apply: non weapon/wand object class, non-loot-gen item (no
// SalvageWorkmanship), or an unrecognized (skill, mastery, multiStrike,
// wieldReq) tier (exact match only — no nearest-tier rounding).
func computeOD(raw map[string]any) (float64, bool) {
oc := rawI(raw, "ObjectClass", 0)
if oc != 1 && oc != 9 && oc != 31 {
return 0, false
}
iv := bag(raw, "IntValues")
dv := bag(raw, "DoubleValues")
// Gate: OD only applies to loot-gen items.
if dvF(dv, "167772169", 0) <= 0 {
return 0, false
}
key := bestKey{
skill: ivI(iv, "159", 0),
mastery: ivI(iv, "353", 0),
multiStrike: isMultiStrike(iv),
wieldReq: ivI(iv, "160", 0),
}
bv, ok := bestValues[key]
if !ok && oc == 31 {
// Mastery-0 retry for wands: an ADDITION over UB (UB's
// DataTable.Select has no retry; our captured wands may carry a
// mastery key 353 while the table's wand rows use mastery 0).
// Keeps the void VR wand row (43,12,...) reachable on the first
// exact-match attempt while other mastery-carrying wands fall back
// to the normal (34|43, 0, ...) rows.
key.mastery = 0
bv, ok = bestValues[key]
}
if !ok {
return 0, false
}
switch oc {
case 1: // melee
variance := dvF(dv, "167772171", 0)
if variance <= 0 {
return 0, false
}
// UB ItemInfo.cs:1096: Math.Log(perfectVariance / Variance, 0.8).
// Table MaxVar is the tier's baseline variance; lower variance than
// baseline yields negative varianceTinks, i.e. a positive OD credit.
varianceTinks := round2(math.Log(bv.maxVar/variance) / math.Log(0.8))
od := float64(buffedMaxDamage(raw, iv)) - varianceTinks - bv.maxDmg
return round2(od), true
case 9: // missile
var arrowMax float64
switch ivI(iv, "353", 0) {
case 8:
arrowMax = 40
case 9:
arrowMax = 53
case 10:
arrowMax = 42
default:
arrowMax = 0
}
tinks := ivI(iv, "171", 0)
imbued := ivI(iv, "179", 0)
var remaining int
if tinks == 0 {
remaining = 9
} else {
remaining = 10 - tinks
if imbued == 0 {
remaining--
}
}
if remaining < 0 {
remaining = 0
}
// NOTE: UB's literal CalcMissileDamage reads DoubleValues[63], but no
// DoubleValueKey ordinal 63 exists (verified against Decal.Adapter) —
// that line is a stale/bugged reference in the UB source. The correct
// populated key is 167772174 (DamageBonus), consistent with UB's own
// CalcedBuffedMissileDamage. Do NOT "fix" this back to 63.
dmgMod := dvF(dv, "167772174", 0)*100 - 100
bDmg := float64(buffedMaxDamage(raw, iv))
if bDmg <= 10 {
bDmg += 24
}
bElem := float64(ivI(iv, "204", 0))
maxMod := (bv.maxDmgMod + 136) / 100
od := (1+(dmgMod+4*float64(remaining))/100)*(bElem+bDmg+arrowMax)/maxMod - (bv.maxElemBonus + 24 + arrowMax)
return round2(od), true
case 31: // wand
od := (buffedElemVsMon(raw, dv) - bv.maxElemVsMon) * 100
return round2(od), true
}
return 0, false
}

View file

@ -0,0 +1,180 @@
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)
}
}
// Wand with an ACTIVE Prodigal Spirit Drinker (3735, change .15, bonus 0):
// shown ElemVsMonsters 1.55 un-buffs to 1.55 − .15 = 1.40 → OD +22, same
// underlying weapon as TestOD_Wand.
func TestOD_WandActiveProdigalSpiritDrinker(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.55, "167772169": 8.0},
"Spells": []any{}, "ActiveSpells": []any{3735.0},
}
od, ok := computeOD(raw)
if !ok || !approx(od, 22.0) {
t.Fatalf("wand w/ active Prodigal SD OD = %v ok=%v, want +22", od, ok)
}
}
// Wand carrying a mastery value (353) with no matching wand table row: the
// exact-match lookup on (34,5,0,385) misses, and the mastery-0 retry must hit
// the (34,0,0,385) row → OD +22 as in TestOD_Wand.
func TestOD_WandMasteryFallback(t *testing.T) {
raw := map[string]any{
"ObjectClass": 31.0,
"IntValues": map[string]any{"159": 34.0, "353": 5.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 mastery-fallback 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 the 0.47 baseline):
// the table MaxVar is the BASELINE variance for the tier, and lower variance than
// baseline is better — it contributes positively to OD. Per UB ItemInfo.cs:1096
// (Math.Log(perfectVariance / Variance, 0.8)):
// 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)
}
}
// Exact SpellInfo(change, bonus) semantics (UB Dictionaries.cs:356): innate
// Spells add `bonus`, ActiveSpells subtract `change`. Incantation of Blood
// Drinker (5183) has change=24 but bonus=0 (the 2012 aura conversion), so:
// - innate 5183 grants NOTHING → maxDmg 71 + 0 = 71 → OD 0;
// - active 5183 on an enchanted maxDmg 95 (71+24) subtracts 24 → 71 → OD 0.
func TestOD_MeleeInnateIncantBDGrantsNothing(t *testing.T) {
innate := 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{5183.0}, "ActiveSpells": []any{},
}
od, ok := computeOD(innate)
if !ok || !approx(od, 0.0) {
t.Fatalf("innate Incant BD OD = %v ok=%v, want 0 (bonus is 0)", od, ok)
}
active := map[string]any{
"ObjectClass": 1.0,
"IntValues": map[string]any{"159": 44.0, "353": 2.0, "160": 430.0, "218103842": 95.0},
"DoubleValues": map[string]any{"167772171": 0.47, "167772169": 10.0},
"Spells": []any{}, "ActiveSpells": []any{5183.0},
}
od, ok = computeOD(active)
if !ok || !approx(od, 0.0) {
t.Fatalf("active Incant BD OD = %v ok=%v, want 0 (change 24 subtracted)", od, ok)
}
}
// Missile golden test, modeled on the live Fire Compound Bow:
// bow (mastery 8), wieldreq 385, maxDmg 32, elem bonus 21, DamageBonus 2.73,
// no "171" key → tinks 0 → remaining 9 (the imbue tink is reserved).
// dmgMod = 2.73·100−100 = 173; arrowMax = 40; maxMod = (140+136)/100 = 2.76;
// OD = (1+(173+4·9)/100)·(21+32+40)/2.76 − (22+24+40) = 3.09·93/2.76 − 86 ≈ 18.12.
func TestOD_MissileBow(t *testing.T) {
raw := map[string]any{
"ObjectClass": 9.0,
"IntValues": map[string]any{"159": 47.0, "353": 8.0, "160": 385.0, "218103842": 32.0, "204": 21.0},
"DoubleValues": map[string]any{"167772174": 2.73, "167772169": 6.0},
"Spells": []any{}, "ActiveSpells": []any{},
}
od, ok := computeOD(raw)
if !ok || !approx(od, 18.12) {
t.Fatalf("missile bow OD = %v ok=%v, want 18.12", od, ok)
}
}

View file

@ -136,6 +136,10 @@ func (s *Server) processItem(raw map[string]any) map[string]any {
"remaining_lifespan": nilNeg(ivI(iv, "268", -1)), "remaining_lifespan": nilNeg(ivI(iv, "268", -1)),
} }
if od, ok := computeOD(raw); ok {
items["od_rating"] = od
}
// combat (sentinel defaults), then base values merged. // combat (sentinel defaults), then base values merged.
wt := ivI(iv, "218103835", -1) wt := ivI(iv, "218103835", -1)
if wt > 100 { if wt > 100 {

View file

@ -38,6 +38,7 @@ func initSchema(ctx context.Context, pool *pgxpool.Pool, log *slog.Logger) {
rare_id INTEGER, rare_id INTEGER,
lifespan INTEGER, lifespan INTEGER,
remaining_lifespan INTEGER, remaining_lifespan INTEGER,
od_rating DOUBLE PRECISION,
has_id_data BOOLEAN DEFAULT false, has_id_data BOOLEAN DEFAULT false,
last_id_time BIGINT DEFAULT 0, last_id_time BIGINT DEFAULT 0,
CONSTRAINT uq_char_item UNIQUE (character_name, item_id) CONSTRAINT uq_char_item UNIQUE (character_name, item_id)

View file

@ -23,6 +23,7 @@ import (
const cteSelect = ` const cteSelect = `
SELECT DISTINCT SELECT DISTINCT
i.id AS db_item_id, i.character_name, i.name, i.icon, i.object_class, i.value, i.burden, i.id AS db_item_id, i.character_name, i.name, i.icon, i.object_class, i.value, i.burden,
i.od_rating,
i.current_wielded_location, i.bonded, i.attuned, i."unique", i.stack_size, i.max_stack_size, i.current_wielded_location, i.bonded, i.attuned, i."unique", i.stack_size, i.max_stack_size,
i.structure, i.max_structure, i.rare_id, i.timestamp AS last_updated, i.structure, i.max_structure, i.rare_id, i.timestamp AS last_updated,
COALESCE(cs.max_damage, -1) AS max_damage, COALESCE(cs.max_damage, -1) AS max_damage,
@ -133,6 +134,7 @@ var sortMapping = map[string]string{
"crit_damage_resist_rating": "crit_damage_resist_rating", "item_set": "item_set", "crit_damage_resist_rating": "crit_damage_resist_rating", "item_set": "item_set",
"coverage": "coverage_mask", "item_type_name": "object_class", "coverage": "coverage_mask", "item_type_name": "object_class",
"last_updated": "last_updated", "spell_names": "computed_spell_names", "last_updated": "last_updated", "spell_names": "computed_spell_names",
"od": "od_rating",
} }
// argBuilder accumulates positional ($N) query args. // argBuilder accumulates positional ($N) query args.
@ -219,6 +221,7 @@ func (s *Server) runSearch(ctx context.Context, q url.Values) (map[string]any, e
{"min_pk_damage_resist_rating", "pk_damage_resist_rating"}, {"min_gear_pk_damage_rating", "gear_pk_damage_rating"}, {"min_pk_damage_resist_rating", "pk_damage_resist_rating"}, {"min_gear_pk_damage_rating", "gear_pk_damage_rating"},
{"min_gear_pk_damage_resist_rating", "gear_pk_damage_resist_rating"}, {"min_tinks", "tinks"}, {"min_gear_pk_damage_resist_rating", "gear_pk_damage_resist_rating"}, {"min_tinks", "tinks"},
{"min_value", "value"}, {"min_workmanship", "workmanship"}, {"min_value", "value"}, {"min_workmanship", "workmanship"},
{"min_od", "od_rating"},
} }
for _, f := range geFilters { for _, f := range geFilters {
if v := q.Get(f.param); v != "" { if v := q.Get(f.param); v != "" {
@ -241,6 +244,11 @@ func (s *Server) runSearch(ctx context.Context, q url.Values) (map[string]any, e
conds = append(conds, "attack_bonus >= "+ab.add(n)) conds = append(conds, "attack_bonus >= "+ab.add(n))
} }
} }
if v := q.Get("max_od"); v != "" {
if n, err := strconv.ParseFloat(v, 64); err == nil {
conds = append(conds, "od_rating <= "+ab.add(n))
}
}
// --- requirements (wield level) --- // --- requirements (wield level) ---
if v, ok := qInt(q, "max_level"); ok { if v, ok := qInt(q, "max_level"); ok {

View file

@ -1 +1 @@
import{e as I,r as t,l as _,f as L,g as N,h as W,j as e,D as F}from"./index-D5aV9Wxd.js";import"./react-yfL0ty4i.js";function R(c){try{return new Date(c).toISOString().slice(0,10)}catch{return c}}const q=({id:c,zIndex:x})=>{const{user:j}=I(),[g,k]=t.useState([]),[w,p]=t.useState(!0),[b,n]=t.useState(null),[l,C]=t.useState(""),[i,f]=t.useState(""),[h,y]=t.useState(!1),[d,S]=t.useState(!1),[A,u]=t.useState(null),[o,m]=t.useState(""),r=t.useCallback(async()=>{p(!0),n(null);try{const s=await _();k(s.users??[])}catch(s){n(String(s))}finally{p(!1)}},[]);t.useEffect(()=>{r()},[r]);const U=t.useCallback(async s=>{if(s.preventDefault(),!l.trim()||i.length<4){n("Username required and password must be at least 4 chars");return}S(!0),n(null);try{await L(l.trim(),i,h),C(""),f(""),y(!1),await r()}catch(a){n(String(a))}finally{S(!1)}},[l,i,h,r]),v=t.useCallback(async s=>{n(null);try{await N(s.id,{is_admin:!s.is_admin}),await r()}catch(a){n(String(a))}},[r]),D=t.useCallback(async s=>{if(o.length<4){n("Password must be at least 4 characters");return}n(null);try{await N(s,{password:o}),u(null),m("")}catch(a){n(String(a))}},[o]),P=t.useCallback(async s=>{if(confirm(`Delete user "${s.username}"? This cannot be undone.`)){n(null);try{await W(s.id),await r()}catch(a){n(String(a))}}},[r]);return e.jsx(F,{id:c,title:"🛡️ Admin · Users",zIndex:x,width:620,height:540,children:e.jsxs("div",{className:"ml-admin",children:[b&&e.jsx("div",{className:"ml-admin-error",children:b}),e.jsxs("section",{className:"ml-admin-section",children:[e.jsx("h3",{children:"Add user"}),e.jsxs("form",{onSubmit:U,className:"ml-admin-create",children:[e.jsx("input",{type:"text",placeholder:"Username",value:l,onChange:s=>C(s.target.value),disabled:d,autoComplete:"off"}),e.jsx("input",{type:"password",placeholder:"Password (min 4)",value:i,onChange:s=>f(s.target.value),disabled:d,autoComplete:"new-password"}),e.jsxs("label",{children:[e.jsx("input",{type:"checkbox",checked:h,onChange:s=>y(s.target.checked),disabled:d}),"admin"]}),e.jsx("button",{type:"submit",disabled:d||!l.trim()||i.length<4,children:d?"Adding…":"Add"})]})]}),e.jsxs("section",{className:"ml-admin-section",children:[e.jsxs("h3",{children:["Users ",w&&e.jsx("span",{className:"ml-admin-muted",children:"(loading…)"})]}),e.jsxs("table",{className:"ml-admin-table",children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"ID"}),e.jsx("th",{children:"Username"}),e.jsx("th",{children:"Admin"}),e.jsx("th",{children:"Created"}),e.jsx("th",{children:"Actions"})]})}),e.jsxs("tbody",{children:[g.map(s=>{const a=j!=null&&j.username.toLowerCase()===s.username.toLowerCase();return e.jsxs("tr",{children:[e.jsx("td",{children:s.id}),e.jsxs("td",{children:[s.username,a&&e.jsx("span",{className:"ml-admin-muted",children:" (you)"})]}),e.jsx("td",{children:e.jsx("button",{className:"ml-admin-toggle",onClick:()=>v(s),title:"Click to toggle admin",children:s.is_admin?"✓":"–"})}),e.jsx("td",{children:R(s.created_at)}),e.jsx("td",{children:A===s.id?e.jsxs("span",{className:"ml-admin-pw-edit",children:[e.jsx("input",{type:"text",placeholder:"New password",value:o,onChange:E=>m(E.target.value),autoFocus:!0}),e.jsx("button",{onClick:()=>D(s.id),children:"Save"}),e.jsx("button",{onClick:()=>{u(null),m("")},children:"Cancel"})]}):e.jsxs(e.Fragment,{children:[e.jsx("button",{onClick:()=>{u(s.id),m("")},children:"Reset PW"}),!a&&e.jsx("button",{className:"ml-admin-danger",onClick:()=>P(s),children:"Delete"})]})})]},s.id)}),g.length===0&&!w&&e.jsx("tr",{children:e.jsx("td",{colSpan:5,className:"ml-admin-muted",children:"No users."})})]})]})]})]})})};export{q as AdminUsersWindow}; import{e as I,r as t,l as _,f as L,g as N,h as W,j as e,D as F}from"./index-CPIBhBUe.js";import"./react-yfL0ty4i.js";function R(c){try{return new Date(c).toISOString().slice(0,10)}catch{return c}}const q=({id:c,zIndex:x})=>{const{user:j}=I(),[g,k]=t.useState([]),[w,p]=t.useState(!0),[b,n]=t.useState(null),[l,C]=t.useState(""),[i,f]=t.useState(""),[h,y]=t.useState(!1),[d,S]=t.useState(!1),[A,u]=t.useState(null),[o,m]=t.useState(""),r=t.useCallback(async()=>{p(!0),n(null);try{const s=await _();k(s.users??[])}catch(s){n(String(s))}finally{p(!1)}},[]);t.useEffect(()=>{r()},[r]);const U=t.useCallback(async s=>{if(s.preventDefault(),!l.trim()||i.length<4){n("Username required and password must be at least 4 chars");return}S(!0),n(null);try{await L(l.trim(),i,h),C(""),f(""),y(!1),await r()}catch(a){n(String(a))}finally{S(!1)}},[l,i,h,r]),v=t.useCallback(async s=>{n(null);try{await N(s.id,{is_admin:!s.is_admin}),await r()}catch(a){n(String(a))}},[r]),D=t.useCallback(async s=>{if(o.length<4){n("Password must be at least 4 characters");return}n(null);try{await N(s,{password:o}),u(null),m("")}catch(a){n(String(a))}},[o]),P=t.useCallback(async s=>{if(confirm(`Delete user "${s.username}"? This cannot be undone.`)){n(null);try{await W(s.id),await r()}catch(a){n(String(a))}}},[r]);return e.jsx(F,{id:c,title:"🛡️ Admin · Users",zIndex:x,width:620,height:540,children:e.jsxs("div",{className:"ml-admin",children:[b&&e.jsx("div",{className:"ml-admin-error",children:b}),e.jsxs("section",{className:"ml-admin-section",children:[e.jsx("h3",{children:"Add user"}),e.jsxs("form",{onSubmit:U,className:"ml-admin-create",children:[e.jsx("input",{type:"text",placeholder:"Username",value:l,onChange:s=>C(s.target.value),disabled:d,autoComplete:"off"}),e.jsx("input",{type:"password",placeholder:"Password (min 4)",value:i,onChange:s=>f(s.target.value),disabled:d,autoComplete:"new-password"}),e.jsxs("label",{children:[e.jsx("input",{type:"checkbox",checked:h,onChange:s=>y(s.target.checked),disabled:d}),"admin"]}),e.jsx("button",{type:"submit",disabled:d||!l.trim()||i.length<4,children:d?"Adding…":"Add"})]})]}),e.jsxs("section",{className:"ml-admin-section",children:[e.jsxs("h3",{children:["Users ",w&&e.jsx("span",{className:"ml-admin-muted",children:"(loading…)"})]}),e.jsxs("table",{className:"ml-admin-table",children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"ID"}),e.jsx("th",{children:"Username"}),e.jsx("th",{children:"Admin"}),e.jsx("th",{children:"Created"}),e.jsx("th",{children:"Actions"})]})}),e.jsxs("tbody",{children:[g.map(s=>{const a=j!=null&&j.username.toLowerCase()===s.username.toLowerCase();return e.jsxs("tr",{children:[e.jsx("td",{children:s.id}),e.jsxs("td",{children:[s.username,a&&e.jsx("span",{className:"ml-admin-muted",children:" (you)"})]}),e.jsx("td",{children:e.jsx("button",{className:"ml-admin-toggle",onClick:()=>v(s),title:"Click to toggle admin",children:s.is_admin?"✓":"–"})}),e.jsx("td",{children:R(s.created_at)}),e.jsx("td",{children:A===s.id?e.jsxs("span",{className:"ml-admin-pw-edit",children:[e.jsx("input",{type:"text",placeholder:"New password",value:o,onChange:E=>m(E.target.value),autoFocus:!0}),e.jsx("button",{onClick:()=>D(s.id),children:"Save"}),e.jsx("button",{onClick:()=>{u(null),m("")},children:"Cancel"})]}):e.jsxs(e.Fragment,{children:[e.jsx("button",{onClick:()=>{u(s.id),m("")},children:"Reset PW"}),!a&&e.jsx("button",{className:"ml-admin-danger",onClick:()=>P(s),children:"Delete"})]})})]},s.id)}),g.length===0&&!w&&e.jsx("tr",{children:e.jsx("td",{colSpan:5,className:"ml-admin-muted",children:"No users."})})]})]})]})]})})};export{q as AdminUsersWindow};

View file

@ -1 +1 @@
import{r as n,b as w,c as k,d as E,j as t,D as I}from"./index-D5aV9Wxd.js";import"./react-yfL0ty4i.js";const h="overlord_agent_session_id";function v(){if(typeof crypto<"u"&&typeof crypto.randomUUID=="function")return crypto.randomUUID();const s=l=>Math.floor(Math.random()*l);return`${s(4294967296).toString(16).padStart(8,"0")}-${s(65536).toString(16).padStart(4,"0")}-4${s(4096).toString(16).padStart(3,"0")}-${(8+s(4)).toString(16)}${s(4096).toString(16).padStart(3,"0")}-${s(281474976710656).toString(16).padStart(12,"0")}`}function $(){try{const l=localStorage.getItem(h);if(l)return l}catch{}const s=v();try{localStorage.setItem(h,s)}catch{}return s}const _=({id:s,zIndex:l})=>{const[o,j]=n.useState(()=>$()),[d,i]=n.useState([]),[g,m]=n.useState(""),[r,f]=n.useState(!1),[p,x]=n.useState(!0),S=n.useRef(null);n.useEffect(()=>{let e=!1;return x(!0),w(o).then(a=>{if(e)return;const c=(a.messages??[]).map(y=>({role:y.role,text:y.text}));i(c)}).catch(()=>{e||i([])}).finally(()=>{e||x(!1)}),()=>{e=!0}},[o]),n.useEffect(()=>{const e=S.current;e&&(e.scrollTop=e.scrollHeight)},[d.length,r]);const u=n.useCallback(async()=>{const e=g.trim();if(!(!e||r)){m(""),i(a=>[...a,{role:"user",text:e}]),f(!0);try{const a=await k(e,o);i(c=>[...c,{role:a.is_error?"error":"assistant",text:a.result||"(no response)"}])}catch(a){i(c=>[...c,{role:"error",text:`Request failed: ${String(a)}`}])}finally{f(!1)}}},[g,r,o]),N=n.useCallback(async()=>{if(r)return;let e="";try{e=(await E()).session_id}catch{e=v()}try{localStorage.setItem(h,e)}catch{}j(e),i([]),m("")},[r]),b=n.useCallback(e=>{e.key==="Enter"&&!e.shiftKey&&(e.preventDefault(),u())},[u]);return t.jsx(I,{id:s,title:"🤖 Overlord Assistant",zIndex:l,width:520,height:620,children:t.jsxs("div",{className:"ml-agent",children:[t.jsxs("div",{className:"ml-agent-toolbar",children:[t.jsx("button",{className:"ml-agent-btn",onClick:N,disabled:r,children:"+ New Chat"}),t.jsxs("span",{className:"ml-agent-session",title:o,children:[o.slice(0,8),"…"]})]}),t.jsxs("div",{className:"ml-agent-messages",ref:S,children:[p&&d.length===0&&t.jsx("div",{className:"ml-agent-empty",children:"Loading conversation…"}),!p&&d.length===0&&t.jsx("div",{className:"ml-agent-empty",children:"Ask anything about the live game state — players, kills, inventory, suitbuilder, recent rares, etc."}),d.map((e,a)=>t.jsxs("div",{className:`ml-agent-msg ml-agent-${e.role}`,children:[t.jsx("div",{className:"ml-agent-role",children:e.role==="user"?"You":e.role==="assistant"?"Overlord":"Error"}),t.jsx("div",{className:"ml-agent-text",children:e.text})]},a)),r&&t.jsxs("div",{className:"ml-agent-msg ml-agent-assistant",children:[t.jsx("div",{className:"ml-agent-role",children:"Overlord"}),t.jsx("div",{className:"ml-agent-text ml-agent-thinking",children:"Thinking…"})]})]}),t.jsxs("form",{className:"ml-agent-form",onSubmit:e=>{e.preventDefault(),u()},children:[t.jsx("textarea",{className:"ml-agent-input",value:g,onChange:e=>m(e.target.value),onKeyDown:b,placeholder:r?"Waiting for response…":"Type a message — Enter to send, Shift+Enter for newline",disabled:r,rows:2}),t.jsx("button",{type:"submit",className:"ml-agent-send",disabled:r||!g.trim(),children:"Send"})]})]})})};export{_ as AgentWindow}; import{r as n,b as w,c as k,d as E,j as t,D as I}from"./index-CPIBhBUe.js";import"./react-yfL0ty4i.js";const h="overlord_agent_session_id";function v(){if(typeof crypto<"u"&&typeof crypto.randomUUID=="function")return crypto.randomUUID();const s=l=>Math.floor(Math.random()*l);return`${s(4294967296).toString(16).padStart(8,"0")}-${s(65536).toString(16).padStart(4,"0")}-4${s(4096).toString(16).padStart(3,"0")}-${(8+s(4)).toString(16)}${s(4096).toString(16).padStart(3,"0")}-${s(281474976710656).toString(16).padStart(12,"0")}`}function $(){try{const l=localStorage.getItem(h);if(l)return l}catch{}const s=v();try{localStorage.setItem(h,s)}catch{}return s}const _=({id:s,zIndex:l})=>{const[o,j]=n.useState(()=>$()),[d,i]=n.useState([]),[g,m]=n.useState(""),[r,f]=n.useState(!1),[p,x]=n.useState(!0),S=n.useRef(null);n.useEffect(()=>{let e=!1;return x(!0),w(o).then(a=>{if(e)return;const c=(a.messages??[]).map(y=>({role:y.role,text:y.text}));i(c)}).catch(()=>{e||i([])}).finally(()=>{e||x(!1)}),()=>{e=!0}},[o]),n.useEffect(()=>{const e=S.current;e&&(e.scrollTop=e.scrollHeight)},[d.length,r]);const u=n.useCallback(async()=>{const e=g.trim();if(!(!e||r)){m(""),i(a=>[...a,{role:"user",text:e}]),f(!0);try{const a=await k(e,o);i(c=>[...c,{role:a.is_error?"error":"assistant",text:a.result||"(no response)"}])}catch(a){i(c=>[...c,{role:"error",text:`Request failed: ${String(a)}`}])}finally{f(!1)}}},[g,r,o]),N=n.useCallback(async()=>{if(r)return;let e="";try{e=(await E()).session_id}catch{e=v()}try{localStorage.setItem(h,e)}catch{}j(e),i([]),m("")},[r]),b=n.useCallback(e=>{e.key==="Enter"&&!e.shiftKey&&(e.preventDefault(),u())},[u]);return t.jsx(I,{id:s,title:"🤖 Overlord Assistant",zIndex:l,width:520,height:620,children:t.jsxs("div",{className:"ml-agent",children:[t.jsxs("div",{className:"ml-agent-toolbar",children:[t.jsx("button",{className:"ml-agent-btn",onClick:N,disabled:r,children:"+ New Chat"}),t.jsxs("span",{className:"ml-agent-session",title:o,children:[o.slice(0,8),"…"]})]}),t.jsxs("div",{className:"ml-agent-messages",ref:S,children:[p&&d.length===0&&t.jsx("div",{className:"ml-agent-empty",children:"Loading conversation…"}),!p&&d.length===0&&t.jsx("div",{className:"ml-agent-empty",children:"Ask anything about the live game state — players, kills, inventory, suitbuilder, recent rares, etc."}),d.map((e,a)=>t.jsxs("div",{className:`ml-agent-msg ml-agent-${e.role}`,children:[t.jsx("div",{className:"ml-agent-role",children:e.role==="user"?"You":e.role==="assistant"?"Overlord":"Error"}),t.jsx("div",{className:"ml-agent-text",children:e.text})]},a)),r&&t.jsxs("div",{className:"ml-agent-msg ml-agent-assistant",children:[t.jsx("div",{className:"ml-agent-role",children:"Overlord"}),t.jsx("div",{className:"ml-agent-text ml-agent-thinking",children:"Thinking…"})]})]}),t.jsxs("form",{className:"ml-agent-form",onSubmit:e=>{e.preventDefault(),u()},children:[t.jsx("textarea",{className:"ml-agent-input",value:g,onChange:e=>m(e.target.value),onKeyDown:b,placeholder:r?"Waiting for response…":"Type a message — Enter to send, Shift+Enter for newline",disabled:r,rows:2}),t.jsx("button",{type:"submit",className:"ml-agent-send",disabled:r||!g.trim(),children:"Send"})]})]})})};export{_ as AgentWindow};

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
import{u as c,j as r,D as d}from"./index-D5aV9Wxd.js";import"./react-yfL0ty4i.js";const p=({id:n,zIndex:i,characters:a})=>{const{openWindow:s}=c(),e=Array.from(a.keys()).sort();return r.jsx(d,{id:n,title:"Combat Stats — Select Character",zIndex:i,width:300,height:400,children:r.jsx("div",{style:{flex:1,overflowY:"auto",padding:6},children:e.length===0?r.jsx("div",{style:{padding:12,color:"#666",textAlign:"center",fontSize:"0.8rem"},children:"No characters online"}):e.map(o=>r.jsx("div",{style:{padding:"5px 8px",cursor:"pointer",borderBottom:"1px solid #222",color:"#ccc",fontSize:"0.82rem"},onMouseEnter:t=>t.currentTarget.style.background="#2a2a2a",onMouseLeave:t=>t.currentTarget.style.background="",onClick:()=>s(`combat-${o}`,`Combat: ${o}`,o),children:o},o))})})};export{p as CombatPickerWindow}; import{u as c,j as r,D as d}from"./index-CPIBhBUe.js";import"./react-yfL0ty4i.js";const p=({id:n,zIndex:i,characters:a})=>{const{openWindow:s}=c(),e=Array.from(a.keys()).sort();return r.jsx(d,{id:n,title:"Combat Stats — Select Character",zIndex:i,width:300,height:400,children:r.jsx("div",{style:{flex:1,overflowY:"auto",padding:6},children:e.length===0?r.jsx("div",{style:{padding:12,color:"#666",textAlign:"center",fontSize:"0.8rem"},children:"No characters online"}):e.map(o=>r.jsx("div",{style:{padding:"5px 8px",cursor:"pointer",borderBottom:"1px solid #222",color:"#ccc",fontSize:"0.82rem"},onMouseEnter:t=>t.currentTarget.style.background="#2a2a2a",onMouseLeave:t=>t.currentTarget.style.background="",onClick:()=>s(`combat-${o}`,`Combat: ${o}`,o),children:o},o))})})};export{p as CombatPickerWindow};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
import{r as c,j as t,D as u,a as f}from"./index-D5aV9Wxd.js";import"./react-yfL0ty4i.js";const j=({id:p,zIndex:x})=>{const[s,h]=c.useState(null);c.useEffect(()=>{const e=async()=>{try{h(await f("/quest-status"))}catch{}};e();const o=setInterval(e,3e4);return()=>clearInterval(o)},[]);const i=s?Object.keys(s.quest_data).sort():[],l=new Set;if(s)for(const e of Object.values(s.quest_data))for(const o of Object.keys(e))l.add(o);const n=Array.from(l).sort();return t.jsx(u,{id:p,title:"Quest Status",zIndex:x,width:780,height:500,children:t.jsx("div",{style:{flex:1,overflow:"auto",fontSize:"0.72rem"},children:s?i.length===0?t.jsx("div",{style:{padding:20,color:"#666",textAlign:"center"},children:"No quest data available"}):t.jsxs("table",{style:{width:"100%",borderCollapse:"collapse"},children:[t.jsx("thead",{children:t.jsxs("tr",{style:{position:"sticky",top:0,background:"#1a1a1a",zIndex:1},children:[t.jsx("th",{style:{textAlign:"left",padding:"4px 8px",borderBottom:"1px solid #444",color:"#888",fontSize:"0.65rem",fontWeight:600,minWidth:140},children:"Character"}),n.map(e=>t.jsx("th",{style:{textAlign:"center",padding:"4px 6px",borderBottom:"1px solid #444",color:"#888",fontSize:"0.6rem",fontWeight:600,maxWidth:120,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},title:e,children:e.replace(" Timer","").replace(" Pickup","")},e))]})}),t.jsx("tbody",{children:i.map(e=>{const o=s.quest_data[e]||{};return t.jsxs("tr",{style:{borderBottom:"1px solid #222"},children:[t.jsx("td",{style:{padding:"3px 8px",color:"#ccc",fontWeight:500,whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis",maxWidth:160},children:e}),n.map(d=>{const r=o[d],a=r==="READY";return t.jsx("td",{style:{textAlign:"center",padding:"3px 6px",color:a?"#4c4":r?"#ca0":"#333",fontWeight:a?600:400,fontSize:a?"0.7rem":"0.68rem"},children:r||"—"},d)})]},e)})})]}):t.jsx("div",{style:{padding:20,color:"#666",textAlign:"center"},children:"Loading quest data..."})})})};export{j as QuestStatusWindow}; import{r as c,j as t,D as u,a as f}from"./index-CPIBhBUe.js";import"./react-yfL0ty4i.js";const j=({id:p,zIndex:x})=>{const[s,h]=c.useState(null);c.useEffect(()=>{const e=async()=>{try{h(await f("/quest-status"))}catch{}};e();const o=setInterval(e,3e4);return()=>clearInterval(o)},[]);const i=s?Object.keys(s.quest_data).sort():[],l=new Set;if(s)for(const e of Object.values(s.quest_data))for(const o of Object.keys(e))l.add(o);const n=Array.from(l).sort();return t.jsx(u,{id:p,title:"Quest Status",zIndex:x,width:780,height:500,children:t.jsx("div",{style:{flex:1,overflow:"auto",fontSize:"0.72rem"},children:s?i.length===0?t.jsx("div",{style:{padding:20,color:"#666",textAlign:"center"},children:"No quest data available"}):t.jsxs("table",{style:{width:"100%",borderCollapse:"collapse"},children:[t.jsx("thead",{children:t.jsxs("tr",{style:{position:"sticky",top:0,background:"#1a1a1a",zIndex:1},children:[t.jsx("th",{style:{textAlign:"left",padding:"4px 8px",borderBottom:"1px solid #444",color:"#888",fontSize:"0.65rem",fontWeight:600,minWidth:140},children:"Character"}),n.map(e=>t.jsx("th",{style:{textAlign:"center",padding:"4px 6px",borderBottom:"1px solid #444",color:"#888",fontSize:"0.6rem",fontWeight:600,maxWidth:120,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},title:e,children:e.replace(" Timer","").replace(" Pickup","")},e))]})}),t.jsx("tbody",{children:i.map(e=>{const o=s.quest_data[e]||{};return t.jsxs("tr",{style:{borderBottom:"1px solid #222"},children:[t.jsx("td",{style:{padding:"3px 8px",color:"#ccc",fontWeight:500,whiteSpace:"nowrap",overflow:"hidden",textOverflow:"ellipsis",maxWidth:160},children:e}),n.map(d=>{const r=o[d],a=r==="READY";return t.jsx("td",{style:{textAlign:"center",padding:"3px 6px",color:a?"#4c4":r?"#ca0":"#333",fontWeight:a?600:400,fontSize:a?"0.7rem":"0.68rem"},children:r||"—"},d)})]},e)})})]}):t.jsx("div",{style:{padding:20,color:"#666",textAlign:"center"},children:"Loading quest data..."})})})};export{j as QuestStatusWindow};

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
import{r as o,j as t,D as d}from"./index-D5aV9Wxd.js";import"./react-yfL0ty4i.js";const c=[{title:"Kills per Hour",id:1},{title:"Memory (MB)",id:2},{title:"CPU (%)",id:3},{title:"Mem Handles",id:4}],m=[{label:"1H",value:"now-1h"},{label:"6H",value:"now-6h"},{label:"24H",value:"now-24h"},{label:"7D",value:"now-7d"}],v=({id:s,charName:a,zIndex:i})=>{const[l,r]=o.useState("now-24h"),n=e=>`/grafana/d-solo/dereth-tracker/dereth-tracker-dashboard?panelId=${e}&var-character=${encodeURIComponent(a)}&from=${l}&to=now&theme=light`;return t.jsxs(d,{id:s,title:`Stats: ${a}`,zIndex:i,width:750,height:480,children:[t.jsx("div",{className:"ml-stats-controls",children:m.map(e=>t.jsx("button",{className:`ml-stats-range-btn ${l===e.value?"active":""}`,onClick:()=>r(e.value),children:e.label},e.value))}),t.jsx("div",{className:"ml-stats-grid",children:c.map(e=>t.jsx("div",{className:"ml-stats-panel",children:t.jsx("iframe",{src:n(e.id),width:"100%",height:"100%",frameBorder:"0",title:e.title})},e.id))})]})};export{v as StatsWindow}; import{r as o,j as t,D as d}from"./index-CPIBhBUe.js";import"./react-yfL0ty4i.js";const c=[{title:"Kills per Hour",id:1},{title:"Memory (MB)",id:2},{title:"CPU (%)",id:3},{title:"Mem Handles",id:4}],m=[{label:"1H",value:"now-1h"},{label:"6H",value:"now-6h"},{label:"24H",value:"now-24h"},{label:"7D",value:"now-7d"}],v=({id:s,charName:a,zIndex:i})=>{const[l,r]=o.useState("now-24h"),n=e=>`/grafana/d-solo/dereth-tracker/dereth-tracker-dashboard?panelId=${e}&var-character=${encodeURIComponent(a)}&from=${l}&to=now&theme=light`;return t.jsxs(d,{id:s,title:`Stats: ${a}`,zIndex:i,width:750,height:480,children:[t.jsx("div",{className:"ml-stats-controls",children:m.map(e=>t.jsx("button",{className:`ml-stats-range-btn ${l===e.value?"active":""}`,onClick:()=>r(e.value),children:e.label},e.value))}),t.jsx("div",{className:"ml-stats-grid",children:c.map(e=>t.jsx("div",{className:"ml-stats-panel",children:t.jsx("iframe",{src:n(e.id),width:"100%",height:"100%",frameBorder:"0",title:e.title})},e.id))})]})};export{v as StatsWindow};

View file

@ -1 +1 @@
import{r as n,j as t,D as x,a as m}from"./index-D5aV9Wxd.js";import"./react-yfL0ty4i.js";const v=({id:o,zIndex:c})=>{const[a,d]=n.useState([]);n.useEffect(()=>{const e=async()=>{try{const s=await m("/vital-sharing/peers");d(s.peers??[])}catch{}};e();const l=setInterval(e,5e3);return()=>clearInterval(l)},[]);const h=(e,l)=>l>0?Math.min(100,e/l*100):0;return t.jsx(x,{id:o,title:"Vital Sharing Network",zIndex:c,width:520,height:450,children:t.jsx("div",{style:{flex:1,overflowY:"auto",padding:6,fontSize:"0.75rem"},children:a.length===0?t.jsx("div",{style:{padding:16,color:"#666",textAlign:"center"},children:"No vital-sharing peers connected"}):a.map(e=>{var l,s,r;return t.jsxs("div",{style:{padding:"6px 8px",marginBottom:4,background:"#1f1f1f",borderRadius:3,border:"1px solid #333"},children:[t.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6,marginBottom:3},children:[t.jsx("span",{style:{color:e.plugin_connected?"#4c4":"#a33",fontSize:"0.8rem"},children:"●"}),t.jsx("strong",{style:{flex:1},children:e.character_name}),e.subscribed&&t.jsx("span",{style:{color:"#6bf",fontSize:"0.65rem"},children:"[subscribed]"})]}),t.jsxs("div",{style:{color:"#666",fontSize:"0.68rem",marginBottom:3},children:["tags: ",((l=e.tags)==null?void 0:l.join(", "))||"none"]}),e.vitals&&e.vitals.max_health>0&&t.jsx("div",{style:{display:"flex",flexDirection:"column",gap:2},children:[{label:"HP",cur:e.vitals.current_health,max:e.vitals.max_health,bg:"#330000",fill:"#c44"},{label:"STA",cur:e.vitals.current_stamina,max:e.vitals.max_stamina,bg:"#331a00",fill:"#ca0"},{label:"MANA",cur:e.vitals.current_mana,max:e.vitals.max_mana,bg:"#001433",fill:"#48f"}].map(i=>t.jsxs("div",{style:{display:"flex",alignItems:"center",gap:4},children:[t.jsx("span",{style:{width:32,color:"#888",fontSize:"0.65rem"},children:i.label}),t.jsx("div",{style:{flex:1,height:6,background:i.bg,borderRadius:3,overflow:"hidden"},children:t.jsx("div",{style:{width:`${h(i.cur,i.max)}%`,height:"100%",background:i.fill,borderRadius:3}})}),t.jsxs("span",{style:{width:60,textAlign:"right",fontSize:"0.65rem",color:"#888"},children:[i.cur,"/",i.max]})]},i.label))}),e.position&&t.jsxs("div",{style:{color:"#555",fontSize:"0.65rem",marginTop:2},children:[(s=e.position.ns)==null?void 0:s.toFixed(1),"N, ",(r=e.position.ew)==null?void 0:r.toFixed(1),"E"]})]},e.character_name)})})})};export{v as VitalSharingWindow}; import{r as n,j as t,D as x,a as m}from"./index-CPIBhBUe.js";import"./react-yfL0ty4i.js";const v=({id:o,zIndex:c})=>{const[a,d]=n.useState([]);n.useEffect(()=>{const e=async()=>{try{const s=await m("/vital-sharing/peers");d(s.peers??[])}catch{}};e();const l=setInterval(e,5e3);return()=>clearInterval(l)},[]);const h=(e,l)=>l>0?Math.min(100,e/l*100):0;return t.jsx(x,{id:o,title:"Vital Sharing Network",zIndex:c,width:520,height:450,children:t.jsx("div",{style:{flex:1,overflowY:"auto",padding:6,fontSize:"0.75rem"},children:a.length===0?t.jsx("div",{style:{padding:16,color:"#666",textAlign:"center"},children:"No vital-sharing peers connected"}):a.map(e=>{var l,s,r;return t.jsxs("div",{style:{padding:"6px 8px",marginBottom:4,background:"#1f1f1f",borderRadius:3,border:"1px solid #333"},children:[t.jsxs("div",{style:{display:"flex",alignItems:"center",gap:6,marginBottom:3},children:[t.jsx("span",{style:{color:e.plugin_connected?"#4c4":"#a33",fontSize:"0.8rem"},children:"●"}),t.jsx("strong",{style:{flex:1},children:e.character_name}),e.subscribed&&t.jsx("span",{style:{color:"#6bf",fontSize:"0.65rem"},children:"[subscribed]"})]}),t.jsxs("div",{style:{color:"#666",fontSize:"0.68rem",marginBottom:3},children:["tags: ",((l=e.tags)==null?void 0:l.join(", "))||"none"]}),e.vitals&&e.vitals.max_health>0&&t.jsx("div",{style:{display:"flex",flexDirection:"column",gap:2},children:[{label:"HP",cur:e.vitals.current_health,max:e.vitals.max_health,bg:"#330000",fill:"#c44"},{label:"STA",cur:e.vitals.current_stamina,max:e.vitals.max_stamina,bg:"#331a00",fill:"#ca0"},{label:"MANA",cur:e.vitals.current_mana,max:e.vitals.max_mana,bg:"#001433",fill:"#48f"}].map(i=>t.jsxs("div",{style:{display:"flex",alignItems:"center",gap:4},children:[t.jsx("span",{style:{width:32,color:"#888",fontSize:"0.65rem"},children:i.label}),t.jsx("div",{style:{flex:1,height:6,background:i.bg,borderRadius:3,overflow:"hidden"},children:t.jsx("div",{style:{width:`${h(i.cur,i.max)}%`,height:"100%",background:i.fill,borderRadius:3}})}),t.jsxs("span",{style:{width:60,textAlign:"right",fontSize:"0.65rem",color:"#888"},children:[i.cur,"/",i.max]})]},i.label))}),e.position&&t.jsxs("div",{style:{color:"#555",fontSize:"0.65rem",marginTop:2},children:[(s=e.position.ns)==null?void 0:s.toFixed(1),"N, ",(r=e.position.ew)==null?void 0:r.toFixed(1),"E"]})]},e.character_name)})})})};export{v as VitalSharingWindow};

File diff suppressed because one or more lines are too long

View file

@ -8,7 +8,7 @@
<link rel="preload" as="image" href="/dereth.png" /> <link rel="preload" as="image" href="/dereth.png" />
<link rel="preload" as="image" href="/icons/0600127E.png" /> <link rel="preload" as="image" href="/icons/0600127E.png" />
<link rel="preload" as="fetch" href="/dungeon_tiles.json" crossorigin="anonymous" /> <link rel="preload" as="fetch" href="/dungeon_tiles.json" crossorigin="anonymous" />
<script type="module" crossorigin src="/assets/index-D5aV9Wxd.js"></script> <script type="module" crossorigin src="/assets/index-CPIBhBUe.js"></script>
<link rel="modulepreload" crossorigin href="/assets/react-yfL0ty4i.js"> <link rel="modulepreload" crossorigin href="/assets/react-yfL0ty4i.js">
<link rel="stylesheet" crossorigin href="/assets/index-Dj1_Fy8n.css"> <link rel="stylesheet" crossorigin href="/assets/index-Dj1_Fy8n.css">
</head> </head>