docs: spec + plan for weapon-type-by-skill multi-select filter + OD column

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-15 08:38:18 +02:00
parent d5febaabda
commit 29d400f86a
2 changed files with 404 additions and 0 deletions

View 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").

View file

@ -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).