Compare commits
No commits in common. "e4960eae5e8dac458e65e40fa803e0f3e607d23a" and "cc403240d6f9df75e1d92617700dcef4187969d1" have entirely different histories.
e4960eae5e
...
cc403240d6
20 changed files with 727 additions and 549 deletions
|
|
@ -1,303 +0,0 @@
|
|||
# Weapon OD from Loot Profile — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Replace the OD calculation with the user's VTank loot-profile ladder so `od_rating` is the 0–10 (casters 0–7) integer they see on ident. Spec: `docs/superpowers/specs/2026-07-15-weapon-od-loot-profile-design.md`.
|
||||
|
||||
**Architecture:** Rewrite `go-services/inventory-go/od.go` around an ordered bucket table (below, extracted from `Loot.utl`). `OD = clamp(stat − baseline, 1, cap)` for the first matching bucket. Backfill; the frontend renders integer OD.
|
||||
|
||||
**Tech Stack:** Go 1.25 (server-only tests in `golang:1.25-bookworm`), React (npm build).
|
||||
|
||||
**Working dir for git:** `C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord`
|
||||
|
||||
**Go test command:**
|
||||
```bash
|
||||
cd "C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord" && \
|
||||
tar czf - go-services | ssh erik@overlord.snakedesert.se \
|
||||
"rm -rf /tmp/tdd-verify && mkdir -p /tmp/tdd-verify && tar xzf - -C /tmp/tdd-verify" && \
|
||||
ssh erik@overlord.snakedesert.se \
|
||||
"docker run --rm -v /tmp/tdd-verify/go-services/inventory-go:/src -w /src golang:1.25-bookworm \
|
||||
sh -c 'go mod tidy >/dev/null 2>&1; go test ./... -run <PATTERN> -v 2>&1'"
|
||||
```
|
||||
|
||||
**Item keys:** object_class from `raw["ObjectClass"]`; skill `IntValues["218103840"]` (melee/missile) / `IntValues["159"]` (casters); mastery `IntValues["353"]`; MaxDamage `IntValues["218103842"]`; missile dmg `IntValues["218103839"]`; elemVsMon `DoubleValues["152"]`; name `raw["Name"]`. Helpers `bag/ivI/dvF/rawI/rawS` exist in process.go.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Rewrite od.go with the loot-profile table (TDD)
|
||||
|
||||
**Files:** rewrite `go-services/inventory-go/od.go`; rewrite `go-services/inventory-go/od_test.go`.
|
||||
|
||||
- [ ] **Step 1: Write tests first** (`od_test.go`, replacing the old content):
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestOD_MeleeTetsubo(t *testing.T) {
|
||||
// 2H Cleaving baseline 45, name "Tetsubo", maxdmg 85 → clamp(85-45,1,10)=10.
|
||||
raw := map[string]any{"ObjectClass": 1.0, "Name": "Tetsubo",
|
||||
"IntValues": map[string]any{"218103840": 41.0, "353": 11.0, "218103842": 85.0}}
|
||||
if od, ok := computeOD(raw); !ok || od != 10 {
|
||||
t.Fatalf("Tetsubo OD=%v ok=%v want 10", od, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOD_MeleeMidTier(t *testing.T) {
|
||||
// Heavy Axe baseline 74, name "War Axe", maxdmg 79 → clamp(79-74,1,10)=5.
|
||||
raw := map[string]any{"ObjectClass": 1.0, "Name": "Bronze War Axe",
|
||||
"IntValues": map[string]any{"218103840": 44.0, "353": 3.0, "218103842": 79.0}}
|
||||
if od, ok := computeOD(raw); !ok || od != 5 {
|
||||
t.Fatalf("War Axe OD=%v ok=%v want 5", od, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOD_MeleeBelowBaselineNull(t *testing.T) {
|
||||
// Heavy Axe baseline 74, maxdmg 74 → stat-baseline=0 (<1) → null.
|
||||
raw := map[string]any{"ObjectClass": 1.0, "Name": "Bronze War Axe",
|
||||
"IntValues": map[string]any{"218103840": 44.0, "353": 3.0, "218103842": 74.0}}
|
||||
if _, ok := computeOD(raw); ok {
|
||||
t.Fatal("maxdmg == baseline must be null")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOD_MeleeNameGateNull(t *testing.T) {
|
||||
// Heavy Axe bucket requires name in {Silifi,Lugian,War Axe,Battle}. A skill-44
|
||||
// mastery-3 item NOT matching any name and matching no other bucket → null.
|
||||
raw := map[string]any{"ObjectClass": 1.0, "Name": "Bronze Frobnicator",
|
||||
"IntValues": map[string]any{"218103840": 44.0, "353": 3.0, "218103842": 84.0}}
|
||||
if _, ok := computeOD(raw); ok {
|
||||
t.Fatal("name not matching the Heavy Axe filter must be null")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOD_Crossbow(t *testing.T) {
|
||||
// Xbow baseline 77, k839=89 → clamp(89-77,1,10)=10.
|
||||
raw := map[string]any{"ObjectClass": 9.0, "Name": "Fire Compound Crossbow",
|
||||
"IntValues": map[string]any{"218103840": 47.0, "353": 9.0, "218103839": 89.0}}
|
||||
if od, ok := computeOD(raw); !ok || od != 10 {
|
||||
t.Fatalf("Xbow OD=%v ok=%v want 10", od, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOD_WarCaster(t *testing.T) {
|
||||
// War caster: elemVsMon 1.40 → clamp(round((1.40-1.18)*100),1,7)=clamp(22,1,7)=7.
|
||||
raw := map[string]any{"ObjectClass": 31.0, "Name": "Diamond Frost Baton",
|
||||
"IntValues": map[string]any{"159": 34.0},
|
||||
"DoubleValues": map[string]any{"152": 1.40}}
|
||||
if od, ok := computeOD(raw); !ok || od != 7 {
|
||||
t.Fatalf("War caster OD=%v ok=%v want 7", od, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOD_VoidCasterTier(t *testing.T) {
|
||||
// Void caster: name must contain "Nether"; elemVsMon 1.22 → round((1.22-1.18)*100)=4.
|
||||
raw := map[string]any{"ObjectClass": 31.0, "Name": "Silver Nether Staff",
|
||||
"IntValues": map[string]any{"159": 43.0},
|
||||
"DoubleValues": map[string]any{"152": 1.22}}
|
||||
if od, ok := computeOD(raw); !ok || od != 4 {
|
||||
t.Fatalf("Void caster OD=%v ok=%v want 4", od, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOD_BowDeferredNull(t *testing.T) {
|
||||
// Bows (mastery 8) are deferred → always null in this pass.
|
||||
raw := map[string]any{"ObjectClass": 9.0, "Name": "Corsair's Arc",
|
||||
"IntValues": map[string]any{"218103840": 47.0, "353": 8.0, "218103839": 70.0}}
|
||||
if _, ok := computeOD(raw); ok {
|
||||
t.Fatal("bows are deferred → null")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run → RED** (`-run TestOD`): fails (old computeOD signature returns float; these expect int). Build/logic failure expected.
|
||||
|
||||
- [ ] **Step 3: Rewrite `od.go`** (delete ALL old content — table, spell dicts, variance math — and replace):
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
// Weapon OD = the user's VirindiTank Loot.utl classification (0–10, casters
|
||||
// 0–7), printed by Mag-Tools on ident. OD = clamp(stat − baseline, 1, cap) for
|
||||
// the first matching bucket, else null. Buckets/baselines transcribed from
|
||||
// Loot.utl (see design doc 2026-07-15-weapon-od-loot-profile).
|
||||
|
||||
type odBucket struct {
|
||||
objectClass int // 1 melee, 9 missile, 31 caster
|
||||
skill int // 218103840 for melee/missile, 159 for casters; 0 = any
|
||||
mastery int // IntValues 353; -1 = not checked
|
||||
nameAlts []string // OR of case-sensitive substrings; empty = no name gate
|
||||
statKey string // IntValues key (melee/missile) or ""
|
||||
dblStatKey string // DoubleValues key (casters) or ""
|
||||
baseline float64
|
||||
cap int
|
||||
}
|
||||
|
||||
// Ordered: name-gated buckets are effectively specific; first match wins.
|
||||
var odBuckets = []odBucket{
|
||||
// --- Melee (object_class 1, stat MaxDamage 218103842, cap 10) ---
|
||||
{1, 44, 3, []string{"Silifi", "Lugian", "War Axe", "Battle"}, "218103842", "", 74, 10}, // (H) Axe
|
||||
{1, 45, 3, []string{"Dolabra", "Ono", "Hand", "War Hammer"}, "218103842", "", 61, 10}, // (L) Axe
|
||||
{1, 46, 3, []string{"Hatchet", "Shou-ono", "Tungi", "Hammer"}, "218103842", "", 61, 10}, // (F) Axe
|
||||
{1, 44, 6, []string{"Stiletto", "Jambiya"}, "218103842", "", 38, 10}, // (H) Dagger MS
|
||||
{1, 44, 6, nil, "218103842", "", 71, 10}, // (H) Dagger
|
||||
{1, 45, 6, nil, "218103842", "", 58, 10}, // (L) Dagger (MS shares names=nil; see note)
|
||||
{1, 46, 6, []string{"Knife", "Lancet"}, "218103842", "", 28, 10}, // (F) Dagger MS
|
||||
{1, 46, 6, nil, "218103842", "", 58, 10}, // (F) Dagger
|
||||
{1, 44, 4, []string{"Mazule", "Mace", "Morning"}, "218103842", "", 69, 10}, // (H) Mace
|
||||
{1, 45, 4, []string{"Club", "Kasrullah"}, "218103842", "", 56, 10}, // (L) Mace
|
||||
{1, 46, 4, []string{"Board", "Tofun", "Dabus"}, "218103842", "", 56, 10}, // (F) Mace
|
||||
{1, 44, 5, []string{"Glaive", "Trident", "Partizan"}, "218103842", "", 72, 10}, // (H) Spear
|
||||
{1, 45, 5, []string{"Spear", "Yari"}, "218103842", "", 60, 10}, // (L) Spear
|
||||
{1, 44, 7, []string{"Nabut", "Stick"}, "218103842", "", 69, 10}, // (H) Staff
|
||||
{1, 45, 7, nil, "218103842", "", 57, 10}, // (L) Staff
|
||||
{1, 46, 7, []string{"Jo", "Bastone"}, "218103842", "", 57, 10}, // (F) Staff
|
||||
{1, 44, 2, []string{"Takuba", "Flamberge", "Ken", "Long", "Tachi"}, "218103842", "", 71, 10}, // (H) Sword
|
||||
{1, 45, 2, []string{"Dericost", "Broad", "Shamshir", "Kaskara", "Spada"}, "218103842", "", 58, 10}, // (L) Sword
|
||||
{1, 46, 2, []string{"Scimitar", "Yaoji", "Short", "Sabra", "Simi"}, "218103842", "", 58, 10}, // (F) Sword
|
||||
{1, 44, 1, []string{"Nekode", "Cestus"}, "218103842", "", 59, 10}, // (H) UA
|
||||
{1, 45, 1, []string{"Katar", "Knuckles"}, "218103842", "", 48, 10}, // (L) UA
|
||||
{1, 46, 1, []string{"Claw", "Wraps"}, "218103842", "", 48, 10}, // (F) UA
|
||||
{1, 41, -1, []string{"Nodachi", "Shashqa", "Spadone", "Greataxe", "Quadrelle", "Khanda-handled", "Tetsubo", "Star"}, "218103842", "", 45, 10}, // (2H) Cleaving
|
||||
{1, 41, -1, []string{"Assagai", "Pike", "Magari", "Corsesca"}, "218103842", "", 48, 10}, // (2H) Spear
|
||||
// --- Crossbow (object_class 9, mastery 9, stat 218103839, cap 10) ---
|
||||
{9, 47, 9, nil, "218103839", "", 77, 10},
|
||||
// --- Casters (object_class 31, stat elemVsMon 152, cap 7) ---
|
||||
{31, 34, -1, []string{"Baton", "Sceptre", "Staff"}, "", "152", 1.18, 7}, // War
|
||||
{31, 43, -1, []string{"Nether"}, "", "152", 1.18, 7}, // Void
|
||||
// Bows (mastery 8) and Thrown (mastery 10) are intentionally ABSENT → null.
|
||||
}
|
||||
|
||||
func nameMatches(name string, alts []string) bool {
|
||||
if len(alts) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, a := range alts {
|
||||
if strings.Contains(name, a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// computeOD returns the loot-profile OD (integer, as float64 for the DB column)
|
||||
// and ok=false when no bucket matches or the stat is at/below baseline.
|
||||
func computeOD(raw map[string]any) (float64, bool) {
|
||||
oc := rawI(raw, "ObjectClass", 0)
|
||||
iv := bag(raw, "IntValues")
|
||||
dv := bag(raw, "DoubleValues")
|
||||
name := rawS(raw, "Name")
|
||||
skillMelee := ivI(iv, "218103840", 0)
|
||||
skillCaster := ivI(iv, "159", 0)
|
||||
mastery := ivI(iv, "353", -999)
|
||||
|
||||
for _, b := range odBuckets {
|
||||
if b.objectClass != oc {
|
||||
continue
|
||||
}
|
||||
sk := skillMelee
|
||||
if b.objectClass == 31 {
|
||||
sk = skillCaster
|
||||
}
|
||||
if b.skill != 0 && b.skill != sk {
|
||||
continue
|
||||
}
|
||||
if b.mastery != -1 && b.mastery != mastery {
|
||||
continue
|
||||
}
|
||||
if !nameMatches(name, b.nameAlts) {
|
||||
continue
|
||||
}
|
||||
var stat float64
|
||||
if b.dblStatKey != "" {
|
||||
stat = dvF(dv, b.dblStatKey, 0)
|
||||
} else {
|
||||
stat = float64(ivI(iv, b.statKey, 0))
|
||||
}
|
||||
od := stat - b.baseline
|
||||
if b.dblStatKey != "" {
|
||||
od = od * 100 // caster elemVsMon: percentage points
|
||||
}
|
||||
odInt := int(od + 0.5) // round
|
||||
if odInt < 1 {
|
||||
return 0, false
|
||||
}
|
||||
if odInt > b.cap {
|
||||
odInt = b.cap
|
||||
}
|
||||
return float64(odInt), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
```
|
||||
|
||||
Note on the (L) Dagger MS bucket: the profile has an `(L) Dagger MS` (baseline 28) with no name filter, which would collide with `(L) Dagger` (baseline 58, also no name). Two nameless same-skill/mastery buckets can't be distinguished by our fields; the plan keeps only `(L) Dagger` (baseline 58) — the more conservative (higher-baseline, fewer false OD10s). Document this as a known minor gap in the commit message.
|
||||
|
||||
- [ ] **Step 4: Run → GREEN** (`-run TestOD`) all pass; then `-run .` whole package + `go vet ./...` clean.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```
|
||||
feat(inventory-go): recompute OD from VTank loot-profile ladder (0-10 / casters 0-7)
|
||||
|
||||
Replaces the UtilityBelt over-retail float with the user's actual in-game OD:
|
||||
per-weapon-type MaxDamage/elemVsMon thresholds from Loot.utl. Bows/thrown
|
||||
deferred (null) pending a computed-stat calibration. (L) Dagger MS bucket
|
||||
folded into (L) Dagger (indistinguishable by captured fields).
|
||||
|
||||
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Frontend integer rendering
|
||||
|
||||
**Files:** `frontend/src/components/inventory/ResultsTable.tsx`, `DetailPanel.tsx`.
|
||||
|
||||
- [ ] **Step 1:** In `ResultsTable.tsx` `cell()` `case 'od_rating'`, render integer:
|
||||
```ts
|
||||
case 'od_rating': {
|
||||
const v = item.od_rating;
|
||||
return v == null ? '—' : String(v);
|
||||
}
|
||||
```
|
||||
- [ ] **Step 2:** In `DetailPanel.tsx`, change the OD row to plain integer:
|
||||
```tsx
|
||||
{item.od_rating != null && <Row k="Weapon OD" v={String(item.od_rating)} />}
|
||||
```
|
||||
- [ ] **Step 3:** Build `cd frontend && npm run build`; delete `static/_build`. Commit:
|
||||
```
|
||||
feat(frontend): render weapon OD as integer (0-10 loot-profile scale)
|
||||
|
||||
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Deploy backend + backfill + verify
|
||||
|
||||
- [ ] **Step 1:** Sync + test-gated build + recreate inventory-go (cutover override) — standard flow from CLAUDE.md.
|
||||
- [ ] **Step 2:** Backfill: `ssh erik@overlord.snakedesert.se "curl -s -X POST http://127.0.0.1:8772/admin/backfill-od"`. Expect `{scanned, updated}`.
|
||||
- [ ] **Step 3:** Verify distribution is 0–10 / casters ≤7 and spot-check known items:
|
||||
```bash
|
||||
ssh erik@overlord.snakedesert.se "docker exec inventory-db psql -U inventory_user -d inventory_db -tc \"SELECT min(od_rating),max(od_rating),count(*) FILTER (WHERE od_rating IS NOT NULL) FROM items WHERE object_class IN (1,9,31);\""
|
||||
ssh erik@overlord.snakedesert.se "curl -s 'http://127.0.0.1:8770/inv/search/items?include_all_characters=true&weapon_only=true&min_od=1&sort_by=od&sort_dir=desc&limit=10' | python3 -c \"import json,sys;d=json.load(sys.stdin);[print(i.get('od_rating'),'|',i['name']) for i in d['items']]\""
|
||||
```
|
||||
Expect max = 10, casters max 7, Tetsubo = 10.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Deploy frontend + user verify
|
||||
|
||||
- [ ] `bash deploy-frontend.sh`, commit `static/`+`frontend/`, push, server `git pull` (handle the tar-sync working-tree blocker as before: verify blockers match origin, checkout/rm, re-pull).
|
||||
- [ ] **User check:** ID a few weapons in-game and confirm the site's OD column matches the grey OD text (melee + crossbows + casters). Bows/thrown show `—` (deferred) — confirm that's acceptable and, if wanted, provide one bow OD reading to finish them.
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
- Melee 25 buckets + xbow + 2 casters implemented; bows/thrown null (documented).
|
||||
- (L) Dagger MS folded into (L) Dagger — noted.
|
||||
- Old variance/retail od.go fully deleted.
|
||||
- od_rating stays the same column/param/sort; only values + rendering change.
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
# Weapon OD — recompute from the VTank loot profile (0–10 scale)
|
||||
|
||||
**Date:** 2026-07-15
|
||||
**Status:** Approved (user directed the rebuild)
|
||||
|
||||
## Problem
|
||||
|
||||
The shipped OD (`od.go`) ports UtilityBelt's unbounded "over-retail damage"
|
||||
float (−16 … +43). The user's actual in-game OD is a **0–10** (casters 0–7)
|
||||
integer they see on ident. Investigation (this session) proved:
|
||||
|
||||
- Mag-Tools has **no** OD calculation. Its `ItemInfoPrinter` prints the item
|
||||
info **plus the matching VirindiTank loot-rule name** (`GetLootRuleInfoFromItemInfo`).
|
||||
- The user's `VirindiTank\Loot.utl` defines a ladder of rules named
|
||||
`(H) Axe (OD +10)`, `(OD +9)`, … per weapon bucket. Mag-Tools prints whichever
|
||||
matched — **that** is the OD the user sees.
|
||||
|
||||
The thresholds are the same across all the user's profiles (user-confirmed), so
|
||||
one table drives every weapon and caster.
|
||||
|
||||
## The rule (decoded from Loot.utl)
|
||||
|
||||
Each bucket is `OD = clamp(stat − baseline, 1, cap)`, where the bucket is matched
|
||||
by **weapon skill + mastery + (optional) weapon-name filter**, and `stat` is a
|
||||
raw captured value. Ladders step by 1 per tier.
|
||||
|
||||
- **Melee** (object_class 1): `stat = MaxDamage` (int key `218103842`), cap 10.
|
||||
27 buckets (skill `218103840`, mastery `353`, name filter, baseline) — see the
|
||||
table in the plan. Example: 2H Cleaving baseline 45 → Tetsubo (maxdmg 85) = OD
|
||||
**+10** (matches in-game); Heavy Axe baseline 74.
|
||||
- **Crossbow** (object_class 9, mastery `353`=9): `stat = int key 218103839`
|
||||
("effective missile damage"), baseline **77**, cap 10 (OD+10=87 … OD+1=78,
|
||||
verified on Fire Compound Crossbow k839=89 → +10).
|
||||
- **Casters** (object_class 31): `stat = ElementalDamageVersusMonsters` (double
|
||||
key `152`), baseline **1.18**, ×100, cap **7**. War bucket: skill `159`=34,
|
||||
name `Baton|Sceptre|Staff`; Void bucket: skill 43, name `Nether`. OD+7 at
|
||||
elemVsMon 1.25, OD+1 at 1.19. Frost Baton (1.40) → +7.
|
||||
- **No match / stat below baseline+1 → NULL** (item shows no OD, exactly like
|
||||
in-game when no OD rule matches).
|
||||
|
||||
## Deferred: Bows & Thrown (mastery 8 / 10)
|
||||
|
||||
Their OD rules threshold a **computed decimal** damage stat (bow OD+10 = 78.6,
|
||||
thrown = 85.3) on a key not reliably identifiable from the profile alone (bow
|
||||
`218103839` values 45–70 don't reach 78.6; the rule uses a DoubleValKeyGE the
|
||||
`.utl` encodes indirectly). To avoid showing a *wrong* number (the very problem
|
||||
we're fixing), **bows and thrown get NULL OD** in this pass. Reopened once the
|
||||
user supplies one in-game bow OD reading to calibrate the stat.
|
||||
|
||||
## Design
|
||||
|
||||
- **`od.go` rewritten** around an ordered bucket table (extracted from
|
||||
`Loot.utl`, committed as data): `{objectClass, skill, mastery, nameAlts[],
|
||||
statKey, baseline, cap}`. `computeOD` finds the first bucket whose
|
||||
objectClass+skill+mastery+name match, then `OD = clamp(stat − baseline, 1,
|
||||
cap)`; returns null if none match or stat ≤ baseline. Name match = any
|
||||
alternative is a substring of the item name (VTank substring semantics).
|
||||
- **Type**: `od_rating` stays `DOUBLE PRECISION NULL` but now holds an integer
|
||||
1–10 (or 1–7). Frontend already renders it; drop the `+`/decimals for the new
|
||||
integer (show plain `7`, `10`, `—`).
|
||||
- Search `min_od`/`max_od`/sort unchanged. Backfill via existing
|
||||
`POST /admin/backfill-od`.
|
||||
- The best-values table, spell-effect dicts, and variance math in the old
|
||||
`od.go` are **deleted** (wrong metric).
|
||||
|
||||
## Testing
|
||||
|
||||
Go golden tests from real items: Tetsubo → 10, Frost Baton → 7, Fire Compound
|
||||
Crossbow → 10, a mid melee → its expected tier, a below-baseline weapon → null,
|
||||
a bow → null (deferred). Then live backfill + spot-checks; the column must read
|
||||
0–10/0–7 and the user confirms against ident.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Bows/thrown OD (deferred, null).
|
||||
- The loot-rule name filters are reproduced as substring lists; exotic renamed
|
||||
items may differ from a live VTank match — acceptable.
|
||||
|
|
@ -21,7 +21,7 @@ export function DetailPanel({ item, onClose }: { item: InvItem; onClose: () => v
|
|||
<Row k="Workmanship" v={item.workmanship ?? '—'} />
|
||||
{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.od_rating != null && <Row k="Weapon OD" v={String(item.od_rating)} />}
|
||||
{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.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(', ')} />}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ function cell(item: InvItem, key: string): React.ReactNode {
|
|||
return item.last_updated ? item.last_updated.slice(0, 16).replace('T', ' ') : '—';
|
||||
case 'od_rating': {
|
||||
const v = item.od_rating;
|
||||
return v == null ? '—' : String(v);
|
||||
if (v == null) return '—';
|
||||
return v > 0 ? `+${v.toFixed(2)}` : v.toFixed(2);
|
||||
}
|
||||
default: {
|
||||
const v = (item as any)[key];
|
||||
|
|
|
|||
|
|
@ -259,10 +259,10 @@ func (s *Server) handleBackfillOD(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
type upd struct {
|
||||
id int
|
||||
od *float64 // nil → clear stale OD (weapon no longer matches any rule)
|
||||
od float64
|
||||
}
|
||||
var updates []upd
|
||||
scanned, rated := 0, 0
|
||||
scanned := 0
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var oj map[string]any
|
||||
|
|
@ -273,13 +273,7 @@ func (s *Server) handleBackfillOD(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
scanned++
|
||||
if od, ok := computeOD(oj); ok {
|
||||
v := od
|
||||
updates = append(updates, upd{id, &v})
|
||||
rated++
|
||||
} else {
|
||||
// No matching rule → ensure od_rating is NULL (clears any stale value
|
||||
// from a prior formula so the column never keeps a wrong number).
|
||||
updates = append(updates, upd{id, nil})
|
||||
updates = append(updates, upd{id, od})
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
|
|
@ -291,7 +285,7 @@ func (s *Server) handleBackfillOD(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
updated++
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"scanned": scanned, "rated": rated, "updated": updated})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"scanned": scanned, "updated": updated})
|
||||
}
|
||||
|
||||
func parseNaiveTime(s string) time.Time {
|
||||
|
|
|
|||
|
|
@ -1,116 +1,577 @@
|
|||
package main
|
||||
|
||||
import "strings"
|
||||
import "math"
|
||||
|
||||
// Weapon OD = the user's VirindiTank Loot.utl classification (0–10, casters
|
||||
// 0–7), printed by Mag-Tools on ident. OD = clamp(stat − baseline, 1, cap) for
|
||||
// the first matching bucket, else null. Buckets/baselines transcribed from
|
||||
// Loot.utl (see design doc 2026-07-15-weapon-od-loot-profile).
|
||||
// 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.
|
||||
|
||||
type odBucket struct {
|
||||
objectClass int // 1 melee, 9 missile, 31 caster
|
||||
skill int // 218103840 for melee/missile, 159 for casters; 0 = any
|
||||
mastery int // IntValues 353; -1 = not checked
|
||||
nameAlts []string // OR of case-sensitive substrings; empty = no name gate
|
||||
statKey string // IntValues key (melee/missile) or ""
|
||||
dblStatKey string // DoubleValues key (casters) or ""
|
||||
baseline float64
|
||||
cap int
|
||||
// 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
|
||||
}
|
||||
|
||||
// Ordered: name-gated buckets are effectively specific; first match wins.
|
||||
var odBuckets = []odBucket{
|
||||
// --- Melee (object_class 1, stat MaxDamage 218103842, cap 10) ---
|
||||
{1, 44, 3, []string{"Silifi", "Lugian", "War Axe", "Battle"}, "218103842", "", 74, 10}, // (H) Axe
|
||||
{1, 45, 3, []string{"Dolabra", "Ono", "Hand", "War Hammer"}, "218103842", "", 61, 10}, // (L) Axe
|
||||
{1, 46, 3, []string{"Hatchet", "Shou-ono", "Tungi", "Hammer"}, "218103842", "", 61, 10}, // (F) Axe
|
||||
{1, 44, 6, []string{"Stiletto", "Jambiya"}, "218103842", "", 38, 10}, // (H) Dagger MS
|
||||
{1, 44, 6, nil, "218103842", "", 71, 10}, // (H) Dagger
|
||||
{1, 45, 6, nil, "218103842", "", 58, 10}, // (L) Dagger (MS shares names=nil; see note)
|
||||
{1, 46, 6, []string{"Knife", "Lancet"}, "218103842", "", 28, 10}, // (F) Dagger MS
|
||||
{1, 46, 6, nil, "218103842", "", 58, 10}, // (F) Dagger
|
||||
{1, 44, 4, []string{"Mazule", "Mace", "Morning"}, "218103842", "", 69, 10}, // (H) Mace
|
||||
{1, 45, 4, []string{"Club", "Kasrullah"}, "218103842", "", 56, 10}, // (L) Mace
|
||||
{1, 46, 4, []string{"Board", "Tofun", "Dabus"}, "218103842", "", 56, 10}, // (F) Mace
|
||||
{1, 44, 5, []string{"Glaive", "Trident", "Partizan"}, "218103842", "", 72, 10}, // (H) Spear
|
||||
{1, 45, 5, []string{"Spear", "Yari"}, "218103842", "", 60, 10}, // (L) Spear
|
||||
{1, 44, 7, []string{"Nabut", "Stick"}, "218103842", "", 69, 10}, // (H) Staff
|
||||
{1, 45, 7, nil, "218103842", "", 57, 10}, // (L) Staff
|
||||
{1, 46, 7, []string{"Jo", "Bastone"}, "218103842", "", 57, 10}, // (F) Staff
|
||||
{1, 44, 2, []string{"Takuba", "Flamberge", "Ken", "Long", "Tachi"}, "218103842", "", 71, 10}, // (H) Sword
|
||||
{1, 45, 2, []string{"Dericost", "Broad", "Shamshir", "Kaskara", "Spada"}, "218103842", "", 58, 10}, // (L) Sword
|
||||
{1, 46, 2, []string{"Scimitar", "Yaoji", "Short", "Sabra", "Simi"}, "218103842", "", 58, 10}, // (F) Sword
|
||||
{1, 44, 1, []string{"Nekode", "Cestus"}, "218103842", "", 59, 10}, // (H) UA
|
||||
{1, 45, 1, []string{"Katar", "Knuckles"}, "218103842", "", 48, 10}, // (L) UA
|
||||
{1, 46, 1, []string{"Claw", "Wraps"}, "218103842", "", 48, 10}, // (F) UA
|
||||
{1, 41, -1, []string{"Nodachi", "Shashqa", "Spadone", "Greataxe", "Quadrelle", "Khanda-handled", "Tetsubo", "Star"}, "218103842", "", 45, 10}, // (2H) Cleaving
|
||||
{1, 41, -1, []string{"Assagai", "Pike", "Magari", "Corsesca"}, "218103842", "", 48, 10}, // (2H) Spear
|
||||
// --- Crossbow (object_class 9, mastery 9, stat 218103839, cap 10) ---
|
||||
{9, 47, 9, nil, "218103839", "", 77, 10},
|
||||
// --- Casters (object_class 31, stat elemVsMon 152, cap 7) ---
|
||||
{31, 34, -1, []string{"Baton", "Sceptre", "Staff"}, "", "152", 1.18, 7}, // War
|
||||
{31, 43, -1, []string{"Nether"}, "", "152", 1.18, 7}, // Void
|
||||
// Bows (mastery 8) and Thrown (mastery 10) are intentionally ABSENT → null.
|
||||
// bestValue mirrors the trailing DataTable columns:
|
||||
// (MaxDmg, MaxVar, MaxDmgMod, MaxElementalDmgBonus, MaxElementalDmgVsMonsters).
|
||||
type bestValue struct {
|
||||
maxDmg float64
|
||||
maxVar float64
|
||||
maxDmgMod float64
|
||||
maxElemBonus float64
|
||||
maxElemVsMon float64
|
||||
}
|
||||
|
||||
func nameMatches(name string, alts []string) bool {
|
||||
if len(alts) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, a := range alts {
|
||||
if strings.Contains(name, a) {
|
||||
return true
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
return false
|
||||
for _, id := range toIntList(raw["Spells"]) {
|
||||
if e, ok := maxDamageSpellEffects[id]; ok {
|
||||
value += int(e.bonus)
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// computeOD returns the loot-profile OD (integer, as float64 for the DB column)
|
||||
// and ok=false when no bucket matches or the stat is at/below baseline.
|
||||
// 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")
|
||||
name := rawS(raw, "Name")
|
||||
skillMelee := ivI(iv, "218103840", 0)
|
||||
skillCaster := ivI(iv, "159", 0)
|
||||
mastery := ivI(iv, "353", -999)
|
||||
|
||||
for _, b := range odBuckets {
|
||||
if b.objectClass != oc {
|
||||
continue
|
||||
}
|
||||
sk := skillMelee
|
||||
if b.objectClass == 31 {
|
||||
sk = skillCaster
|
||||
}
|
||||
if b.skill != 0 && b.skill != sk {
|
||||
continue
|
||||
}
|
||||
if b.mastery != -1 && b.mastery != mastery {
|
||||
continue
|
||||
}
|
||||
if !nameMatches(name, b.nameAlts) {
|
||||
continue
|
||||
}
|
||||
var stat float64
|
||||
if b.dblStatKey != "" {
|
||||
stat = dvF(dv, b.dblStatKey, 0)
|
||||
} else {
|
||||
stat = float64(ivI(iv, b.statKey, 0))
|
||||
}
|
||||
od := stat - b.baseline
|
||||
if b.dblStatKey != "" {
|
||||
od = od * 100 // caster elemVsMon: percentage points
|
||||
}
|
||||
odInt := int(od + 0.5) // round
|
||||
if odInt < 1 {
|
||||
// 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
|
||||
}
|
||||
if odInt > b.cap {
|
||||
odInt = b.cap
|
||||
// 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
|
||||
}
|
||||
return float64(odInt), true
|
||||
|
||||
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,78 +1,180 @@
|
|||
package main
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOD_MeleeTetsubo(t *testing.T) {
|
||||
// 2H Cleaving baseline 45, name "Tetsubo", maxdmg 85 → clamp(85-45,1,10)=10.
|
||||
raw := map[string]any{"ObjectClass": 1.0, "Name": "Tetsubo",
|
||||
"IntValues": map[string]any{"218103840": 41.0, "353": 11.0, "218103842": 85.0}}
|
||||
if od, ok := computeOD(raw); !ok || od != 10 {
|
||||
t.Fatalf("Tetsubo OD=%v ok=%v want 10", od, ok)
|
||||
func 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOD_MeleeMidTier(t *testing.T) {
|
||||
// Heavy Axe baseline 74, name "War Axe", maxdmg 79 → clamp(79-74,1,10)=5.
|
||||
raw := map[string]any{"ObjectClass": 1.0, "Name": "Bronze War Axe",
|
||||
"IntValues": map[string]any{"218103840": 44.0, "353": 3.0, "218103842": 79.0}}
|
||||
if od, ok := computeOD(raw); !ok || od != 5 {
|
||||
t.Fatalf("War Axe OD=%v ok=%v want 5", od, ok)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOD_MeleeBelowBaselineNull(t *testing.T) {
|
||||
// Heavy Axe baseline 74, maxdmg 74 → stat-baseline=0 (<1) → null.
|
||||
raw := map[string]any{"ObjectClass": 1.0, "Name": "Bronze War Axe",
|
||||
"IntValues": map[string]any{"218103840": 44.0, "353": 3.0, "218103842": 74.0}}
|
||||
// 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("maxdmg == baseline must be null")
|
||||
t.Fatal("no-workmanship item must have no OD")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOD_MeleeNameGateNull(t *testing.T) {
|
||||
// Heavy Axe bucket requires name in {Silifi,Lugian,War Axe,Battle}. A skill-44
|
||||
// mastery-3 item NOT matching any name and matching no other bucket → null.
|
||||
raw := map[string]any{"ObjectClass": 1.0, "Name": "Bronze Frobnicator",
|
||||
"IntValues": map[string]any{"218103840": 44.0, "353": 3.0, "218103842": 84.0}}
|
||||
// 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("name not matching the Heavy Axe filter must be null")
|
||||
t.Fatal("unknown wieldreq tier must have no OD")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOD_Crossbow(t *testing.T) {
|
||||
// Xbow baseline 77, k839=89 → clamp(89-77,1,10)=10.
|
||||
raw := map[string]any{"ObjectClass": 9.0, "Name": "Fire Compound Crossbow",
|
||||
"IntValues": map[string]any{"218103840": 47.0, "353": 9.0, "218103839": 89.0}}
|
||||
if od, ok := computeOD(raw); !ok || od != 10 {
|
||||
t.Fatalf("Xbow OD=%v ok=%v want 10", od, ok)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOD_WarCaster(t *testing.T) {
|
||||
// War caster: elemVsMon 1.40 → clamp(round((1.40-1.18)*100),1,7)=clamp(22,1,7)=7.
|
||||
raw := map[string]any{"ObjectClass": 31.0, "Name": "Diamond Frost Baton",
|
||||
"IntValues": map[string]any{"159": 34.0},
|
||||
"DoubleValues": map[string]any{"152": 1.40}}
|
||||
if od, ok := computeOD(raw); !ok || od != 7 {
|
||||
t.Fatalf("War caster OD=%v ok=%v want 7", od, ok)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOD_VoidCasterTier(t *testing.T) {
|
||||
// Void caster: name must contain "Nether"; elemVsMon 1.22 → round((1.22-1.18)*100)=4.
|
||||
raw := map[string]any{"ObjectClass": 31.0, "Name": "Silver Nether Staff",
|
||||
"IntValues": map[string]any{"159": 43.0},
|
||||
"DoubleValues": map[string]any{"152": 1.22}}
|
||||
if od, ok := computeOD(raw); !ok || od != 4 {
|
||||
t.Fatalf("Void caster OD=%v ok=%v want 4", od, ok)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOD_BowDeferredNull(t *testing.T) {
|
||||
// Bows (mastery 8) are deferred → always null in this pass.
|
||||
raw := map[string]any{"ObjectClass": 9.0, "Name": "Corsair's Arc",
|
||||
"IntValues": map[string]any{"218103840": 47.0, "353": 8.0, "218103839": 70.0}}
|
||||
if _, ok := computeOD(raw); ok {
|
||||
t.Fatal("bows are deferred → null")
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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-YQnl0KV7.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-C-OoyBwD.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-YQnl0KV7.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-C-OoyBwD.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-YQnl0KV7.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-C-OoyBwD.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-YQnl0KV7.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-C-OoyBwD.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-YQnl0KV7.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-C-OoyBwD.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-YQnl0KV7.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-C-OoyBwD.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="/icons/0600127E.png" />
|
||||
<link rel="preload" as="fetch" href="/dungeon_tiles.json" crossorigin="anonymous" />
|
||||
<script type="module" crossorigin src="/assets/index-YQnl0KV7.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-C-OoyBwD.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/react-yfL0ty4i.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-IwGZYtQM.css">
|
||||
</head>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue