Compare commits
5 commits
d5febaabda
...
cc403240d6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc403240d6 | ||
|
|
29f8bec046 | ||
|
|
d6fa0fa9b0 | ||
|
|
b4bee24773 | ||
|
|
29d400f86a |
25 changed files with 577 additions and 52 deletions
326
docs/superpowers/plans/2026-07-15-weapon-type-skill-filter.md
Normal file
326
docs/superpowers/plans/2026-07-15-weapon-type-skill-filter.md
Normal file
|
|
@ -0,0 +1,326 @@
|
||||||
|
# Weapon-type (by skill) filter + OD column Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** Multi-select weapon-type filter by skill (Heavy/Light/Finesse/Two-Handed/Missile/War/Void), and auto-show the OD column on weapon searches.
|
||||||
|
|
||||||
|
**Architecture:** Backend reworks `weaponTypeClause` to be object-class/key aware and adds a `weapon_types` CSV param (OR of per-type clauses) in `runSearch`; legacy `weapon_type` kept. Frontend changes `weaponType: string` → `weaponTypes: string[]` (multi-select checkboxes), redefines `WEAPON_TYPES` to seven skill categories, and unions `od_rating` into the visible columns when item type is Weapons. Spec: `docs/superpowers/specs/2026-07-15-weapon-type-skill-filter-design.md`.
|
||||||
|
|
||||||
|
**Tech Stack:** Go 1.25 (backend, no local toolchain — test in `golang:1.25-bookworm` on the server); React 19 + Vite (`npm run build` locally).
|
||||||
|
|
||||||
|
**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'"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verified field keys:** melee `object_class=1` + `int_values->>'218103840'` (44/45/46/41); missile `object_class=9` + `218103840=47`, mastery `353` (8 bow/9 xbow/10 thrown); wand `object_class=31` + equip skill `int_values->>'159'` (34 war/43 void).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Backend — reworked weaponTypeClause + weapon_types param (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `go-services/inventory-go/search.go` (`weaponTypeClause` ~line 545; the `weapon_only` case ~line 193)
|
||||||
|
- Create: `go-services/inventory-go/search_test.go`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests.** Create `search_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWeaponTypeClause(t *testing.T) {
|
||||||
|
cases := map[string][]string{
|
||||||
|
"heavy": {"object_class = 1", "'218103840')::int = 44"},
|
||||||
|
"light": {"object_class = 1", "= 45"},
|
||||||
|
"finesse": {"object_class = 1", "= 46"},
|
||||||
|
"two_handed": {"object_class = 1", "= 41"},
|
||||||
|
"missile": {"object_class = 9", "'218103840')::int = 47"},
|
||||||
|
"bow": {"object_class = 9", "'218103840')::int = 47", "'353')::int = 8"},
|
||||||
|
"crossbow": {"object_class = 9", "'353')::int = 9"},
|
||||||
|
"thrown": {"object_class = 9", "'353')::int = 10"},
|
||||||
|
"war": {"object_class = 31", "'159')::int = 34"},
|
||||||
|
"void": {"object_class = 31", "'159')::int = 43"},
|
||||||
|
"caster": {"object_class = 31"},
|
||||||
|
}
|
||||||
|
for wt, subs := range cases {
|
||||||
|
got := weaponTypeClause(wt)
|
||||||
|
for _, s := range subs {
|
||||||
|
if !strings.Contains(got, s) {
|
||||||
|
t.Errorf("weaponTypeClause(%q) = %q, missing %q", wt, got, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := weaponTypeClause("nonsense"); !strings.Contains(got, "object_class IN (1, 9, 31)") {
|
||||||
|
t.Errorf("unknown type = %q, want all-weapons fallback", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeaponOnlyClause(t *testing.T) {
|
||||||
|
// No types → all weapons.
|
||||||
|
if got := weaponOnlyClause(url.Values{}); !strings.Contains(got, "object_class IN (1, 9, 31)") {
|
||||||
|
t.Errorf("empty = %q, want all weapons", got)
|
||||||
|
}
|
||||||
|
// Single type via weapon_types.
|
||||||
|
got := weaponOnlyClause(url.Values{"weapon_types": {"heavy"}})
|
||||||
|
if !strings.Contains(got, "= 44") || strings.Contains(got, " OR ") {
|
||||||
|
t.Errorf("single = %q, want just heavy, no OR", got)
|
||||||
|
}
|
||||||
|
// Multiple → parenthesized OR, one clause per type.
|
||||||
|
got = weaponOnlyClause(url.Values{"weapon_types": {"heavy,two_handed,war"}})
|
||||||
|
if !strings.HasPrefix(got, "(") || !strings.HasSuffix(got, ")") ||
|
||||||
|
strings.Count(got, " OR ") != 2 ||
|
||||||
|
!strings.Contains(got, "= 44") || !strings.Contains(got, "= 41") || !strings.Contains(got, "= 34") {
|
||||||
|
t.Errorf("multi = %q, want (heavy OR two_handed OR war)", got)
|
||||||
|
}
|
||||||
|
// Legacy weapon_type still honored, deduped against weapon_types.
|
||||||
|
got = weaponOnlyClause(url.Values{"weapon_type": {"missile"}})
|
||||||
|
if !strings.Contains(got, "object_class = 9") {
|
||||||
|
t.Errorf("legacy weapon_type = %q, want missile", got)
|
||||||
|
}
|
||||||
|
got = weaponOnlyClause(url.Values{"weapon_types": {"heavy"}, "weapon_type": {"heavy"}})
|
||||||
|
if strings.Contains(got, " OR ") {
|
||||||
|
t.Errorf("dedup = %q, want single heavy clause", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests → RED** (`-run 'TestWeaponTypeClause|TestWeaponOnlyClause'`): `undefined: weaponOnlyClause` build failure.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement.** Replace the whole `weaponTypeClause` func (~545-575) with:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func weaponTypeClause(wt string) string {
|
||||||
|
meleeSkill := func(skill int) string {
|
||||||
|
return fmt.Sprintf("(object_class = 1 AND EXISTS (SELECT 1 FROM item_raw_data wrd WHERE wrd.item_id = db_item_id AND (wrd.int_values->>'218103840')::int = %d))", skill)
|
||||||
|
}
|
||||||
|
missileMastery := func(mastery int) string {
|
||||||
|
return fmt.Sprintf("(object_class = 9 AND EXISTS (SELECT 1 FROM item_raw_data wrd WHERE wrd.item_id = db_item_id AND (wrd.int_values->>'218103840')::int = 47 AND (wrd.int_values->>'353')::int = %d))", mastery)
|
||||||
|
}
|
||||||
|
casterSkill := func(skill int) string {
|
||||||
|
return fmt.Sprintf("(object_class = 31 AND EXISTS (SELECT 1 FROM item_raw_data wrd WHERE wrd.item_id = db_item_id AND (wrd.int_values->>'159')::int = %d))", skill)
|
||||||
|
}
|
||||||
|
switch strings.ToLower(wt) {
|
||||||
|
case "heavy":
|
||||||
|
return meleeSkill(44)
|
||||||
|
case "light":
|
||||||
|
return meleeSkill(45)
|
||||||
|
case "finesse":
|
||||||
|
return meleeSkill(46)
|
||||||
|
case "two_handed":
|
||||||
|
return meleeSkill(41)
|
||||||
|
case "missile":
|
||||||
|
return "(object_class = 9 AND EXISTS (SELECT 1 FROM item_raw_data wrd WHERE wrd.item_id = db_item_id AND (wrd.int_values->>'218103840')::int = 47))"
|
||||||
|
case "bow":
|
||||||
|
return missileMastery(8)
|
||||||
|
case "crossbow":
|
||||||
|
return missileMastery(9)
|
||||||
|
case "thrown":
|
||||||
|
return missileMastery(10)
|
||||||
|
case "war":
|
||||||
|
return casterSkill(34)
|
||||||
|
case "void":
|
||||||
|
return casterSkill(43)
|
||||||
|
case "caster":
|
||||||
|
return "object_class = 31"
|
||||||
|
default:
|
||||||
|
return "object_class IN (1, 9, 31)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// weaponOnlyClause builds the WHERE fragment for weapon_only searches: an OR of
|
||||||
|
// the per-type clauses named in weapon_types (CSV) plus the legacy single
|
||||||
|
// weapon_type. Empty selection = all weapons. Types are deduped.
|
||||||
|
func weaponOnlyClause(q url.Values) string {
|
||||||
|
var types []string
|
||||||
|
types = append(types, splitNonEmpty(q.Get("weapon_types"))...)
|
||||||
|
if wt := strings.TrimSpace(q.Get("weapon_type")); wt != "" {
|
||||||
|
types = append(types, wt)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var clauses []string
|
||||||
|
for _, t := range types {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(t))
|
||||||
|
if key == "" || seen[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
clauses = append(clauses, weaponTypeClause(key))
|
||||||
|
}
|
||||||
|
if len(clauses) == 0 {
|
||||||
|
return "object_class IN (1, 9, 31)"
|
||||||
|
}
|
||||||
|
if len(clauses) == 1 {
|
||||||
|
return clauses[0]
|
||||||
|
}
|
||||||
|
return "(" + strings.Join(clauses, " OR ") + ")"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then change the `weapon_only` case (~line 193-194) from
|
||||||
|
`conds = append(conds, weaponTypeClause(q.Get("weapon_type")))`
|
||||||
|
to
|
||||||
|
`conds = append(conds, weaponOnlyClause(q))`.
|
||||||
|
|
||||||
|
(`url` and `strings` and `fmt` and `splitNonEmpty` are already imported/defined in search.go.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests → GREEN** (`-run 'TestWeapon'`) then whole package (`-run .`) + `go vet ./...` clean.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
```bash
|
||||||
|
git add go-services/inventory-go/search.go go-services/inventory-go/search_test.go
|
||||||
|
git commit -m "feat(inventory-go): weapon_types multi-select filter by skill (melee/missile/war/void)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Frontend — multi-select weapon type + auto OD column
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/components/inventory/constants.ts`
|
||||||
|
- Modify: `frontend/src/components/inventory/types.ts`
|
||||||
|
- Modify: `frontend/src/components/inventory/useInventorySearch.ts`
|
||||||
|
- Modify: `frontend/src/components/inventory/FilterSidebar.tsx`
|
||||||
|
- Modify: `frontend/src/components/inventory/ActiveChips.tsx`
|
||||||
|
- Modify: `frontend/src/components/inventory/ResultsTable.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: constants.ts** — replace the `WEAPON_TYPES` array with the seven skill categories:
|
||||||
|
```ts
|
||||||
|
export const WEAPON_TYPES: Array<{ value: string; label: string }> = [
|
||||||
|
{ value: 'heavy', label: 'Heavy' },
|
||||||
|
{ value: 'light', label: 'Light' },
|
||||||
|
{ value: 'finesse', label: 'Finesse' },
|
||||||
|
{ value: 'two_handed', label: 'Two-Handed' },
|
||||||
|
{ value: 'missile', label: 'Missile' },
|
||||||
|
{ value: 'war', label: 'War Magic' },
|
||||||
|
{ value: 'void', label: 'Void Magic' },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
(Removed the leading `{ value: '', label: 'All weapons' }` — multi-select uses "none checked = all".)
|
||||||
|
|
||||||
|
- [ ] **Step 2: types.ts** — in `SearchFilters` change `weaponType: string;` to `weaponTypes: string[];`; in `DEFAULT_FILTERS` change `weaponType: '',` to `weaponTypes: [],`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: useInventorySearch.ts**
|
||||||
|
- `validateFilterValue`: add a case
|
||||||
|
```ts
|
||||||
|
case 'weaponTypes':
|
||||||
|
return isStringArray(value) ? value : undefined;
|
||||||
|
```
|
||||||
|
(place near the `slots`/`cantrips` case; `isStringArray` already exists.)
|
||||||
|
- `buildParams`: in the `case 'weapon':` block, replace
|
||||||
|
```ts
|
||||||
|
p.set('weapon_only', 'true');
|
||||||
|
if (f.weaponType) p.set('weapon_type', f.weaponType);
|
||||||
|
```
|
||||||
|
with
|
||||||
|
```ts
|
||||||
|
p.set('weapon_only', 'true');
|
||||||
|
if (f.weaponTypes.length) p.set('weapon_types', f.weaponTypes.join(','));
|
||||||
|
```
|
||||||
|
- Anywhere `weaponType` is referenced elsewhere (e.g. `update({ itemType: v, weaponType: '' })` resets) — grep and change to `weaponTypes: []`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: FilterSidebar.tsx** — replace the weapon-type `<select>` (the `{f.itemType === 'weapon' && (<select>...)}` block, ~line 72-77) with a checkbox group:
|
||||||
|
```tsx
|
||||||
|
{f.itemType === 'weapon' && (
|
||||||
|
<div className="inv-subgroup">
|
||||||
|
{WEAPON_TYPES.map(w => (
|
||||||
|
<label className="inv-fitem" key={w.value}>
|
||||||
|
<input type="checkbox" checked={f.weaponTypes.includes(w.value)}
|
||||||
|
onChange={() => update({
|
||||||
|
weaponTypes: f.weaponTypes.includes(w.value)
|
||||||
|
? f.weaponTypes.filter(x => x !== w.value)
|
||||||
|
: [...f.weaponTypes, w.value],
|
||||||
|
})} /> {w.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
Also update the item-type radio `onChange` that currently does `update({ itemType: v, weaponType: '' })` → `update({ itemType: v, weaponTypes: [] })`. Add `.inv-subgroup{margin:2px 0 2px 10px}` to inventory.css.
|
||||||
|
|
||||||
|
- [ ] **Step 5: ActiveChips.tsx** — replace the single item-type/weaponType chip logic. The current block:
|
||||||
|
```ts
|
||||||
|
if (f.itemType !== 'all') chips.push({
|
||||||
|
label: f.itemType[0].toUpperCase() + f.itemType.slice(1) + (f.weaponType ? `: ${f.weaponType}` : ''),
|
||||||
|
remove: () => update({ itemType: 'all', weaponType: '' }),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
becomes:
|
||||||
|
```ts
|
||||||
|
if (f.itemType !== 'all') chips.push({
|
||||||
|
label: f.itemType[0].toUpperCase() + f.itemType.slice(1),
|
||||||
|
remove: () => update({ itemType: 'all', weaponTypes: [] }),
|
||||||
|
});
|
||||||
|
for (const wt of f.weaponTypes) {
|
||||||
|
const label = WEAPON_TYPES.find(w => w.value === wt)?.label ?? wt;
|
||||||
|
chips.push({ label, remove: () => update({ weaponTypes: f.weaponTypes.filter(x => x !== wt) }) });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Add `import { WEAPON_TYPES } from './constants';` if not present (RATING_DEFS import is already there).
|
||||||
|
|
||||||
|
- [ ] **Step 6: ResultsTable.tsx** — make the OD column auto-visible on weapon searches. Change the `cols` memo:
|
||||||
|
```ts
|
||||||
|
const cols = useMemo(() => {
|
||||||
|
const eff = new Set(visible);
|
||||||
|
if (f.itemType === 'weapon') eff.add('od_rating');
|
||||||
|
return COLUMNS.filter(c => eff.has(c.key));
|
||||||
|
}, [visible, f.itemType]);
|
||||||
|
```
|
||||||
|
(`f` is already destructured from `search`.)
|
||||||
|
|
||||||
|
- [ ] **Step 7: Build** — `cd frontend && npm run build`; delete `static/_build` after. Grep to confirm no stray `weaponType` (singular) references remain: `grep -rn "weaponType\b" frontend/src/components/inventory` should only match `weaponTypes`.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
```bash
|
||||||
|
git add frontend/src/components/inventory/ frontend/src/styles/inventory.css
|
||||||
|
git commit -m "feat(frontend): multi-select weapon-type-by-skill filter + auto OD column
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Deploy + verify
|
||||||
|
|
||||||
|
- [ ] **Step 1: Backend** — sync + test-gated build + recreate:
|
||||||
|
```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 2: Frontend** — `bash deploy-frontend.sh`, then `git add static/ frontend/ && git commit -m "build(frontend): deploy weapon-type filter" && git push origin master`.
|
||||||
|
- [ ] **Step 3: Server pull** — `ssh erik@overlord.snakedesert.se "cd /home/erik/MosswartOverlord && git pull --ff-only origin master"`. If it aborts on the tar-synced working-tree files, verify each blocker matches origin (`git show origin/master:<f> | diff -q - <f>`), `git checkout --`/`rm` them, then re-pull. Confirm the served bundle hash changed.
|
||||||
|
- [ ] **Step 4: Backend live checks** (through the `/inv` proxy the browser uses):
|
||||||
|
```bash
|
||||||
|
ssh erik@overlord.snakedesert.se "for t in heavy two_handed missile war void; do echo -n \"\$t: \"; curl -s \"http://127.0.0.1:8770/inv/search/items?include_all_characters=true&weapon_only=true&weapon_types=\$t&limit=1\" | python3 -c 'import json,sys;d=json.load(sys.stdin);print(d[\"total_count\"])'; done"
|
||||||
|
```
|
||||||
|
Expected: each returns a plausible non-zero count. Then a multi-select + no-Bowl check:
|
||||||
|
```bash
|
||||||
|
ssh erik@overlord.snakedesert.se "curl -s 'http://127.0.0.1:8770/inv/search/items?include_all_characters=true&weapon_only=true&weapon_types=missile&limit=200' | python3 -c \"import json,sys;d=json.load(sys.stdin);names=[i['name'] for i in d['items']];print('missile total',d['total_count'],'| any Bowl?', any('Bowl' in n for n in names))\""
|
||||||
|
```
|
||||||
|
Expected: total > 0, `any Bowl? False`.
|
||||||
|
- [ ] **Step 5: User verification** (login-gated — ask the user): open `/?view=inventory`, select Weapons, check Heavy + Two-Handed, confirm results; confirm the OD column shows automatically; check War Magic / Void Magic return casters; chips add/remove per type.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review
|
||||||
|
- Spec coverage: multi-select by skill (T1 backend, T2 frontend), OD column on weapon searches (T2 step 6), verify (T3). All covered.
|
||||||
|
- Back-compat: legacy `weapon_type` still honored by `weaponOnlyClause`; URL state with old `weaponType` string is dropped by validation (falls back to `[]`), harmless.
|
||||||
|
- Bugfix ride-along: missile subtypes now mastery-based (fixes "Bowl").
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
# Weapon-type (by skill) multi-select filter + OD column for weapon searches
|
||||||
|
|
||||||
|
**Date:** 2026-07-15
|
||||||
|
**Status:** Pre-approved by user
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Weapon searching is weak: the current weapon-type filter is a single-select
|
||||||
|
dropdown (`weapon_type`, shown only under the "Weapons" item type) and the OD
|
||||||
|
column is hidden by default. The user wants to search across weapon types by
|
||||||
|
skill (Heavy, Light, Finesse, Two-Handed, Missile, War/Void casters), pick
|
||||||
|
more than one at once, and see the OD value in the results columns.
|
||||||
|
|
||||||
|
## Field semantics (verified against live data)
|
||||||
|
|
||||||
|
- Melee (`object_class = 1`): WeaponSkill `int_values->>'218103840'` = 44 Heavy,
|
||||||
|
45 Light, 46 Finesse, 41 Two-Handed.
|
||||||
|
- Missile (`object_class = 9`): WeaponSkill `218103840` = 47; subtype from
|
||||||
|
mastery `int_values->>'353'` = 8 Bow, 9 Crossbow, 10 Thrown. Arrows/ammo lack
|
||||||
|
skill 47, so gating on it excludes them — and fixes the existing bug where
|
||||||
|
the name-based `%bow%` clause matched "Bowl".
|
||||||
|
- Wands (`object_class = 31`): WeaponSkill is null; use equip skill
|
||||||
|
`int_values->>'159'` = 34 War Magic, 43 Void Magic (the only caster schools
|
||||||
|
the OD table rates).
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **Weapon-type becomes multi-select** (checkbox group) with seven
|
||||||
|
skill-level categories: Heavy, Light, Finesse, Two-Handed, Missile,
|
||||||
|
War Magic, Void Magic. Any combination; none = all weapons. Shown when the
|
||||||
|
"Weapons" item type is selected (same nesting the single dropdown used).
|
||||||
|
2. **Backend** accepts a new `weapon_types` CSV param → OR of per-type clauses,
|
||||||
|
ANDed into the query. The legacy single `weapon_type` param is kept for
|
||||||
|
back-compat. `weaponTypeClause` is reworked to be object-class/key aware
|
||||||
|
(melee by 218103840, missile launcher by 218103840=47 + mastery, casters by
|
||||||
|
159), replacing the melee-only `exists(skill)` helper. Existing type keys
|
||||||
|
(bow/crossbow/thrown/caster/light/finesse/heavy/two_handed) keep working;
|
||||||
|
bow/crossbow/thrown switch to the robust mastery-based clause.
|
||||||
|
3. **OD column auto-shows for weapon searches**: when `itemType === 'weapon'`,
|
||||||
|
the results table includes the `od_rating` column regardless of the user's
|
||||||
|
saved column set (union, non-destructive — their other prefs and the column
|
||||||
|
picker still work; OD just can't be hidden while weapon-searching). No
|
||||||
|
change to default sort.
|
||||||
|
|
||||||
|
## Frontend model
|
||||||
|
|
||||||
|
- `SearchFilters.weaponType: string` → `weaponTypes: string[]`. Update
|
||||||
|
DEFAULT_FILTERS, `validateFilterValue` (string array), `buildParams`
|
||||||
|
(`weapon_types` CSV when non-empty), FilterSidebar (checkbox group replacing
|
||||||
|
the `<select>`), ActiveChips (one chip per selected type, removable).
|
||||||
|
- `WEAPON_TYPES` in constants becomes the seven skill categories
|
||||||
|
`{value,label}`: heavy/light/finesse/two_handed/missile/war/void.
|
||||||
|
- ResultsTable computes effective visible columns = saved set ∪
|
||||||
|
(`itemType==='weapon'` ? {od_rating} : {}).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Backend: Go unit tests on the reworked `weaponTypeClause` (correct SQL per
|
||||||
|
type: object_class, keys, mastery) and the `weapon_types` CSV → OR
|
||||||
|
composition in `runSearch` (via the existing search-string test approach if
|
||||||
|
present, else targeted clause tests). No DB-backed tests (consistent with the
|
||||||
|
rest of search.go); live verification after deploy.
|
||||||
|
- Frontend: `npm run build` (tsc) green; live browser verification.
|
||||||
|
|
||||||
|
## Verification (live)
|
||||||
|
|
||||||
|
- `weapon_types=heavy,two_handed` returns only skill-44 and skill-41 melee.
|
||||||
|
- `weapon_types=missile` returns bows/xbows/thrown, no "Bowl".
|
||||||
|
- `weapon_types=war` / `void` return the respective caster schools.
|
||||||
|
- Multi-select in the UI; OD column visible automatically on weapon searches;
|
||||||
|
chips add/remove; sort by OD works.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Changing OD math or the best-values table.
|
||||||
|
- Non-weapon filters, suitbuilder.
|
||||||
|
- Splitting missile back into per-subtype UI checkboxes (Missile is one
|
||||||
|
category; the backend still supports bow/crossbow/thrown individually).
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { InventorySearch } from './useInventorySearch';
|
import type { InventorySearch } from './useInventorySearch';
|
||||||
import { RATING_DEFS } from './constants';
|
import { RATING_DEFS, WEAPON_TYPES } from './constants';
|
||||||
|
|
||||||
interface Chip { label: string; gold?: boolean; remove: () => void; }
|
interface Chip { label: string; gold?: boolean; remove: () => void; }
|
||||||
|
|
||||||
|
|
@ -12,9 +12,13 @@ export function ActiveChips({ search }: { search: InventorySearch }) {
|
||||||
remove: () => update({ characters: 'all' }),
|
remove: () => update({ characters: 'all' }),
|
||||||
});
|
});
|
||||||
if (f.itemType !== 'all') chips.push({
|
if (f.itemType !== 'all') chips.push({
|
||||||
label: f.itemType[0].toUpperCase() + f.itemType.slice(1) + (f.weaponType ? `: ${f.weaponType}` : ''),
|
label: f.itemType[0].toUpperCase() + f.itemType.slice(1),
|
||||||
remove: () => update({ itemType: 'all', weaponType: '' }),
|
remove: () => update({ itemType: 'all', weaponTypes: [] }),
|
||||||
});
|
});
|
||||||
|
if (f.itemType === 'weapon') for (const wt of f.weaponTypes) {
|
||||||
|
const label = WEAPON_TYPES.find(w => w.value === wt)?.label ?? wt;
|
||||||
|
chips.push({ label, remove: () => update({ weaponTypes: f.weaponTypes.filter(x => x !== wt) }) });
|
||||||
|
}
|
||||||
for (const s of f.slots) chips.push({ label: `Slot: ${s}`, remove: () => update({ slots: f.slots.filter(x => x !== s) }) });
|
for (const s of f.slots) chips.push({ label: `Slot: ${s}`, remove: () => update({ slots: f.slots.filter(x => x !== s) }) });
|
||||||
for (const c of f.cantrips) chips.push({
|
for (const c of f.cantrips) chips.push({
|
||||||
label: c.replace(/^Legendary /, 'Leg. '), gold: true,
|
label: c.replace(/^Legendary /, 'Leg. '), gold: true,
|
||||||
|
|
|
||||||
|
|
@ -66,14 +66,22 @@ export function FilterSidebar({ search }: { search: InventorySearch }) {
|
||||||
{types.map(([v, label]) => (
|
{types.map(([v, label]) => (
|
||||||
<label className="inv-fitem" key={v}>
|
<label className="inv-fitem" key={v}>
|
||||||
<input type="radio" name="inv-t" checked={f.itemType === v}
|
<input type="radio" name="inv-t" checked={f.itemType === v}
|
||||||
onChange={() => update({ itemType: v, weaponType: '' })} /> {label}
|
onChange={() => update({ itemType: v, weaponTypes: [] })} /> {label}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
{f.itemType === 'weapon' && (
|
{f.itemType === 'weapon' && (
|
||||||
<select className="inv-minisearch" value={f.weaponType}
|
<div className="inv-subgroup">
|
||||||
onChange={e => update({ weaponType: e.target.value })}>
|
{WEAPON_TYPES.map(w => (
|
||||||
{WEAPON_TYPES.map(w => <option key={w.value} value={w.value}>{w.label}</option>)}
|
<label className="inv-fitem" key={w.value}>
|
||||||
</select>
|
<input type="checkbox" checked={f.weaponTypes.includes(w.value)}
|
||||||
|
onChange={() => update({
|
||||||
|
weaponTypes: f.weaponTypes.includes(w.value)
|
||||||
|
? f.weaponTypes.filter(x => x !== w.value)
|
||||||
|
: [...f.weaponTypes, w.value],
|
||||||
|
})} /> {w.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Group title="Slots" defaultOpen badge={f.slots.length || undefined}>
|
<Group title="Slots" defaultOpen badge={f.slots.length || undefined}>
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,11 @@ export function ResultsTable({ search, selected, onSelect }: {
|
||||||
const [visible, setVisible] = useState<Set<string>>(loadVisible);
|
const [visible, setVisible] = useState<Set<string>>(loadVisible);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const cols = useMemo(() => COLUMNS.filter(c => visible.has(c.key)), [visible]);
|
const cols = useMemo(() => {
|
||||||
|
const eff = new Set(visible);
|
||||||
|
if (f.itemType === 'weapon') eff.add('od_rating');
|
||||||
|
return COLUMNS.filter(c => eff.has(c.key));
|
||||||
|
}, [visible, f.itemType]);
|
||||||
const items = result?.items ?? [];
|
const items = result?.items ?? [];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -44,11 +44,13 @@ export const ARMOR_SLOTS = ['Head', 'Chest', 'Abdomen', 'Upper Arms', 'Lower Arm
|
||||||
'Hands', 'Upper Legs', 'Lower Legs', 'Feet', 'Shield'];
|
'Hands', 'Upper Legs', 'Lower Legs', 'Feet', 'Shield'];
|
||||||
|
|
||||||
export const WEAPON_TYPES: Array<{ value: string; label: string }> = [
|
export const WEAPON_TYPES: Array<{ value: string; label: string }> = [
|
||||||
{ value: '', label: 'All weapons' }, { value: 'heavy', label: 'Heavy' },
|
{ value: 'heavy', label: 'Heavy' },
|
||||||
{ value: 'light', label: 'Light' }, { value: 'finesse', label: 'Finesse' },
|
{ value: 'light', label: 'Light' },
|
||||||
{ value: 'two_handed', label: 'Two-handed' }, { value: 'bow', label: 'Bow' },
|
{ value: 'finesse', label: 'Finesse' },
|
||||||
{ value: 'crossbow', label: 'Crossbow' }, { value: 'thrown', label: 'Thrown' },
|
{ value: 'two_handed', label: 'Two-Handed' },
|
||||||
{ value: 'caster', label: 'Wand/Staff/Orb' },
|
{ value: 'missile', label: 'Missile' },
|
||||||
|
{ value: 'war', label: 'War Magic' },
|
||||||
|
{ value: 'void', label: 'Void Magic' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface RatingDef { param: string; label: string; common?: boolean; }
|
export interface RatingDef { param: string; label: string; common?: boolean; }
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ export interface SearchFilters {
|
||||||
/** 'all' → include_all_characters=true; otherwise explicit list. */
|
/** 'all' → include_all_characters=true; otherwise explicit list. */
|
||||||
characters: 'all' | string[];
|
characters: 'all' | string[];
|
||||||
itemType: ItemType;
|
itemType: ItemType;
|
||||||
weaponType: string; // '' = all weapon types
|
weaponTypes: string[]; // [] = all weapon types
|
||||||
slots: string[];
|
slots: string[];
|
||||||
cantrips: string[]; // full "Legendary X" value strings
|
cantrips: string[]; // full "Legendary X" value strings
|
||||||
spellContains: string;
|
spellContains: string;
|
||||||
|
|
@ -61,7 +61,7 @@ export interface SearchFilters {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_FILTERS: SearchFilters = {
|
export const DEFAULT_FILTERS: SearchFilters = {
|
||||||
text: '', characters: 'all', itemType: 'all', weaponType: '', slots: [],
|
text: '', characters: 'all', itemType: 'all', weaponTypes: [], slots: [],
|
||||||
cantrips: [], spellContains: '', ratings: {}, itemSet: '',
|
cantrips: [], spellContains: '', ratings: {}, itemSet: '',
|
||||||
equipStatus: '', bonded: false, attuned: false, rare: false,
|
equipStatus: '', bonded: false, attuned: false, rare: false,
|
||||||
maxLevel: '', minValue: '', minWorkmanship: '', maxBurden: '',
|
maxLevel: '', minValue: '', minWorkmanship: '', maxBurden: '',
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ function validateFilterValue(key: keyof SearchFilters, value: unknown): unknown
|
||||||
return undefined;
|
return undefined;
|
||||||
case 'slots':
|
case 'slots':
|
||||||
case 'cantrips':
|
case 'cantrips':
|
||||||
|
case 'weaponTypes':
|
||||||
return isStringArray(value) ? value : undefined;
|
return isStringArray(value) ? value : undefined;
|
||||||
case 'itemType':
|
case 'itemType':
|
||||||
return typeof value === 'string' && ITEM_TYPES.has(value) ? value : undefined;
|
return typeof value === 'string' && ITEM_TYPES.has(value) ? value : undefined;
|
||||||
|
|
@ -48,7 +49,6 @@ function validateFilterValue(key: keyof SearchFilters, value: unknown): unknown
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
case 'text':
|
case 'text':
|
||||||
case 'weaponType':
|
|
||||||
case 'spellContains':
|
case 'spellContains':
|
||||||
case 'itemSet':
|
case 'itemSet':
|
||||||
case 'sortBy':
|
case 'sortBy':
|
||||||
|
|
@ -104,7 +104,7 @@ export function buildParams(f: SearchFilters): URLSearchParams {
|
||||||
case 'pants': p.set('pants_only', 'true'); break;
|
case 'pants': p.set('pants_only', 'true'); break;
|
||||||
case 'weapon':
|
case 'weapon':
|
||||||
p.set('weapon_only', 'true');
|
p.set('weapon_only', 'true');
|
||||||
if (f.weaponType) p.set('weapon_type', f.weaponType);
|
if (f.weaponTypes.length) p.set('weapon_types', f.weaponTypes.join(','));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (f.slots.length) p.set('slot_names', f.slots.join(','));
|
if (f.slots.length) p.set('slot_names', f.slots.join(','));
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@
|
||||||
.inv-fitem { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 12px; color: #bbb; cursor: pointer; }
|
.inv-fitem { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 12px; color: #bbb; cursor: pointer; }
|
||||||
.inv-fitem:hover { color: #fff; }
|
.inv-fitem:hover { color: #fff; }
|
||||||
.inv-fitem input { accent-color: #88f; }
|
.inv-fitem input { accent-color: #88f; }
|
||||||
|
.inv-subgroup { margin: 2px 0 2px 10px; }
|
||||||
.inv-subhead { color: #666; font-size: 9px; text-transform: uppercase; letter-spacing: 1px; margin: 6px 0 2px; }
|
.inv-subhead { color: #666; font-size: 9px; text-transform: uppercase; letter-spacing: 1px; margin: 6px 0 2px; }
|
||||||
.inv-linky { color: #88f; font-size: 10px; cursor: pointer; }
|
.inv-linky { color: #88f; font-size: 10px; cursor: pointer; }
|
||||||
.inv-linky:hover { text-decoration: underline; }
|
.inv-linky:hover { text-decoration: underline; }
|
||||||
|
|
|
||||||
|
|
@ -191,7 +191,7 @@ func (s *Server) runSearch(ctx context.Context, q url.Values) (map[string]any, e
|
||||||
case qBool(q, "jewelry_only"):
|
case qBool(q, "jewelry_only"):
|
||||||
conds = append(conds, "object_class = 4")
|
conds = append(conds, "object_class = 4")
|
||||||
case qBool(q, "weapon_only"):
|
case qBool(q, "weapon_only"):
|
||||||
conds = append(conds, weaponTypeClause(q.Get("weapon_type")))
|
conds = append(conds, weaponOnlyClause(q))
|
||||||
case qBool(q, "clothing_only"):
|
case qBool(q, "clothing_only"):
|
||||||
conds = append(conds, "(object_class = 3 AND name NOT ILIKE '%cloak%' AND name NOT ILIKE '%robe%' AND name NOT ILIKE '%pallium%' AND name NOT ILIKE '%armet%' AND (name ILIKE '%shirt%' OR name ILIKE '%pants%' OR name ILIKE '%breeches%' OR name ILIKE '%baggy%' OR name ILIKE '%tunic%'))")
|
conds = append(conds, "(object_class = 3 AND name NOT ILIKE '%cloak%' AND name NOT ILIKE '%robe%' AND name NOT ILIKE '%pallium%' AND name NOT ILIKE '%armet%' AND (name ILIKE '%shirt%' OR name ILIKE '%pants%' OR name ILIKE '%breeches%' OR name ILIKE '%baggy%' OR name ILIKE '%tunic%'))")
|
||||||
}
|
}
|
||||||
|
|
@ -543,24 +543,36 @@ func underwearCTEWhere(q map[string][]string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func weaponTypeClause(wt string) string {
|
func weaponTypeClause(wt string) string {
|
||||||
exists := func(skill int) string {
|
meleeSkill := func(skill int) string {
|
||||||
return fmt.Sprintf("(object_class = 1 AND EXISTS (SELECT 1 FROM item_raw_data wrd WHERE wrd.item_id = db_item_id AND (wrd.int_values->>'218103840')::int = %d))", skill)
|
return fmt.Sprintf("(object_class = 1 AND EXISTS (SELECT 1 FROM item_raw_data wrd WHERE wrd.item_id = db_item_id AND (wrd.int_values->>'218103840')::int = %d))", skill)
|
||||||
}
|
}
|
||||||
|
missileMastery := func(mastery int) string {
|
||||||
|
return fmt.Sprintf("(object_class = 9 AND EXISTS (SELECT 1 FROM item_raw_data wrd WHERE wrd.item_id = db_item_id AND (wrd.int_values->>'218103840')::int = 47 AND (wrd.int_values->>'353')::int = %d))", mastery)
|
||||||
|
}
|
||||||
|
casterSkill := func(skill int) string {
|
||||||
|
return fmt.Sprintf("(object_class = 31 AND EXISTS (SELECT 1 FROM item_raw_data wrd WHERE wrd.item_id = db_item_id AND (wrd.int_values->>'159')::int = %d))", skill)
|
||||||
|
}
|
||||||
switch strings.ToLower(wt) {
|
switch strings.ToLower(wt) {
|
||||||
case "heavy":
|
case "heavy":
|
||||||
return exists(44)
|
return meleeSkill(44)
|
||||||
case "light":
|
case "light":
|
||||||
return exists(45)
|
return meleeSkill(45)
|
||||||
case "finesse":
|
case "finesse":
|
||||||
return exists(46)
|
return meleeSkill(46)
|
||||||
case "two_handed":
|
case "two_handed":
|
||||||
return exists(41)
|
return meleeSkill(41)
|
||||||
|
case "missile":
|
||||||
|
return "(object_class = 9 AND EXISTS (SELECT 1 FROM item_raw_data wrd WHERE wrd.item_id = db_item_id AND (wrd.int_values->>'218103840')::int = 47))"
|
||||||
case "bow":
|
case "bow":
|
||||||
return "(object_class = 9 AND name ILIKE '%bow%' AND name NOT ILIKE '%crossbow%')"
|
return missileMastery(8)
|
||||||
case "crossbow":
|
case "crossbow":
|
||||||
return "(object_class = 9 AND name ILIKE '%crossbow%')"
|
return missileMastery(9)
|
||||||
case "thrown":
|
case "thrown":
|
||||||
return "(object_class = 9 AND (name ILIKE '%atlatl%' OR name ILIKE '%throwing%' OR name ILIKE '%javelin%' OR name ILIKE '%shuriken%' OR name ILIKE '%dart%' OR name ILIKE '%slingshot%'))"
|
return missileMastery(10)
|
||||||
|
case "war":
|
||||||
|
return casterSkill(34)
|
||||||
|
case "void":
|
||||||
|
return casterSkill(43)
|
||||||
case "caster":
|
case "caster":
|
||||||
return "object_class = 31"
|
return "object_class = 31"
|
||||||
default:
|
default:
|
||||||
|
|
@ -568,6 +580,34 @@ func weaponTypeClause(wt string) string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// weaponOnlyClause builds the WHERE fragment for weapon_only searches: an OR of
|
||||||
|
// the per-type clauses named in weapon_types (CSV) plus the legacy single
|
||||||
|
// weapon_type. Empty selection = all weapons. Types are deduped.
|
||||||
|
func weaponOnlyClause(q url.Values) string {
|
||||||
|
var types []string
|
||||||
|
types = append(types, splitNonEmpty(q.Get("weapon_types"))...)
|
||||||
|
if wt := strings.TrimSpace(q.Get("weapon_type")); wt != "" {
|
||||||
|
types = append(types, wt)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var clauses []string
|
||||||
|
for _, t := range types {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(t))
|
||||||
|
if key == "" || seen[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
clauses = append(clauses, weaponTypeClause(key))
|
||||||
|
}
|
||||||
|
if len(clauses) == 0 {
|
||||||
|
return "object_class IN (1, 9, 31)"
|
||||||
|
}
|
||||||
|
if len(clauses) == 1 {
|
||||||
|
return clauses[0]
|
||||||
|
}
|
||||||
|
return "(" + strings.Join(clauses, " OR ") + ")"
|
||||||
|
}
|
||||||
|
|
||||||
func slotNameClause(name string, ab *argBuilder) string {
|
func slotNameClause(name string, ab *argBuilder) string {
|
||||||
switch strings.ToLower(name) {
|
switch strings.ToLower(name) {
|
||||||
case "ring":
|
case "ring":
|
||||||
|
|
|
||||||
62
go-services/inventory-go/search_test.go
Normal file
62
go-services/inventory-go/search_test.go
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWeaponTypeClause(t *testing.T) {
|
||||||
|
cases := map[string][]string{
|
||||||
|
"heavy": {"object_class = 1", "'218103840')::int = 44"},
|
||||||
|
"light": {"object_class = 1", "= 45"},
|
||||||
|
"finesse": {"object_class = 1", "= 46"},
|
||||||
|
"two_handed": {"object_class = 1", "= 41"},
|
||||||
|
"missile": {"object_class = 9", "'218103840')::int = 47"},
|
||||||
|
"bow": {"object_class = 9", "'218103840')::int = 47", "'353')::int = 8"},
|
||||||
|
"crossbow": {"object_class = 9", "'353')::int = 9"},
|
||||||
|
"thrown": {"object_class = 9", "'353')::int = 10"},
|
||||||
|
"war": {"object_class = 31", "'159')::int = 34"},
|
||||||
|
"void": {"object_class = 31", "'159')::int = 43"},
|
||||||
|
"caster": {"object_class = 31"},
|
||||||
|
}
|
||||||
|
for wt, subs := range cases {
|
||||||
|
got := weaponTypeClause(wt)
|
||||||
|
for _, s := range subs {
|
||||||
|
if !strings.Contains(got, s) {
|
||||||
|
t.Errorf("weaponTypeClause(%q) = %q, missing %q", wt, got, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := weaponTypeClause("nonsense"); !strings.Contains(got, "object_class IN (1, 9, 31)") {
|
||||||
|
t.Errorf("unknown type = %q, want all-weapons fallback", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeaponOnlyClause(t *testing.T) {
|
||||||
|
// No types → all weapons.
|
||||||
|
if got := weaponOnlyClause(url.Values{}); !strings.Contains(got, "object_class IN (1, 9, 31)") {
|
||||||
|
t.Errorf("empty = %q, want all weapons", got)
|
||||||
|
}
|
||||||
|
// Single type via weapon_types.
|
||||||
|
got := weaponOnlyClause(url.Values{"weapon_types": {"heavy"}})
|
||||||
|
if !strings.Contains(got, "= 44") || strings.Contains(got, " OR ") {
|
||||||
|
t.Errorf("single = %q, want just heavy, no OR", got)
|
||||||
|
}
|
||||||
|
// Multiple → parenthesized OR, one clause per type.
|
||||||
|
got = weaponOnlyClause(url.Values{"weapon_types": {"heavy,two_handed,war"}})
|
||||||
|
if !strings.HasPrefix(got, "(") || !strings.HasSuffix(got, ")") ||
|
||||||
|
strings.Count(got, " OR ") != 2 ||
|
||||||
|
!strings.Contains(got, "= 44") || !strings.Contains(got, "= 41") || !strings.Contains(got, "= 34") {
|
||||||
|
t.Errorf("multi = %q, want (heavy OR two_handed OR war)", got)
|
||||||
|
}
|
||||||
|
// Legacy weapon_type still honored, deduped against weapon_types.
|
||||||
|
got = weaponOnlyClause(url.Values{"weapon_type": {"missile"}})
|
||||||
|
if !strings.Contains(got, "object_class = 9") {
|
||||||
|
t.Errorf("legacy weapon_type = %q, want missile", got)
|
||||||
|
}
|
||||||
|
got = weaponOnlyClause(url.Values{"weapon_types": {"heavy"}, "weapon_type": {"heavy"}})
|
||||||
|
if strings.Contains(got, " OR ") {
|
||||||
|
t.Errorf("dedup = %q, want single heavy clause", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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-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-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-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-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-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-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-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-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-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-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-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
File diff suppressed because one or more lines are too long
|
|
@ -8,9 +8,9 @@
|
||||||
<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-C-OoyBwD.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-IwGZYtQM.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue