feat(inventory-go): port the suitbuilder solver (/suitbuilder/search) — validated

Full Go port of suitbuilder.py's ConstraintSatisfactionSolver (the LIVE solver
behind the suitbuilder UI; main.py's /optimize/suits is legacy/unused):

- suit_model.go: CoverageMask + reductions, SuitItem/ItemBucket/SuitState,
  SpellBitmapIndex, ScoringWeights, SearchConstraints, CompletedSuit.to_dict,
  ItemPreFilter, set name<->id maps. Every sort carries (character_name, name)
  tiebreakers for deterministic results.
- suit_solver.go: the 5-phase pipeline — load_items (fed in-process by the Go
  /search/items), create_buckets (+multi-slot/generic-jewelry expansion),
  apply_reduction_options, sort_buckets, and the depth-first recursive_search
  with both Mag-SuitBuilder pruning rules, can_add_item constraints (set limits,
  jewelry spell contribution, strict spell mode), scoring, and finalize.
- suit_http.go: POST /suitbuilder/search (SSE: phase/log/suit/progress/complete),
  GET /suitbuilder/characters, GET /suitbuilder/sets.
- search.go: refactor handleSearchItems -> shared runSearch (the solver reuses
  it so both see identical rows); emit slot_name (get_sophisticated_slot_options
  + translate_equipment_slot); fix the trinket slot_names clause to exclude
  %bracelet% (matches Python).
- slotname.go: the EquipMask-based slot translation, loaded from the enum DB.

Validation: 9/9 scenarios stream byte-identical suits vs the Python service on
production data (no-spell, multi-character, locked slots with/without spells,
spell constraints, alternate set pairs, primary-only). ~45x faster than Python.

Three subtle bugs found and fixed during validation:
- slot_name is load-bearing, not display: jewelry's computed_slot_name is empty,
  so load_items falls back to slot_name to bucket rings/neck/wrists/trinket.
- Python scoring uses floor division (total_armor // 100); total_armor goes
  negative (non-armor items carry armor_level -1) so Go's truncation was +1 off.
- the trinket fetch must exclude bracelets or they duplicate the Wrist buckets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-24 14:03:59 +02:00
parent 2473b80519
commit 57f53ff36b
6 changed files with 1806 additions and 18 deletions

View file

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@ -57,6 +58,7 @@ SELECT DISTINCT
COALESCE(enh.tinks, -1) AS tinks,
COALESCE(enh.item_set, '') AS item_set,
COALESCE((rd.int_values->>'218103821')::int, 0) AS coverage_mask,
COALESCE((rd.int_values->>'218103822')::int, 0) AS equippable_slots,
CASE
WHEN rd.original_json IS NOT NULL
AND rd.original_json->'IntValues'->>'218103822' IS NOT NULL
@ -142,7 +144,18 @@ func (b *argBuilder) add(v any) string {
}
func (s *Server) handleSearchItems(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
res, err := s.runSearch(r.Context(), r.URL.Query())
if err != nil {
s.dbErr(w, "search/items", err)
return
}
writeJSON(w, http.StatusOK, res)
}
// runSearch executes /search/items and returns the response object (items +
// pagination, or an {error,...} object for invalid params). Shared by the HTTP
// handler and the suitbuilder solver's load_items, so both see identical rows.
func (s *Server) runSearch(ctx context.Context, q url.Values) (map[string]any, error) {
ab := &argBuilder{}
var conds []string
@ -152,8 +165,7 @@ func (s *Server) handleSearchItems(w http.ResponseWriter, r *http.Request) {
} else if cs := q.Get("characters"); cs != "" {
names := splitNonEmpty(cs)
if len(names) == 0 {
writeJSON(w, http.StatusOK, map[string]any{"error": "Empty characters list provided", "items": []any{}, "total_count": 0})
return
return map[string]any{"error": "Empty characters list provided", "items": []any{}, "total_count": 0}, nil
}
ph := make([]string, len(names))
for i, n := range names {
@ -161,8 +173,7 @@ func (s *Server) handleSearchItems(w http.ResponseWriter, r *http.Request) {
}
conds = append(conds, "character_name IN ("+strings.Join(ph, ", ")+")")
} else if !qBool(q, "include_all_characters") {
writeJSON(w, http.StatusOK, map[string]any{"error": "Must specify character, characters, or set include_all_characters=true", "items": []any{}, "total_count": 0})
return
return map[string]any{"error": "Must specify character, characters, or set include_all_characters=true", "items": []any{}, "total_count": 0}, nil
}
// --- text ---
@ -312,7 +323,7 @@ func (s *Server) handleSearchItems(w http.ResponseWriter, r *http.Request) {
limit := clampInt(qIntDefault(q, "limit", 200), 1, 50000)
offset := (page - 1) * limit
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
// Underwear filters (shirt_only/pants_only/underwear_only) are injected into
@ -327,8 +338,7 @@ func (s *Server) handleSearchItems(w http.ResponseWriter, r *http.Request) {
" LIMIT " + ab.add(limit) + " OFFSET " + ab.add(offset)
rows, err := queryRowsAsMaps(ctx, s.pool, mainSQL, ab.args...)
if err != nil {
s.dbErr(w, "search/items", err)
return
return nil, err
}
// count uses the SAME CTE (incl. the underwear injection) + conditions, so
@ -342,20 +352,19 @@ func (s *Server) handleSearchItems(w http.ResponseWriter, r *http.Request) {
countSQL := cte + "SELECT COUNT(DISTINCT db_item_id) FROM items_with_slots" + where
var totalCount int64
if err := s.pool.QueryRow(ctx, countSQL, ab.args[:len(ab.args)-2]...).Scan(&totalCount); err != nil {
s.dbErr(w, "search/items count", err)
return
return nil, err
}
items := s.enrichRows(rows)
writeJSON(w, http.StatusOK, map[string]any{
return map[string]any{
"items": items,
"total_count": totalCount,
"page": page,
"limit": limit,
"has_next": int64(page*limit) < totalCount,
"has_previous": page > 1,
})
}, nil
}
// enrichRows applies the direct-column transforms (computed booleans, condition,
@ -428,6 +437,25 @@ func (s *Server) enrichRows(rows []map[string]any) []map[string]any {
}
delete(row, "spell_ids_ordered")
// slot_name — sophisticated equipment-slot translation (main.py:3977-4033).
// Load-bearing for the suitbuilder: jewelry has an empty computed_slot_name,
// so load_items falls back to this to bucket rings/neck/wrists/trinket.
eq := int(toInt64(row["equippable_slots"]))
hasMat := toStr(row["material"]) != ""
row["slot_name"] = s.computeSlotName(eq, int(toInt64(row["coverage_mask"])), hasMat)
delete(row, "equippable_slots")
// Gear-total display ratings (main.py:4035-4072): damage_rating,
// crit_damage_rating, heal_boost_rating only. The CTE already does
// GREATEST(individual, gear-key 370/374/376), so the gear-positive rescue
// branch is dead — the net rule is simply -1 -> null. The other three
// solver ratings (damage_resist/crit_damage_resist/vitality) stay -1.
for _, f := range []string{"damage_rating", "crit_damage_rating", "heal_boost_rating"} {
if toInt64(row[f]) == -1 {
row[f] = nil
}
}
delete(row, "db_item_id")
out = append(out, row)
}
@ -538,7 +566,10 @@ func slotNameClause(name string, ab *argBuilder) string {
case "neck":
return "((computed_slot_name ILIKE " + ab.add("%neck%") + ") OR (object_class = 4 AND (name ILIKE '%amulet%' OR name ILIKE '%necklace%' OR name ILIKE '%gorget%')))"
case "trinket":
return "((computed_slot_name ILIKE " + ab.add("%trinket%") + ") OR (current_wielded_location = 67108864) OR (object_class = 4 AND (name ILIKE '%trinket%' OR name ILIKE '%compass%' OR name ILIKE '%goggles%')) OR (object_class = 11 AND name ILIKE '%trinket%') OR (object_class = 4 AND name NOT ILIKE '%ring%' AND name NOT ILIKE '%amulet%' AND name NOT ILIKE '%necklace%' AND name NOT ILIKE '%gorget%'))"
// Approach 5 (jewelry fallback) MUST exclude %bracelet% — without it the
// Trinket fetch sweeps in bracelets, which then duplicate the Wrist buckets
// (also fetched via slot_names=Bracelet) and the DFS re-emits suits.
return "((computed_slot_name ILIKE " + ab.add("%trinket%") + ") OR (current_wielded_location = 67108864) OR (object_class = 4 AND (name ILIKE '%trinket%' OR name ILIKE '%compass%' OR name ILIKE '%goggles%')) OR (object_class = 11 AND name ILIKE '%trinket%') OR (object_class = 4 AND name NOT ILIKE '%ring%' AND name NOT ILIKE '%bracelet%' AND name NOT ILIKE '%amulet%' AND name NOT ILIKE '%necklace%' AND name NOT ILIKE '%gorget%'))"
case "cloak":
return "((computed_slot_name ILIKE " + ab.add("%cloak%") + ") OR (name ILIKE '%cloak%') OR (computed_slot_name = 'Cloak'))"
default: