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:
parent
d5febaabda
commit
29d400f86a
2 changed files with 404 additions and 0 deletions
326
docs/superpowers/plans/2026-07-15-weapon-type-skill-filter.md
Normal file
326
docs/superpowers/plans/2026-07-15-weapon-type-skill-filter.md
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
# Weapon-type (by skill) filter + OD column Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Multi-select weapon-type filter by skill (Heavy/Light/Finesse/Two-Handed/Missile/War/Void), and auto-show the OD column on weapon searches.
|
||||
|
||||
**Architecture:** Backend reworks `weaponTypeClause` to be object-class/key aware and adds a `weapon_types` CSV param (OR of per-type clauses) in `runSearch`; legacy `weapon_type` kept. Frontend changes `weaponType: string` → `weaponTypes: string[]` (multi-select checkboxes), redefines `WEAPON_TYPES` to seven skill categories, and unions `od_rating` into the visible columns when item type is Weapons. Spec: `docs/superpowers/specs/2026-07-15-weapon-type-skill-filter-design.md`.
|
||||
|
||||
**Tech Stack:** Go 1.25 (backend, no local toolchain — test in `golang:1.25-bookworm` on the server); React 19 + Vite (`npm run build` locally).
|
||||
|
||||
**Working dir for git:** `C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord`
|
||||
|
||||
**Go test command:**
|
||||
```bash
|
||||
cd "C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord" && \
|
||||
tar czf - go-services | ssh erik@overlord.snakedesert.se \
|
||||
"rm -rf /tmp/tdd-verify && mkdir -p /tmp/tdd-verify && tar xzf - -C /tmp/tdd-verify" && \
|
||||
ssh erik@overlord.snakedesert.se \
|
||||
"docker run --rm -v /tmp/tdd-verify/go-services/inventory-go:/src -w /src golang:1.25-bookworm \
|
||||
sh -c 'go mod tidy >/dev/null 2>&1; go test ./... -run <PATTERN> -v 2>&1'"
|
||||
```
|
||||
|
||||
**Verified field keys:** melee `object_class=1` + `int_values->>'218103840'` (44/45/46/41); missile `object_class=9` + `218103840=47`, mastery `353` (8 bow/9 xbow/10 thrown); wand `object_class=31` + equip skill `int_values->>'159'` (34 war/43 void).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend — reworked weaponTypeClause + weapon_types param (TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `go-services/inventory-go/search.go` (`weaponTypeClause` ~line 545; the `weapon_only` case ~line 193)
|
||||
- Create: `go-services/inventory-go/search_test.go`
|
||||
|
||||
- [ ] **Step 1: Write failing tests.** Create `search_test.go`:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWeaponTypeClause(t *testing.T) {
|
||||
cases := map[string][]string{
|
||||
"heavy": {"object_class = 1", "'218103840')::int = 44"},
|
||||
"light": {"object_class = 1", "= 45"},
|
||||
"finesse": {"object_class = 1", "= 46"},
|
||||
"two_handed": {"object_class = 1", "= 41"},
|
||||
"missile": {"object_class = 9", "'218103840')::int = 47"},
|
||||
"bow": {"object_class = 9", "'218103840')::int = 47", "'353')::int = 8"},
|
||||
"crossbow": {"object_class = 9", "'353')::int = 9"},
|
||||
"thrown": {"object_class = 9", "'353')::int = 10"},
|
||||
"war": {"object_class = 31", "'159')::int = 34"},
|
||||
"void": {"object_class = 31", "'159')::int = 43"},
|
||||
"caster": {"object_class = 31"},
|
||||
}
|
||||
for wt, subs := range cases {
|
||||
got := weaponTypeClause(wt)
|
||||
for _, s := range subs {
|
||||
if !strings.Contains(got, s) {
|
||||
t.Errorf("weaponTypeClause(%q) = %q, missing %q", wt, got, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if got := weaponTypeClause("nonsense"); !strings.Contains(got, "object_class IN (1, 9, 31)") {
|
||||
t.Errorf("unknown type = %q, want all-weapons fallback", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeaponOnlyClause(t *testing.T) {
|
||||
// No types → all weapons.
|
||||
if got := weaponOnlyClause(url.Values{}); !strings.Contains(got, "object_class IN (1, 9, 31)") {
|
||||
t.Errorf("empty = %q, want all weapons", got)
|
||||
}
|
||||
// Single type via weapon_types.
|
||||
got := weaponOnlyClause(url.Values{"weapon_types": {"heavy"}})
|
||||
if !strings.Contains(got, "= 44") || strings.Contains(got, " OR ") {
|
||||
t.Errorf("single = %q, want just heavy, no OR", got)
|
||||
}
|
||||
// Multiple → parenthesized OR, one clause per type.
|
||||
got = weaponOnlyClause(url.Values{"weapon_types": {"heavy,two_handed,war"}})
|
||||
if !strings.HasPrefix(got, "(") || !strings.HasSuffix(got, ")") ||
|
||||
strings.Count(got, " OR ") != 2 ||
|
||||
!strings.Contains(got, "= 44") || !strings.Contains(got, "= 41") || !strings.Contains(got, "= 34") {
|
||||
t.Errorf("multi = %q, want (heavy OR two_handed OR war)", got)
|
||||
}
|
||||
// Legacy weapon_type still honored, deduped against weapon_types.
|
||||
got = weaponOnlyClause(url.Values{"weapon_type": {"missile"}})
|
||||
if !strings.Contains(got, "object_class = 9") {
|
||||
t.Errorf("legacy weapon_type = %q, want missile", got)
|
||||
}
|
||||
got = weaponOnlyClause(url.Values{"weapon_types": {"heavy"}, "weapon_type": {"heavy"}})
|
||||
if strings.Contains(got, " OR ") {
|
||||
t.Errorf("dedup = %q, want single heavy clause", got)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests → RED** (`-run 'TestWeaponTypeClause|TestWeaponOnlyClause'`): `undefined: weaponOnlyClause` build failure.
|
||||
|
||||
- [ ] **Step 3: Implement.** Replace the whole `weaponTypeClause` func (~545-575) with:
|
||||
|
||||
```go
|
||||
func weaponTypeClause(wt string) string {
|
||||
meleeSkill := func(skill int) string {
|
||||
return fmt.Sprintf("(object_class = 1 AND EXISTS (SELECT 1 FROM item_raw_data wrd WHERE wrd.item_id = db_item_id AND (wrd.int_values->>'218103840')::int = %d))", skill)
|
||||
}
|
||||
missileMastery := func(mastery int) string {
|
||||
return fmt.Sprintf("(object_class = 9 AND EXISTS (SELECT 1 FROM item_raw_data wrd WHERE wrd.item_id = db_item_id AND (wrd.int_values->>'218103840')::int = 47 AND (wrd.int_values->>'353')::int = %d))", mastery)
|
||||
}
|
||||
casterSkill := func(skill int) string {
|
||||
return fmt.Sprintf("(object_class = 31 AND EXISTS (SELECT 1 FROM item_raw_data wrd WHERE wrd.item_id = db_item_id AND (wrd.int_values->>'159')::int = %d))", skill)
|
||||
}
|
||||
switch strings.ToLower(wt) {
|
||||
case "heavy":
|
||||
return meleeSkill(44)
|
||||
case "light":
|
||||
return meleeSkill(45)
|
||||
case "finesse":
|
||||
return meleeSkill(46)
|
||||
case "two_handed":
|
||||
return meleeSkill(41)
|
||||
case "missile":
|
||||
return "(object_class = 9 AND EXISTS (SELECT 1 FROM item_raw_data wrd WHERE wrd.item_id = db_item_id AND (wrd.int_values->>'218103840')::int = 47))"
|
||||
case "bow":
|
||||
return missileMastery(8)
|
||||
case "crossbow":
|
||||
return missileMastery(9)
|
||||
case "thrown":
|
||||
return missileMastery(10)
|
||||
case "war":
|
||||
return casterSkill(34)
|
||||
case "void":
|
||||
return casterSkill(43)
|
||||
case "caster":
|
||||
return "object_class = 31"
|
||||
default:
|
||||
return "object_class IN (1, 9, 31)"
|
||||
}
|
||||
}
|
||||
|
||||
// weaponOnlyClause builds the WHERE fragment for weapon_only searches: an OR of
|
||||
// the per-type clauses named in weapon_types (CSV) plus the legacy single
|
||||
// weapon_type. Empty selection = all weapons. Types are deduped.
|
||||
func weaponOnlyClause(q url.Values) string {
|
||||
var types []string
|
||||
types = append(types, splitNonEmpty(q.Get("weapon_types"))...)
|
||||
if wt := strings.TrimSpace(q.Get("weapon_type")); wt != "" {
|
||||
types = append(types, wt)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var clauses []string
|
||||
for _, t := range types {
|
||||
key := strings.ToLower(strings.TrimSpace(t))
|
||||
if key == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
clauses = append(clauses, weaponTypeClause(key))
|
||||
}
|
||||
if len(clauses) == 0 {
|
||||
return "object_class IN (1, 9, 31)"
|
||||
}
|
||||
if len(clauses) == 1 {
|
||||
return clauses[0]
|
||||
}
|
||||
return "(" + strings.Join(clauses, " OR ") + ")"
|
||||
}
|
||||
```
|
||||
|
||||
Then change the `weapon_only` case (~line 193-194) from
|
||||
`conds = append(conds, weaponTypeClause(q.Get("weapon_type")))`
|
||||
to
|
||||
`conds = append(conds, weaponOnlyClause(q))`.
|
||||
|
||||
(`url` and `strings` and `fmt` and `splitNonEmpty` are already imported/defined in search.go.)
|
||||
|
||||
- [ ] **Step 4: Run tests → GREEN** (`-run 'TestWeapon'`) then whole package (`-run .`) + `go vet ./...` clean.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add go-services/inventory-go/search.go go-services/inventory-go/search_test.go
|
||||
git commit -m "feat(inventory-go): weapon_types multi-select filter by skill (melee/missile/war/void)
|
||||
|
||||
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Frontend — multi-select weapon type + auto OD column
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/inventory/constants.ts`
|
||||
- Modify: `frontend/src/components/inventory/types.ts`
|
||||
- Modify: `frontend/src/components/inventory/useInventorySearch.ts`
|
||||
- Modify: `frontend/src/components/inventory/FilterSidebar.tsx`
|
||||
- Modify: `frontend/src/components/inventory/ActiveChips.tsx`
|
||||
- Modify: `frontend/src/components/inventory/ResultsTable.tsx`
|
||||
|
||||
- [ ] **Step 1: constants.ts** — replace the `WEAPON_TYPES` array with the seven skill categories:
|
||||
```ts
|
||||
export const WEAPON_TYPES: Array<{ value: string; label: string }> = [
|
||||
{ value: 'heavy', label: 'Heavy' },
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'finesse', label: 'Finesse' },
|
||||
{ value: 'two_handed', label: 'Two-Handed' },
|
||||
{ value: 'missile', label: 'Missile' },
|
||||
{ value: 'war', label: 'War Magic' },
|
||||
{ value: 'void', label: 'Void Magic' },
|
||||
];
|
||||
```
|
||||
(Removed the leading `{ value: '', label: 'All weapons' }` — multi-select uses "none checked = all".)
|
||||
|
||||
- [ ] **Step 2: types.ts** — in `SearchFilters` change `weaponType: string;` to `weaponTypes: string[];`; in `DEFAULT_FILTERS` change `weaponType: '',` to `weaponTypes: [],`.
|
||||
|
||||
- [ ] **Step 3: useInventorySearch.ts**
|
||||
- `validateFilterValue`: add a case
|
||||
```ts
|
||||
case 'weaponTypes':
|
||||
return isStringArray(value) ? value : undefined;
|
||||
```
|
||||
(place near the `slots`/`cantrips` case; `isStringArray` already exists.)
|
||||
- `buildParams`: in the `case 'weapon':` block, replace
|
||||
```ts
|
||||
p.set('weapon_only', 'true');
|
||||
if (f.weaponType) p.set('weapon_type', f.weaponType);
|
||||
```
|
||||
with
|
||||
```ts
|
||||
p.set('weapon_only', 'true');
|
||||
if (f.weaponTypes.length) p.set('weapon_types', f.weaponTypes.join(','));
|
||||
```
|
||||
- Anywhere `weaponType` is referenced elsewhere (e.g. `update({ itemType: v, weaponType: '' })` resets) — grep and change to `weaponTypes: []`.
|
||||
|
||||
- [ ] **Step 4: FilterSidebar.tsx** — replace the weapon-type `<select>` (the `{f.itemType === 'weapon' && (<select>...)}` block, ~line 72-77) with a checkbox group:
|
||||
```tsx
|
||||
{f.itemType === 'weapon' && (
|
||||
<div className="inv-subgroup">
|
||||
{WEAPON_TYPES.map(w => (
|
||||
<label className="inv-fitem" key={w.value}>
|
||||
<input type="checkbox" checked={f.weaponTypes.includes(w.value)}
|
||||
onChange={() => update({
|
||||
weaponTypes: f.weaponTypes.includes(w.value)
|
||||
? f.weaponTypes.filter(x => x !== w.value)
|
||||
: [...f.weaponTypes, w.value],
|
||||
})} /> {w.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
Also update the item-type radio `onChange` that currently does `update({ itemType: v, weaponType: '' })` → `update({ itemType: v, weaponTypes: [] })`. Add `.inv-subgroup{margin:2px 0 2px 10px}` to inventory.css.
|
||||
|
||||
- [ ] **Step 5: ActiveChips.tsx** — replace the single item-type/weaponType chip logic. The current block:
|
||||
```ts
|
||||
if (f.itemType !== 'all') chips.push({
|
||||
label: f.itemType[0].toUpperCase() + f.itemType.slice(1) + (f.weaponType ? `: ${f.weaponType}` : ''),
|
||||
remove: () => update({ itemType: 'all', weaponType: '' }),
|
||||
});
|
||||
```
|
||||
becomes:
|
||||
```ts
|
||||
if (f.itemType !== 'all') chips.push({
|
||||
label: f.itemType[0].toUpperCase() + f.itemType.slice(1),
|
||||
remove: () => update({ itemType: 'all', weaponTypes: [] }),
|
||||
});
|
||||
for (const wt of f.weaponTypes) {
|
||||
const label = WEAPON_TYPES.find(w => w.value === wt)?.label ?? wt;
|
||||
chips.push({ label, remove: () => update({ weaponTypes: f.weaponTypes.filter(x => x !== wt) }) });
|
||||
}
|
||||
```
|
||||
Add `import { WEAPON_TYPES } from './constants';` if not present (RATING_DEFS import is already there).
|
||||
|
||||
- [ ] **Step 6: ResultsTable.tsx** — make the OD column auto-visible on weapon searches. Change the `cols` memo:
|
||||
```ts
|
||||
const cols = useMemo(() => {
|
||||
const eff = new Set(visible);
|
||||
if (f.itemType === 'weapon') eff.add('od_rating');
|
||||
return COLUMNS.filter(c => eff.has(c.key));
|
||||
}, [visible, f.itemType]);
|
||||
```
|
||||
(`f` is already destructured from `search`.)
|
||||
|
||||
- [ ] **Step 7: Build** — `cd frontend && npm run build`; delete `static/_build` after. Grep to confirm no stray `weaponType` (singular) references remain: `grep -rn "weaponType\b" frontend/src/components/inventory` should only match `weaponTypes`.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
```bash
|
||||
git add frontend/src/components/inventory/ frontend/src/styles/inventory.css
|
||||
git commit -m "feat(frontend): multi-select weapon-type-by-skill filter + auto OD column
|
||||
|
||||
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Deploy + verify
|
||||
|
||||
- [ ] **Step 1: Backend** — sync + test-gated build + recreate:
|
||||
```bash
|
||||
cd "C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord" && \
|
||||
tar czf - go-services | ssh erik@overlord.snakedesert.se "tar xzf - -C /home/erik/MosswartOverlord/"
|
||||
ssh erik@overlord.snakedesert.se 'cd /home/erik/MosswartOverlord && \
|
||||
export BUILD_VERSION="$(date -u +%Y.%-m.%-d.%H%M)-$(git rev-parse --short HEAD)" && \
|
||||
docker compose -f docker-compose.yml -f go-services/docker-compose.go.yml build --build-arg BUILD_VERSION=$BUILD_VERSION inventory-go && \
|
||||
docker compose -f docker-compose.yml -f go-services/docker-compose.go.yml -f go-services/docker-compose.cutover.yml up -d --no-deps inventory-go'
|
||||
```
|
||||
- [ ] **Step 2: Frontend** — `bash deploy-frontend.sh`, then `git add static/ frontend/ && git commit -m "build(frontend): deploy weapon-type filter" && git push origin master`.
|
||||
- [ ] **Step 3: Server pull** — `ssh erik@overlord.snakedesert.se "cd /home/erik/MosswartOverlord && git pull --ff-only origin master"`. If it aborts on the tar-synced working-tree files, verify each blocker matches origin (`git show origin/master:<f> | diff -q - <f>`), `git checkout --`/`rm` them, then re-pull. Confirm the served bundle hash changed.
|
||||
- [ ] **Step 4: Backend live checks** (through the `/inv` proxy the browser uses):
|
||||
```bash
|
||||
ssh erik@overlord.snakedesert.se "for t in heavy two_handed missile war void; do echo -n \"\$t: \"; curl -s \"http://127.0.0.1:8770/inv/search/items?include_all_characters=true&weapon_only=true&weapon_types=\$t&limit=1\" | python3 -c 'import json,sys;d=json.load(sys.stdin);print(d[\"total_count\"])'; done"
|
||||
```
|
||||
Expected: each returns a plausible non-zero count. Then a multi-select + no-Bowl check:
|
||||
```bash
|
||||
ssh erik@overlord.snakedesert.se "curl -s 'http://127.0.0.1:8770/inv/search/items?include_all_characters=true&weapon_only=true&weapon_types=missile&limit=200' | python3 -c \"import json,sys;d=json.load(sys.stdin);names=[i['name'] for i in d['items']];print('missile total',d['total_count'],'| any Bowl?', any('Bowl' in n for n in names))\""
|
||||
```
|
||||
Expected: total > 0, `any Bowl? False`.
|
||||
- [ ] **Step 5: User verification** (login-gated — ask the user): open `/?view=inventory`, select Weapons, check Heavy + Two-Handed, confirm results; confirm the OD column shows automatically; check War Magic / Void Magic return casters; chips add/remove per type.
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
- Spec coverage: multi-select by skill (T1 backend, T2 frontend), OD column on weapon searches (T2 step 6), verify (T3). All covered.
|
||||
- Back-compat: legacy `weapon_type` still honored by `weaponOnlyClause`; URL state with old `weaponType` string is dropped by validation (falls back to `[]`), harmless.
|
||||
- Bugfix ride-along: missile subtypes now mastery-based (fixes "Bowl").
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
# Weapon-type (by skill) multi-select filter + OD column for weapon searches
|
||||
|
||||
**Date:** 2026-07-15
|
||||
**Status:** Pre-approved by user
|
||||
|
||||
## Problem
|
||||
|
||||
Weapon searching is weak: the current weapon-type filter is a single-select
|
||||
dropdown (`weapon_type`, shown only under the "Weapons" item type) and the OD
|
||||
column is hidden by default. The user wants to search across weapon types by
|
||||
skill (Heavy, Light, Finesse, Two-Handed, Missile, War/Void casters), pick
|
||||
more than one at once, and see the OD value in the results columns.
|
||||
|
||||
## Field semantics (verified against live data)
|
||||
|
||||
- Melee (`object_class = 1`): WeaponSkill `int_values->>'218103840'` = 44 Heavy,
|
||||
45 Light, 46 Finesse, 41 Two-Handed.
|
||||
- Missile (`object_class = 9`): WeaponSkill `218103840` = 47; subtype from
|
||||
mastery `int_values->>'353'` = 8 Bow, 9 Crossbow, 10 Thrown. Arrows/ammo lack
|
||||
skill 47, so gating on it excludes them — and fixes the existing bug where
|
||||
the name-based `%bow%` clause matched "Bowl".
|
||||
- Wands (`object_class = 31`): WeaponSkill is null; use equip skill
|
||||
`int_values->>'159'` = 34 War Magic, 43 Void Magic (the only caster schools
|
||||
the OD table rates).
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Weapon-type becomes multi-select** (checkbox group) with seven
|
||||
skill-level categories: Heavy, Light, Finesse, Two-Handed, Missile,
|
||||
War Magic, Void Magic. Any combination; none = all weapons. Shown when the
|
||||
"Weapons" item type is selected (same nesting the single dropdown used).
|
||||
2. **Backend** accepts a new `weapon_types` CSV param → OR of per-type clauses,
|
||||
ANDed into the query. The legacy single `weapon_type` param is kept for
|
||||
back-compat. `weaponTypeClause` is reworked to be object-class/key aware
|
||||
(melee by 218103840, missile launcher by 218103840=47 + mastery, casters by
|
||||
159), replacing the melee-only `exists(skill)` helper. Existing type keys
|
||||
(bow/crossbow/thrown/caster/light/finesse/heavy/two_handed) keep working;
|
||||
bow/crossbow/thrown switch to the robust mastery-based clause.
|
||||
3. **OD column auto-shows for weapon searches**: when `itemType === 'weapon'`,
|
||||
the results table includes the `od_rating` column regardless of the user's
|
||||
saved column set (union, non-destructive — their other prefs and the column
|
||||
picker still work; OD just can't be hidden while weapon-searching). No
|
||||
change to default sort.
|
||||
|
||||
## Frontend model
|
||||
|
||||
- `SearchFilters.weaponType: string` → `weaponTypes: string[]`. Update
|
||||
DEFAULT_FILTERS, `validateFilterValue` (string array), `buildParams`
|
||||
(`weapon_types` CSV when non-empty), FilterSidebar (checkbox group replacing
|
||||
the `<select>`), ActiveChips (one chip per selected type, removable).
|
||||
- `WEAPON_TYPES` in constants becomes the seven skill categories
|
||||
`{value,label}`: heavy/light/finesse/two_handed/missile/war/void.
|
||||
- ResultsTable computes effective visible columns = saved set ∪
|
||||
(`itemType==='weapon'` ? {od_rating} : {}).
|
||||
|
||||
## Testing
|
||||
|
||||
- Backend: Go unit tests on the reworked `weaponTypeClause` (correct SQL per
|
||||
type: object_class, keys, mastery) and the `weapon_types` CSV → OR
|
||||
composition in `runSearch` (via the existing search-string test approach if
|
||||
present, else targeted clause tests). No DB-backed tests (consistent with the
|
||||
rest of search.go); live verification after deploy.
|
||||
- Frontend: `npm run build` (tsc) green; live browser verification.
|
||||
|
||||
## Verification (live)
|
||||
|
||||
- `weapon_types=heavy,two_handed` returns only skill-44 and skill-41 melee.
|
||||
- `weapon_types=missile` returns bows/xbows/thrown, no "Bowl".
|
||||
- `weapon_types=war` / `void` return the respective caster schools.
|
||||
- Multi-select in the UI; OD column visible automatically on weapon searches;
|
||||
chips add/remove; sort by OD works.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changing OD math or the best-values table.
|
||||
- Non-weapon filters, suitbuilder.
|
||||
- Splitting missile back into per-subtype UI checkboxes (Missile is one
|
||||
category; the backend still supports bow/crossbow/thrown individually).
|
||||
Loading…
Add table
Add a link
Reference in a new issue