MosswartOverlord/go-services/inventory-go/suit_http.go
Erik 57f53ff36b 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>
2026-06-24 14:03:59 +02:00

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})
}