Compare commits

..

No commits in common. "cc403240d6f9df75e1d92617700dcef4187969d1" and "d5febaabda6a722103752331a9e48fe2e3cac11e" have entirely different histories.

25 changed files with 52 additions and 577 deletions

View file

@ -1,326 +0,0 @@
# 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").

View file

@ -1,78 +0,0 @@
# 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).

View file

@ -1,5 +1,5 @@
import type { InventorySearch } from './useInventorySearch'; import type { InventorySearch } from './useInventorySearch';
import { RATING_DEFS, WEAPON_TYPES } from './constants'; import { RATING_DEFS } from './constants';
interface Chip { label: string; gold?: boolean; remove: () => void; } interface Chip { label: string; gold?: boolean; remove: () => void; }
@ -12,13 +12,9 @@ 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), label: f.itemType[0].toUpperCase() + f.itemType.slice(1) + (f.weaponType ? `: ${f.weaponType}` : ''),
remove: () => update({ itemType: 'all', weaponTypes: [] }), remove: () => update({ itemType: 'all', weaponType: '' }),
}); });
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,

View file

@ -66,22 +66,14 @@ 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, weaponTypes: [] })} /> {label} onChange={() => update({ itemType: v, weaponType: '' })} /> {label}
</label> </label>
))} ))}
{f.itemType === 'weapon' && ( {f.itemType === 'weapon' && (
<div className="inv-subgroup"> <select className="inv-minisearch" value={f.weaponType}
{WEAPON_TYPES.map(w => ( onChange={e => update({ weaponType: e.target.value })}>
<label className="inv-fitem" key={w.value}> {WEAPON_TYPES.map(w => <option key={w.value} value={w.value}>{w.label}</option>)}
<input type="checkbox" checked={f.weaponTypes.includes(w.value)} </select>
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}>

View file

@ -42,11 +42,7 @@ 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(() => { const cols = useMemo(() => COLUMNS.filter(c => visible.has(c.key)), [visible]);
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(() => {

View file

@ -44,13 +44,11 @@ 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: 'heavy', label: 'Heavy' }, { value: '', label: 'All weapons' }, { value: 'heavy', label: 'Heavy' },
{ value: 'light', label: 'Light' }, { value: 'light', label: 'Light' }, { value: 'finesse', label: 'Finesse' },
{ value: 'finesse', label: 'Finesse' }, { value: 'two_handed', label: 'Two-handed' }, { value: 'bow', label: 'Bow' },
{ value: 'two_handed', label: 'Two-Handed' }, { value: 'crossbow', label: 'Crossbow' }, { value: 'thrown', label: 'Thrown' },
{ value: 'missile', label: 'Missile' }, { value: 'caster', label: 'Wand/Staff/Orb' },
{ 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; }

View file

@ -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;
weaponTypes: string[]; // [] = all weapon types weaponType: 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', weaponTypes: [], slots: [], text: '', characters: 'all', itemType: 'all', weaponType: '', 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: '',

View file

@ -34,7 +34,6 @@ 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;
@ -49,6 +48,7 @@ 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.weaponTypes.length) p.set('weapon_types', f.weaponTypes.join(',')); if (f.weaponType) p.set('weapon_type', f.weaponType);
break; break;
} }
if (f.slots.length) p.set('slot_names', f.slots.join(',')); if (f.slots.length) p.set('slot_names', f.slots.join(','));

View file

@ -49,7 +49,6 @@
.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; }

View file

@ -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, weaponOnlyClause(q)) conds = append(conds, weaponTypeClause(q.Get("weapon_type")))
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,36 +543,24 @@ func underwearCTEWhere(q map[string][]string) string {
} }
func weaponTypeClause(wt string) string { func weaponTypeClause(wt string) string {
meleeSkill := func(skill int) string { exists := 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 meleeSkill(44) return exists(44)
case "light": case "light":
return meleeSkill(45) return exists(45)
case "finesse": case "finesse":
return meleeSkill(46) return exists(46)
case "two_handed": case "two_handed":
return meleeSkill(41) return exists(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 missileMastery(8) return "(object_class = 9 AND name ILIKE '%bow%' AND name NOT ILIKE '%crossbow%')"
case "crossbow": case "crossbow":
return missileMastery(9) return "(object_class = 9 AND name ILIKE '%crossbow%')"
case "thrown": case "thrown":
return missileMastery(10) 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%'))"
case "war":
return casterSkill(34)
case "void":
return casterSkill(43)
case "caster": case "caster":
return "object_class = 31" return "object_class = 31"
default: default:
@ -580,34 +568,6 @@ 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":

View file

@ -1,62 +0,0 @@
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)
}
}

View file

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

View file

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

File diff suppressed because one or more lines are too long

View file

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

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

File diff suppressed because one or more lines are too long

View file

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

View file

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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-C-OoyBwD.js"></script> <script type="module" crossorigin src="/assets/index-CPIBhBUe.js"></script>
<link rel="modulepreload" crossorigin href="/assets/react-yfL0ty4i.js"> <link rel="modulepreload" crossorigin href="/assets/react-yfL0ty4i.js">
<link rel="stylesheet" crossorigin href="/assets/index-IwGZYtQM.css"> <link rel="stylesheet" crossorigin href="/assets/index-Dj1_Fy8n.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>