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>
92 lines
2.8 KiB
Go
92 lines
2.8 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Suitbuilder endpoints — port of suitbuilder.py's router (mounted at
|
|
// /suitbuilder in the Python service). The live UI hits /inv/suitbuilder/* on
|
|
// the tracker, which proxies here; we expose the same contract for parallel
|
|
// validation.
|
|
|
|
// POST /suitbuilder/search — streams SSE events (event: <type>\ndata: <json>\n\n).
|
|
func (s *Server) handleSuitSearch(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
|
// Pydantic defaults applied before decode; json.Unmarshal only overwrites
|
|
// fields present in the body.
|
|
c := SearchConstraints{IncludeEquipped: true, IncludeInventory: true, MaxResults: 50, SearchTimeout: 300}
|
|
if err := json.Unmarshal(body, &c); err != nil {
|
|
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"detail": "invalid SearchConstraints"})
|
|
return
|
|
}
|
|
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "streaming unsupported"})
|
|
return
|
|
}
|
|
h := w.Header()
|
|
h.Set("Content-Type", "text/event-stream")
|
|
h.Set("Cache-Control", "no-cache")
|
|
h.Set("Connection", "keep-alive")
|
|
h.Set("Access-Control-Allow-Origin", "*")
|
|
h.Set("Access-Control-Allow-Headers", "Cache-Control")
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
var mu sync.Mutex
|
|
emit := func(event string, data map[string]any) {
|
|
b, err := json.Marshal(data)
|
|
if err != nil {
|
|
b, _ = json.Marshal(map[string]any{"message": "Serialization error: " + err.Error()})
|
|
event = "error"
|
|
}
|
|
mu.Lock()
|
|
fmt.Fprintf(w, "event: %s\n", event)
|
|
fmt.Fprintf(w, "data: %s\n\n", b)
|
|
flusher.Flush()
|
|
mu.Unlock()
|
|
}
|
|
cancelled := func() bool {
|
|
select {
|
|
case <-r.Context().Done():
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
sv := newSolver(s, c, emit, cancelled)
|
|
sv.Search(r.Context())
|
|
}
|
|
|
|
// GET /suitbuilder/characters (suitbuilder.py:2085).
|
|
func (s *Server) handleSuitCharacters(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
|
defer cancel()
|
|
rows, err := queryRowsAsMaps(ctx, s.pool, `SELECT DISTINCT character_name FROM items ORDER BY character_name`)
|
|
if err != nil {
|
|
s.dbErr(w, "suitbuilder/characters", err)
|
|
return
|
|
}
|
|
chars := make([]any, 0, len(rows))
|
|
for _, row := range rows {
|
|
chars = append(chars, row["character_name"])
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"characters": chars})
|
|
}
|
|
|
|
// GET /suitbuilder/sets (suitbuilder.py:2195) — the hardcoded set list.
|
|
func (s *Server) handleSuitSets(w http.ResponseWriter, r *http.Request) {
|
|
order := []int{14, 16, 13, 21, 40, 41, 46, 47, 48, 15, 19, 20, 22, 24, 26, 29}
|
|
sets := make([]map[string]any, 0, len(order))
|
|
for _, id := range order {
|
|
sets = append(sets, map[string]any{"id": id, "name": setNames[id]})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"sets": sets})
|
|
}
|