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:
parent
2473b80519
commit
57f53ff36b
6 changed files with 1806 additions and 18 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue