Compare commits

...

7 commits

Author SHA1 Message Date
Erik
4bc51a1f48 feat(suitbuilder): Select All / Clear All toggle for Legendary Wards
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 21:24:52 +02:00
Erik
09bde83325 feat(suitbuilder): CD0/CD1/CD2 allowed-tier checkboxes (replace dead crit min/max)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 20:36:45 +02:00
Erik
75a735d589 feat(suitbuilder): apply CD-tier filter in loadItems (before domination)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 20:35:20 +02:00
Erik
7155055072 feat(suitbuilder): CD-tier filter helpers + tests; gate inventory-go build on go test
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 20:35:20 +02:00
Erik
593e99894f feat(suitbuilder): add allowed_crit_damage constraint field
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 20:35:20 +02:00
Erik
dfdfd41882 docs: implementation plan for suitbuilder CD-tier filter
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 20:30:26 +02:00
Erik
f7fd6415a9 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>
2026-06-25 20:14:54 +02:00
10 changed files with 849 additions and 37 deletions

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

View file

@ -0,0 +1,522 @@
# 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
<div class="filter-group">
<label>Crit Damage:</label>
<input type="number" id="minCritDmg" placeholder="Min" min="0" max="999">
<span>-</span>
<input type="number" id="maxCritDmg" placeholder="Max" min="0" max="999">
</div>
```
with:
```html
<div class="filter-group">
<label>Allowed Crit Damage:</label>
<label class="cd-toggle"><input type="checkbox" id="allowCD0" checked> CD0</label>
<label class="cd-toggle"><input type="checkbox" id="allowCD1" checked> CD1</label>
<label class="cd-toggle"><input type="checkbox" id="allowCD2" checked> CD2</label>
</div>
```
- [ ] **Step 2: Build `allowed_crit_damage` in `gatherConstraints()`**
In `static/suitbuilder.js`, replace these two lines:
```js
min_crit_damage: document.getElementById('minCritDmg').value || null,
max_crit_damage: document.getElementById('maxCritDmg').value || null,
```
with:
```js
allowed_crit_damage: [
document.getElementById('allowCD0').checked ? 0 : null,
document.getElementById('allowCD1').checked ? 1 : null,
document.getElementById('allowCD2').checked ? 2 : null,
].filter(v => v !== null),
```
- [ ] **Step 3: Drop the deleted field from validation**
In `validateConstraints()`, change:
```js
!constraints.min_armor && !constraints.min_crit_damage && !constraints.min_damage_rating) {
```
to:
```js
!constraints.min_armor && !constraints.min_damage_rating) {
```
- [ ] **Step 4: Send `allowed_crit_damage` in the request body**
In `streamOptimalSuits()`, replace these two lines:
```js
min_crit_damage: constraints.min_crit_damage ? parseInt(constraints.min_crit_damage) : null,
max_crit_damage: constraints.max_crit_damage ? parseInt(constraints.max_crit_damage) : null,
```
with:
```js
allowed_crit_damage: constraints.allowed_crit_damage,
```
- [ ] **Step 5: Style the toggles**
Append to `static/suitbuilder.css`:
```css
.cd-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
margin-right: 10px;
font-weight: normal;
cursor: pointer;
}
.cd-toggle input { margin: 0; }
```
- [ ] **Step 6: Commit**
```bash
git add static/suitbuilder.html static/suitbuilder.js static/suitbuilder.css
git commit -m "feat(suitbuilder): CD0/CD1/CD2 allowed-tier checkboxes (replace dead crit min/max)"
```
---
## Task 5: Deploy to the server & verify end-to-end
- [ ] **Step 1: Copy changed backend files to the host build context**
```bash
cd /c/Users/erikn/source/repos/dereth-workspace/MosswartOverlord
scp go-services/inventory-go/suit_model.go go-services/inventory-go/suit_cd.go \
go-services/inventory-go/suit_cd_test.go go-services/inventory-go/suit_solver.go \
go-services/inventory-go/Dockerfile \
erik@overlord.snakedesert.se:/home/erik/MosswartOverlord/go-services/inventory-go/
```
- [ ] **Step 2: Build the image (runs `go test` as part of the build)**
```bash
ssh erik@overlord.snakedesert.se 'cd /home/erik/MosswartOverlord && \
docker compose -f docker-compose.yml -f go-services/docker-compose.go.yml \
build inventory-go'
```
Expected: build succeeds; the `RUN go test ./...` layer passes.
- [ ] **Step 3: Recreate the container with the cutover override**
```bash
ssh erik@overlord.snakedesert.se 'cd /home/erik/MosswartOverlord && \
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'
```
Expected: `inventory-go` recreated; `docker ps` shows it healthy on :8772.
- [ ] **Step 4: Copy the changed static files (bind-mounted; live immediately)**
```bash
scp static/suitbuilder.html static/suitbuilder.js static/suitbuilder.css \
erik@overlord.snakedesert.se:/home/erik/MosswartOverlord/static/
```
- [ ] **Step 5: Verify default search is unchanged + CD1-only works**
Manual, in the browser at the suitbuilder page (hard-refresh to bust cache):
- With **all three CD boxes checked**, run a search (a primary set + a character with armor). Confirm results look like before.
- Check **only CD1**, run the same search. Confirm in the Network tab the request body has `"allowed_crit_damage":[1]`, and every armor piece in the returned suits shows **CD1** (jewelry/clothing unaffected; slots with no CD1 piece may be empty).
- Check **CD1 + CD0**, confirm no CD2 armor appears and CD1 is preferred where available.
---
## Task 6: Finalize the local feature commit
- [ ] **Step 1: Confirm the branch state**
```bash
cd /c/Users/erikn/source/repos/dereth-workspace/MosswartOverlord
git log --oneline -6
git status
```
Expected: clean tree; the spec + plan + Tasks 1-4 feature commits on `suitbuilder-cd-tier-filter`.
---
## Phase 2: Reconcile host git + push to Gitea (separate, after the feature is verified live)
> ⚠ Pushing to the **public** Gitea is outward-facing and partly irreversible. Investigate state and decide a strategy BEFORE any push; surface the chosen strategy to the user first. Never `git add` the host's `.env` (secrets).
- [ ] **Step 1: Establish the true state of all three gits**
- Local `MosswartOverlord` HEAD (`9911edbf`, has go-services committed).
- Host `/home/erik/MosswartOverlord` HEAD (`6a0bb9fe`, go-services untracked, has server-only commits like rickroll/midsummer).
- Gitea `origin/master` — fetch and inspect; determine whether local's go-services history and/or the host's server-only commits are already on the remote.
- [ ] **Step 2: Decide a reconciliation strategy** (depends on Step 1 findings):
- Get the host's server-only commits into the canonical local history (cherry-pick or merge), and get the local go-services history onto the host — so a single `master` contains both, with this feature on top.
- Plan must avoid clobbering the host's untracked `.env`/backups and avoid a destructive force-push unless explicitly chosen.
- [ ] **Step 3: Execute the chosen reconciliation, then `git pull` on the host** so the host runs tracked code, and push the unified `master` to Gitea. Confirm `docker compose build` still uses the now-tracked go-services.
(Phase 2 steps are deliberately high-level — the exact git commands depend on Step 1's findings and a strategy choice. Do not pre-bake destructive commands.)

View file

@ -2,6 +2,7 @@ FROM golang:1.25-bookworm AS build
WORKDIR /src
COPY . .
RUN go mod tidy
RUN go test ./...
ARG BUILD_VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath -ldflags "-s -w -X main.buildVersion=${BUILD_VERSION}" -o /out/inventory-go .

View file

@ -0,0 +1,74 @@
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
}

View file

@ -0,0 +1,82 @@
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)
}
}
}

View file

@ -401,32 +401,31 @@ type LockedSlotInfo struct {
}
type SearchConstraints struct {
Characters []string `json:"characters"`
PrimarySet int `json:"primary_set"`
SecondarySet int `json:"secondary_set"`
RequiredSpells []string `json:"required_spells"`
LockedSlots map[string]LockedSlotInfo `json:"locked_slots"`
IncludeEquipped bool `json:"include_equipped"`
IncludeInventory bool `json:"include_inventory"`
MinArmor *int `json:"min_armor"`
MaxArmor *int `json:"max_armor"`
MinCritDamage *int `json:"min_crit_damage"`
MaxCritDamage *int `json:"max_crit_damage"`
MinDamageRating *int `json:"min_damage_rating"`
MaxDamageRating *int `json:"max_damage_rating"`
ScoringWeights *ScoringWeights `json:"scoring_weights"`
MaxResults int `json:"max_results"`
SearchTimeout int `json:"search_timeout"`
Characters []string `json:"characters"`
PrimarySet int `json:"primary_set"`
SecondarySet int `json:"secondary_set"`
RequiredSpells []string `json:"required_spells"`
LockedSlots map[string]LockedSlotInfo `json:"locked_slots"`
IncludeEquipped bool `json:"include_equipped"`
IncludeInventory bool `json:"include_inventory"`
MinArmor *int `json:"min_armor"`
MaxArmor *int `json:"max_armor"`
AllowedCritDamage []int `json:"allowed_crit_damage"`
MinDamageRating *int `json:"min_damage_rating"`
MaxDamageRating *int `json:"max_damage_rating"`
ScoringWeights *ScoringWeights `json:"scoring_weights"`
MaxResults int `json:"max_results"`
SearchTimeout int `json:"search_timeout"`
}
// --- CompletedSuit (suitbuilder.py:446) ---
type CompletedSuit struct {
Items map[string]*SuitItem
Score int
TotalArmor int
TotalRatings map[string]int
SetCounts map[int]int
Items map[string]*SuitItem
Score int
TotalArmor int
TotalRatings map[string]int
SetCounts map[int]int
FulfilledSpells []string
MissingSpells []string
}
@ -500,11 +499,11 @@ func (c *CompletedSuit) toDict() map[string]any {
"secondary_set_count": 0,
"spell_coverage": len(c.FulfilledSpells),
},
"missing": c.MissingSpells,
"notes": []any{},
"missing": c.MissingSpells,
"notes": []any{},
"transfer_summary": map[string]any{
"total_items": totalItems,
"from_characters": transferByChar,
"total_items": totalItems,
"from_characters": transferByChar,
},
"instructions": instructions,
}

View file

@ -25,6 +25,7 @@ type Solver struct {
bestSuitItemCount int
highestArmorCount int
armorBucketsItems int
allowedCD map[int]bool // nil == no CD filter (default / all tiers)
lockedSetCounts map[int]int
lockedSpells map[string]bool
@ -53,6 +54,7 @@ func newSolver(s *Server, c SearchConstraints, emit func(string, map[string]any)
}
// Required spells register first, so they always get the low bits.
sv.neededSpellBitmap = sv.spellIndex.getBitmap(c.RequiredSpells)
sv.allowedCD = allowedCritSet(c.AllowedCritDamage)
return sv
}
@ -259,6 +261,10 @@ func (sv *Solver) loadItems(ctx context.Context) ([]*SuitItem, error) {
}
}
// 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)
filtered := removeSurpassedItems(items)
jewelryFallback := map[string]bool{"Ring": true, "Bracelet": true, "Jewelry": true, "Necklace": true, "Amulet": true}

View file

@ -1529,4 +1529,26 @@ body {
color: #95a5a6;
font-size: 10px;
margin-left: auto;
}
}
.cd-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
margin-right: 10px;
font-weight: normal;
cursor: pointer;
}
.cd-toggle input { margin: 0; }
.select-all-btn {
margin-left: 8px;
padding: 2px 8px;
font-size: 11px;
font-weight: normal;
cursor: pointer;
border: 1px solid #ccc;
border-radius: 3px;
background: #f0f0f0;
}
.select-all-btn:hover { background: #e0e0e0; }

View file

@ -51,10 +51,10 @@
<input type="number" id="maxArmor" placeholder="Max" min="0" max="9999">
</div>
<div class="filter-group">
<label>Crit Damage:</label>
<input type="number" id="minCritDmg" placeholder="Min" min="0" max="999">
<span>-</span>
<input type="number" id="maxCritDmg" placeholder="Max" min="0" max="999">
<label>Allowed Crit Damage:</label>
<label class="cd-toggle"><input type="checkbox" id="allowCD0" checked> CD0</label>
<label class="cd-toggle"><input type="checkbox" id="allowCD1" checked> CD1</label>
<label class="cd-toggle"><input type="checkbox" id="allowCD2" checked> CD2</label>
</div>
<div class="filter-group">
<label>Damage Rating:</label>
@ -245,7 +245,7 @@
<!-- Legendary Wards -->
<div class="constraint-section">
<h4>Legendary Wards</h4>
<h4>Legendary Wards <button type="button" id="wardsSelectAll" class="select-all-btn">Select All</button></h4>
<div class="cantrips-grid">
<div class="checkbox-item">
<input type="checkbox" id="protection_flame" value="Legendary Flame Ward">

View file

@ -152,13 +152,32 @@ function setupEventListeners() {
// Main action buttons
document.getElementById('searchSuits').addEventListener('click', performSuitSearch);
document.getElementById('clearAll').addEventListener('click', clearAllConstraints);
document.getElementById('wardsSelectAll').addEventListener('click', toggleAllWards);
// Slot control buttons
document.getElementById('lockSelectedSlots').addEventListener('click', lockSelectedSlots);
document.getElementById('clearAllLocks').addEventListener('click', clearAllLocks);
document.getElementById('resetSlotView').addEventListener('click', resetSlotView);
}
// Legendary Ward checkboxes (toggled together by the "Select All" button).
const WARD_IDS = [
'protection_flame', 'protection_frost', 'protection_acid', 'protection_storm',
'protection_slashing', 'protection_piercing', 'protection_bludgeoning', 'protection_armor'
];
/**
* Toggle all Legendary Ward checkboxes. If every ward is already checked,
* clears them; otherwise selects all. The button label tracks the state.
*/
function toggleAllWards() {
const boxes = WARD_IDS.map(id => document.getElementById(id)).filter(Boolean);
const allChecked = boxes.every(cb => cb.checked);
boxes.forEach(cb => { cb.checked = !allChecked; });
const btn = document.getElementById('wardsSelectAll');
if (btn) btn.textContent = allChecked ? 'Select All' : 'Clear All';
}
/**
* Setup slot interaction functionality
*/
@ -307,8 +326,11 @@ function gatherConstraints() {
characters: selectedCharacters,
min_armor: document.getElementById('minArmor').value || null,
max_armor: document.getElementById('maxArmor').value || null,
min_crit_damage: document.getElementById('minCritDmg').value || null,
max_crit_damage: document.getElementById('maxCritDmg').value || null,
allowed_crit_damage: [
document.getElementById('allowCD0').checked ? 0 : null,
document.getElementById('allowCD1').checked ? 1 : null,
document.getElementById('allowCD2').checked ? 2 : null,
].filter(v => v !== null),
min_damage_rating: document.getElementById('minDmgRating').value || null,
max_damage_rating: document.getElementById('maxDmgRating').value || null,
@ -357,7 +379,7 @@ function validateConstraints(constraints) {
if (!constraints.primary_set && !constraints.secondary_set &&
constraints.legendary_cantrips.length === 0 &&
constraints.protection_spells.length === 0 &&
!constraints.min_armor && !constraints.min_crit_damage && !constraints.min_damage_rating) {
!constraints.min_armor && !constraints.min_damage_rating) {
alert('Please specify at least one constraint (equipment sets, cantrips, legendary wards, or rating minimums).');
return false;
}
@ -383,8 +405,7 @@ async function streamOptimalSuits(constraints) {
include_inventory: constraints.include_inventory,
min_armor: constraints.min_armor ? parseInt(constraints.min_armor) : null,
max_armor: constraints.max_armor ? parseInt(constraints.max_armor) : null,
min_crit_damage: constraints.min_crit_damage ? parseInt(constraints.min_crit_damage) : null,
max_crit_damage: constraints.max_crit_damage ? parseInt(constraints.max_crit_damage) : null,
allowed_crit_damage: constraints.allowed_crit_damage,
min_damage_rating: constraints.min_damage_rating ? parseInt(constraints.min_damage_rating) : null,
max_damage_rating: constraints.max_damage_rating ? parseInt(constraints.max_damage_rating) : null,
max_results: 10,