diff --git a/docs/plans/2026-06-25-suitbuilder-cd-tier-filter-design.md b/docs/plans/2026-06-25-suitbuilder-cd-tier-filter-design.md deleted file mode 100644 index a17585c7..00000000 --- a/docs/plans/2026-06-25-suitbuilder-cd-tier-filter-design.md +++ /dev/null @@ -1,85 +0,0 @@ -# Suitbuilder CD-tier filter — design - -**Date:** 2026-06-25 -**Status:** Approved (pending spec review) -**Scope:** Live Go suitbuilder only (`go-services/inventory-go/`) + the static suitbuilder page (`static/suitbuilder.{html,js}`). **No changes** to the frozen `inventory-service/suitbuilder.py` (legacy rollback reference). - -## Goal - -Let the user restrict which **crit-damage tiers** (CD0 / CD1 / CD2) are allowed on **armor** pieces in a suit search, so they can build, e.g., all-CD1 suits or CD1/CD0-only suits. Among whatever tiers are allowed, the solver still prefers the highest (existing behavior) — so this is fundamentally a **filter**, not a scoring change. - -## Background — current state - -- The live suitbuilder is the Go solver (`suit_solver.go` / `suit_model.go` / `suit_http.go`), reached via browser → tracker `/inv/suitbuilder/search` → inventory-go `/suitbuilder/search`. Python is frozen on `python-legacy`. -- There is **no crit-damage filtering today.** CD0/CD1/CD2 armor all flows into the search. The only thing distinguishing tiers is scoring (`CritDamage1: +10`, `CritDamage2: +20`) and the CD-descending armor sort — which is why CD2 always wins. -- The UI already shows **Crit Damage min/max** number inputs (`suitbuilder.html:54-57`), and the JS already sends `min_crit_damage`/`max_crit_damage` (`suitbuilder.js:310-311, 386-387`). The Go solver receives them into `SearchConstraints.MinCritDamage`/`MaxCritDamage` but **never references them** — dead, half-wired scaffold. This feature replaces that dead control. - -## Behavior contract - -- A new per-search filter selects which CD tiers are **allowed on armor**: independent CD0 / CD1 / CD2 toggles. -- **A checked tier = "allowed."** "Prefer higher, fall back lower" happens automatically among the allowed tiers via the existing scoring/sort — no scoring change. -- **Default = all three allowed.** Because the solver prefers the highest allowed tier, the default naturally leads with CD2 — i.e. identical to today's behavior. This is the "default CD2" state. -- **Empty / none-selected = treated as the default** (all allowed). A search can never be forced into an armorless state by this control. -- **Jewelry and clothing are never filtered by CD** — they are categorized separately in `loadItems` and the filter only touches armor. -- **Tier mapping** (handles rare high-crit gear): `CD0 = rating ≤ 0`, `CD1 = rating == 1`, **`CD2 = rating ≥ 2`**. A CD3+ gear piece counts as CD2 and is not silently dropped. - -### Worked examples - -| Allowed set | Result | -|---|---| -| `{0,1,2}` (default / empty) | Unchanged from today — prefer CD2, fall back CD1, CD0 | -| `{0,1}` | No CD2 armor; prefer CD1, fall back CD0 | -| `{1}` | All-CD1 suits; a slot with no CD1 piece is left empty | -| `{1,2}` | No CD0 armor; prefer CD2, fall back CD1 | - -## Backend design — `go-services/inventory-go` - -### 1. Constraint field (`suit_model.go`) -- Add `AllowedCritDamage []int \`json:"allowed_crit_damage"\`` to `SearchConstraints`. -- **Remove** the dead `MinCritDamage *int` / `MaxCritDamage *int` fields (never wired; their UI is being replaced). Leave the other unrelated dead fields (`MinArmor`/`MaxArmor`/`MinDamageRating`/`MaxDamageRating`) untouched — out of scope. - -### 2. Precompute the allowed set (`newSolver`, `suit_solver.go`) -- Build `allowedCD map[int]bool` by normalizing each value in `AllowedCritDamage` to a tier in `{0,1,2}` (clamp ≥2 to 2, ≤0 to 0). -- **Filter inactive** (no-op) when the resulting set is empty **or** already contains all of `{0,1,2}`. This makes "all checked", "none checked", and "field absent" all mean *no filter* — and guarantees the default path is byte-identical to current output. - -### 3. Apply the filter in `loadItems` (`suit_solver.go`) -- **Location & ordering are load-bearing:** filter armor items **after** the raw `items` slice is built (~line 254) and **before `removeSurpassedItems`** (line 262). If the CD filter ran after domination, a CD2 piece could dominate and remove an allowed CD1 piece, which we'd then exclude — leaving the slot needlessly empty. Filtering first keeps domination confined to allowed items. -- An item is "armor" iff its slot matches `armorSlotSet` (including comma-joined multi-coverage slots like `"Chest, Abdomen"`). Factor a small package-level helper `isArmorSlot(slot string) bool` (mirrors the existing `matches(it.Slot, armorSlotSet, nil)` logic) so it can be used both here and in the existing categorization pass. Non-armor items (jewelry/clothing/unknown) are never dropped by this filter. -- When the filter is active, drop armor items whose normalized tier ∉ `allowedCD`. -- Tailored/reduced armor inherits its CD from the origin piece (already filtered upstream), so reductions of excluded pieces never appear — no extra handling needed. - -### Regression safety -- The default (no `allowed_crit_damage`, or all three) path must produce **identical** output to the current solver. The no-op guard in step 2 ensures this. - -## Frontend design — `static/suitbuilder.{html,js}` - -(Vanilla static page served from the bind-mounted `static/` — no build step, no container restart.) - -### 1. `suitbuilder.html` (~lines 53-58) -- Replace the `Crit Damage [Min]-[Max]` number inputs (`#minCritDmg`, `#maxCritDmg`) with three checkboxes inside the existing `filter-group`: `#allowCD0`, `#allowCD1`, `#allowCD2`, labelled CD0 / CD1 / CD2, **all `checked` by default.** Keep the surrounding `filter-row`/`filter-group`/`constraint-section` layout. - -### 2. `suitbuilder.js` -- **`gatherConstraints()` (lines 310-311):** remove the `min_crit_damage`/`max_crit_damage` reads; add `allowed_crit_damage`, an array of the checked tiers, e.g. `[0,1,2]`. -- **`validateConstraints()` (line 360):** remove the now-deleted `!constraints.min_crit_damage` term from the "at least one constraint" check. (A CD restriction is not a valid *standalone* search — armor is only loaded for the chosen primary/secondary set, so a set/cantrip/ward/rating-min is still required. The CD filter is a refinement on top.) -- **`streamOptimalSuits()` (lines 386-387):** remove `min_crit_damage`/`max_crit_damage` from `requestBody`; add `allowed_crit_damage: constraints.allowed_crit_damage`. - -## Testing - -- **Regression (Go):** a default search (no `allowed_crit_damage`) yields output identical to baseline — assert the no-op path. Where existing suitbuilder validation/golden harnesses exist (`compare/`), the default case must stay byte-identical; filtered cases are intentionally Python-divergent and are validated by the new tests below, not against Python. -- **New unit test (Go):** - - `allowed=[1]` ⇒ every armor piece in every returned suit has tier CD1; jewelry/clothing still present. - - `allowed=[0,1]` ⇒ no CD2 armor appears in any suit. - - `allowed=[1,2]` ⇒ no CD0 armor appears. - - `allowed=[]` / `[0,1,2]` ⇒ identical to baseline. -- **Manual:** on the server, run a real CD1-only search and confirm all-CD1 armor and sane fallback/empty-slot behavior. - -## Deploy - -- **Backend:** rebuild `inventory-go` on the server (sync `go-services/`, build, recreate with the cutover override) — see MosswartOverlord CLAUDE.md "Go services — build, deploy, gotchas". -- **Frontend:** edit `static/suitbuilder.{html,js}`; a normal `git pull` on the host picks them up via the bind mount — no build, no restart. - -## Out of scope - -- `inventory-service/suitbuilder.py` (frozen/legacy) — intentionally left to diverge. -- The other dead constraint fields (`min/max_armor`, `min/max_damage_rating`) — separate follow-up if wanted. -- No scoring-weight changes; no new scoring knobs. diff --git a/docs/plans/2026-06-25-suitbuilder-cd-tier-filter-plan.md b/docs/plans/2026-06-25-suitbuilder-cd-tier-filter-plan.md deleted file mode 100644 index c9336f7d..00000000 --- a/docs/plans/2026-06-25-suitbuilder-cd-tier-filter-plan.md +++ /dev/null @@ -1,522 +0,0 @@ -# Suitbuilder CD-tier filter — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Let a suitbuilder search restrict which crit-damage tiers (CD0/CD1/CD2) are allowed on armor pieces, so the user can build e.g. all-CD1 suits — while the default (all allowed) stays byte-identical to today. - -**Architecture:** Add an `allowed_crit_damage` constraint. In the live Go solver (`inventory-go`), drop armor items whose CD tier isn't allowed during item loading, before the domination pre-filter. "Prefer highest allowed tier" needs no new code — it falls out of the existing scoring and CD-descending armor sort. Frontend swaps the dead Crit-Damage min/max inputs for three CD checkboxes. - -**Tech Stack:** Go 1.25 (`go-services/inventory-go`), vanilla JS/HTML (`static/suitbuilder.*`), Docker on the server (no local Go toolchain). - -**Spec:** `docs/plans/2026-06-25-suitbuilder-cd-tier-filter-design.md` - ---- - -## Conventions for this plan - -- **Source-of-truth edits** happen in the local repo at `C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord`, on branch `suitbuilder-cd-tier-filter`. Commit there. -- **No local Go toolchain.** Build & test run on the server (`overlord.snakedesert.se`) inside Docker. -- **Fast unit-test loop** (run from the local MosswartOverlord dir after copying changed files to the host — see Task 6 for the copy command): - ```bash - ssh erik@overlord.snakedesert.se "docker run --rm \ - -v /home/erik/MosswartOverlord/go-services/inventory-go:/src -w /src \ - golang:1.25-bookworm sh -c 'go mod tidy >/dev/null 2>&1 && go test ./... -v'" - ``` - (Mounts the host's inventory-go source into a throwaway golang container. `go mod tidy` writes go.sum into that untracked dir — harmless.) -- The live container is `inventory-go` (image `inventory-go:local`, `127.0.0.1:8772`). - ---- - -## File structure - -- `go-services/inventory-go/suit_model.go` — **modify**: constraint field. -- `go-services/inventory-go/suit_cd.go` — **create**: pure CD-tier helpers (one responsibility, DB-free, unit-testable). -- `go-services/inventory-go/suit_cd_test.go` — **create**: unit tests for the helpers. -- `go-services/inventory-go/suit_solver.go` — **modify**: solver field + wire filter into `loadItems`. -- `go-services/inventory-go/Dockerfile` — **modify**: add a `go test` build gate (mirrors tracker-go). -- `static/suitbuilder.html` — **modify**: CD checkboxes replace min/max inputs. -- `static/suitbuilder.js` — **modify**: gather/validate/send `allowed_crit_damage`. -- `static/suitbuilder.css` — **modify**: minor styling for the toggles. - ---- - -## Task 1: Add the `allowed_crit_damage` constraint field - -**Files:** Modify `go-services/inventory-go/suit_model.go` - -- [ ] **Step 1: Replace the dead crit min/max fields** - -In `SearchConstraints`, replace these two lines: - -```go - MinCritDamage *int `json:"min_crit_damage"` - MaxCritDamage *int `json:"max_crit_damage"` -``` - -with: - -```go - AllowedCritDamage []int `json:"allowed_crit_damage"` -``` - -(The `Min/MaxCritDamage` fields were never referenced by the solver — confirmed by grep. The other `Min/Max*` fields stay untouched.) - -- [ ] **Step 2: Commit** - -```bash -cd /c/Users/erikn/source/repos/dereth-workspace/MosswartOverlord -git add go-services/inventory-go/suit_model.go -git commit -m "feat(suitbuilder): add allowed_crit_damage constraint field" -``` - ---- - -## Task 2: CD-tier helpers + unit tests (TDD) - -**Files:** -- Create: `go-services/inventory-go/suit_cd.go` -- Create: `go-services/inventory-go/suit_cd_test.go` - -- [ ] **Step 1: Write the failing tests** - -Create `go-services/inventory-go/suit_cd_test.go`: - -```go -package main - -import "testing" - -func TestCritTier(t *testing.T) { - cases := []struct { - rating, want int - }{{-1, 0}, {0, 0}, {1, 1}, {2, 2}, {3, 2}, {5, 2}} - for _, c := range cases { - if got := critTier(c.rating); got != c.want { - t.Errorf("critTier(%d) = %d, want %d", c.rating, got, c.want) - } - } -} - -func TestAllowedCritSet(t *testing.T) { - for _, vals := range [][]int{nil, {}, {0, 1, 2}, {0, 1, 3}} { - if allowedCritSet(vals) != nil { - t.Errorf("allowedCritSet(%v) should be nil (inactive)", vals) - } - } - if s := allowedCritSet([]int{1}); s == nil || !s[1] || s[0] || s[2] { - t.Errorf("allowedCritSet({1}) = %v, want only tier 1", s) - } - if s := allowedCritSet([]int{0, 1}); s == nil || !s[0] || !s[1] || s[2] { - t.Errorf("allowedCritSet({0,1}) = %v, want tiers 0,1", s) - } - if s := allowedCritSet([]int{3}); s == nil || !s[2] || s[0] || s[1] { - t.Errorf("allowedCritSet({3}) = %v, want only tier 2 (normalized)", s) - } -} - -func TestIsArmorSlot(t *testing.T) { - for _, s := range []string{"Chest", "Head", "Feet", "Chest, Abdomen", "Upper Legs, Lower Legs"} { - if !isArmorSlot(s) { - t.Errorf("isArmorSlot(%q) = false, want true", s) - } - } - for _, s := range []string{"Neck", "Left Ring", "Left Wrist", "Trinket", "Shirt", "Pants", "Unknown", ""} { - if isArmorSlot(s) { - t.Errorf("isArmorSlot(%q) = true, want false", s) - } - } -} - -func cdItem(slot string, cd int) *SuitItem { - return &SuitItem{Slot: slot, Ratings: map[string]int{"crit_damage_rating": cd}} -} - -func TestFilterArmorByCD(t *testing.T) { - items := []*SuitItem{ - cdItem("Chest", 0), cdItem("Head", 1), cdItem("Feet", 2), - cdItem("Chest, Abdomen", 2), // multi-coverage armor, CD2 - cdItem("Neck", 0), // jewelry — never filtered - cdItem("Shirt", 0), // clothing — never filtered - } - - if got := filterArmorByCD(items, nil); len(got) != len(items) { - t.Errorf("nil filter dropped items: got %d, want %d", len(got), len(items)) - } - - got := filterArmorByCD(items, map[int]bool{1: true}) - keep := map[string]bool{"Head": true, "Neck": true, "Shirt": true} - if len(got) != 3 { - t.Fatalf("allowed{1}: got %d items, want 3", len(got)) - } - for _, it := range got { - if !keep[it.Slot] { - t.Errorf("allowed{1}: unexpected slot %q survived", it.Slot) - } - } - - got = filterArmorByCD(items, map[int]bool{0: true, 1: true}) - if len(got) != 4 { // Chest(0), Head(1), Neck, Shirt - t.Errorf("allowed{0,1}: got %d items, want 4", len(got)) - } - for _, it := range got { - if isArmorSlot(it.Slot) && it.Ratings["crit_damage_rating"] >= 2 { - t.Errorf("allowed{0,1}: CD2 armor %q should have been dropped", it.Slot) - } - } -} -``` - -- [ ] **Step 2: Run the tests to confirm they fail to build** - -Copy only the test file to the host (the implementation doesn't exist yet): - -```bash -cd /c/Users/erikn/source/repos/dereth-workspace/MosswartOverlord -scp go-services/inventory-go/suit_cd_test.go \ - erik@overlord.snakedesert.se:/home/erik/MosswartOverlord/go-services/inventory-go/ -``` - -Then run the fast test loop (see Conventions). -Expected: FAIL — `undefined: critTier`, `allowedCritSet`, `isArmorSlot`, `filterArmorByCD`. - -- [ ] **Step 3: Write the implementation** - -Create `go-services/inventory-go/suit_cd.go`: - -```go -package main - -import "strings" - -// CD-tier filtering for the suitbuilder. The allowed_crit_damage constraint -// restricts which crit-damage tiers are permitted on ARMOR pieces; jewelry and -// clothing are never affected. "Prefer the highest allowed tier" is NOT done -// here — it falls out of the existing scoring (CritDamage2 > CritDamage1) and -// the CD-descending armor sort once disallowed tiers are removed. - -// critTier normalizes a raw crit_damage_rating into a tier in {0,1,2}. Rare -// high-crit gear (rating >= 2, including 3+) collapses to tier 2 so it counts -// as "CD2" rather than being silently excluded. -func critTier(rating int) int { - switch { - case rating <= 0: - return 0 - case rating == 1: - return 1 - default: - return 2 - } -} - -// isArmorSlot reports whether a slot name denotes an armor coverage slot, -// including comma-joined multi-coverage slots like "Chest, Abdomen". -func isArmorSlot(slot string) bool { - if armorSlotSet[slot] { - return true - } - if strings.Contains(slot, ", ") { - for _, p := range strings.Split(slot, ", ") { - if armorSlotSet[strings.TrimSpace(p)] { - return true - } - } - } - return false -} - -// allowedCritSet normalizes the constraint's allowed crit-damage tiers into a -// set, or returns nil when the filter is INACTIVE: no values, or all three -// tiers {0,1,2} present (== default). A nil result means "no filter" and keeps -// the default search path byte-identical to the unfiltered solver. -func allowedCritSet(vals []int) map[int]bool { - if len(vals) == 0 { - return nil - } - set := map[int]bool{} - for _, v := range vals { - set[critTier(v)] = true - } - if set[0] && set[1] && set[2] { - return nil - } - return set -} - -// filterArmorByCD drops armor items whose crit-damage tier is not in allowed. -// Non-armor items (jewelry, clothing, unknown) always pass through. When -// allowed is nil the input is returned unchanged. -func filterArmorByCD(items []*SuitItem, allowed map[int]bool) []*SuitItem { - if allowed == nil { - return items - } - out := make([]*SuitItem, 0, len(items)) - for _, it := range items { - if isArmorSlot(it.Slot) && !allowed[critTier(it.Ratings["crit_damage_rating"])] { - continue - } - out = append(out, it) - } - return out -} -``` - -- [ ] **Step 4: Run the tests to confirm they pass** - -```bash -scp go-services/inventory-go/suit_cd.go \ - erik@overlord.snakedesert.se:/home/erik/MosswartOverlord/go-services/inventory-go/ -``` - -Run the fast test loop. Expected: PASS (`ok` — 4 tests). - -- [ ] **Step 5: Add the `go test` build gate to the Dockerfile** - -In `go-services/inventory-go/Dockerfile`, after `RUN go mod tidy` add: - -```dockerfile -RUN go test ./... -``` - -(Mirrors `tracker-go/Dockerfile`; from now on every image build runs the tests.) - -- [ ] **Step 6: Commit** - -```bash -git add go-services/inventory-go/suit_cd.go go-services/inventory-go/suit_cd_test.go go-services/inventory-go/Dockerfile -git commit -m "feat(suitbuilder): CD-tier filter helpers + tests; gate inventory-go build on go test" -``` - ---- - -## Task 3: Wire the filter into the solver - -**Files:** Modify `go-services/inventory-go/suit_solver.go` - -- [ ] **Step 1: Add the precomputed set to the Solver struct** - -In the `Solver` struct, after `armorBucketsItems int`, add: - -```go - allowedCD map[int]bool // nil == no CD filter (default / all tiers) -``` - -- [ ] **Step 2: Populate it in `newSolver`** - -In `newSolver`, after the line `sv.neededSpellBitmap = sv.spellIndex.getBitmap(c.RequiredSpells)`, add: - -```go - sv.allowedCD = allowedCritSet(c.AllowedCritDamage) -``` - -- [ ] **Step 3: Apply the filter in `loadItems` before domination** - -In `loadItems`, find: - -```go - filtered := removeSurpassedItems(items) -``` - -and immediately ABOVE it insert: - -```go - // Drop armor whose CD tier is disallowed BEFORE domination, so a CD2 piece - // can't surpass-and-remove an allowed CD1 piece we'd then exclude. - items = filterArmorByCD(items, sv.allowedCD) -``` - -- [ ] **Step 4: Verify it still builds and all tests pass** - -Copy the changed solver file and run the test loop: - -```bash -scp go-services/inventory-go/suit_solver.go \ - erik@overlord.snakedesert.se:/home/erik/MosswartOverlord/go-services/inventory-go/ -``` - -Run the fast test loop. Expected: PASS, and the package compiles (the wiring type-checks; `go test` builds the whole `main` package). - -- [ ] **Step 5: Commit** - -```bash -git add go-services/inventory-go/suit_solver.go -git commit -m "feat(suitbuilder): apply CD-tier filter in loadItems (before domination)" -``` - ---- - -## Task 4: Frontend — CD checkboxes - -**Files:** Modify `static/suitbuilder.html`, `static/suitbuilder.js`, `static/suitbuilder.css` - -- [ ] **Step 1: Replace the Crit Damage inputs with checkboxes** - -In `static/suitbuilder.html`, replace this block: - -```html -