docs: design for suitbuilder CD-tier filter (CD0/CD1/CD2 toggles)
Per-search filter selecting which crit-damage tiers are allowed on armor. Default (all allowed) is byte-identical to current behavior; "prefer highest allowed tier" falls out of existing scoring. Go-only (live solver); Python copy left frozen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9911edbfa8
commit
f7fd6415a9
1 changed files with 85 additions and 0 deletions
85
docs/plans/2026-06-25-suitbuilder-cd-tier-filter-design.md
Normal file
85
docs/plans/2026-06-25-suitbuilder-cd-tier-filter-design.md
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
# 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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue