Compare commits
No commits in common. "d5febaabda6a722103752331a9e48fe2e3cac11e" and "71f235c8b18e862398c332459b1575229ac4ae44" have entirely different histories.
d5febaabda
...
71f235c8b1
26 changed files with 17 additions and 1360 deletions
|
|
@ -1,388 +0,0 @@
|
||||||
# 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`.
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
# 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).
|
|
||||||
|
|
@ -21,7 +21,6 @@ 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(', ')} />}
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,6 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,6 @@ 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 },
|
||||||
|
|
@ -90,7 +89,6 @@ 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 },
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -245,49 +245,6 @@ 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()
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,6 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -1,577 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,180 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -136,10 +136,6 @@ 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 {
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,6 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ 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,
|
||||||
|
|
@ -134,7 +133,6 @@ 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.
|
||||||
|
|
@ -221,7 +219,6 @@ 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 != "" {
|
||||||
|
|
@ -244,11 +241,6 @@ 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 {
|
||||||
|
|
|
||||||
|
|
@ -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-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};
|
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};
|
||||||
|
|
@ -1 +1 @@
|
||||||
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};
|
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};
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
||||||
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};
|
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};
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
||||||
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};
|
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};
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
||||||
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};
|
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};
|
||||||
|
|
@ -1 +1 @@
|
||||||
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};
|
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};
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -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-CPIBhBUe.js"></script>
|
<script type="module" crossorigin src="/assets/index-D5aV9Wxd.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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue