From 29d400f86aa4ca933c2f3be5e1440fbb1d859936 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 15 Jul 2026 08:38:18 +0200 Subject: [PATCH] docs: spec + plan for weapon-type-by-skill multi-select filter + OD column Co-Authored-By: Claude Fable 5 --- .../2026-07-15-weapon-type-skill-filter.md | 326 ++++++++++++++++++ ...6-07-15-weapon-type-skill-filter-design.md | 78 +++++ 2 files changed, 404 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-15-weapon-type-skill-filter.md create mode 100644 docs/superpowers/specs/2026-07-15-weapon-type-skill-filter-design.md diff --git a/docs/superpowers/plans/2026-07-15-weapon-type-skill-filter.md b/docs/superpowers/plans/2026-07-15-weapon-type-skill-filter.md new file mode 100644 index 00000000..66380fc0 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-weapon-type-skill-filter.md @@ -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 -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 " +``` + +--- + +### 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 `...)}` block, ~line 72-77) with a checkbox group: +```tsx + {f.itemType === 'weapon' && ( +
+ {WEAPON_TYPES.map(w => ( + + ))} +
+ )} +``` +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 " +``` + +--- + +### 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: | diff -q - `), `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"). diff --git a/docs/superpowers/specs/2026-07-15-weapon-type-skill-filter-design.md b/docs/superpowers/specs/2026-07-15-weapon-type-skill-filter-design.md new file mode 100644 index 00000000..7b816a52 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-weapon-type-skill-filter-design.md @@ -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 `