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
|
|
@ -16,7 +16,9 @@ import (
|
|||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
|
|
@ -31,6 +33,8 @@ type Server struct {
|
|||
objectClasses map[int]string // ObjectClass: id -> name
|
||||
materials map[int]string // MaterialType: id -> name
|
||||
spells map[int]map[string]any // SpellTable: spell-id -> raw spell value object
|
||||
equipMaskMap map[int]string // EquipMask: mask -> technical name (exact lookup)
|
||||
equipMaskOrdered []equipMaskEntry // EquipMask in ascending-mask order (bit-flag decode)
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
|
|
@ -57,7 +61,9 @@ func main() {
|
|||
srv.objectClasses = e.objectClasses
|
||||
srv.materials = e.materials
|
||||
srv.spells = e.spells
|
||||
logger.Info("loaded enum DB", "sets", len(e.sets), "object_classes", len(e.objectClasses), "materials", len(e.materials), "spells", len(e.spells))
|
||||
srv.equipMaskMap = e.equipMaskMap
|
||||
srv.equipMaskOrdered = e.equipMaskOrdered
|
||||
logger.Info("loaded enum DB", "sets", len(e.sets), "object_classes", len(e.objectClasses), "materials", len(e.materials), "spells", len(e.spells), "equip_masks", len(e.equipMaskOrdered))
|
||||
}
|
||||
|
||||
if dsn == "" {
|
||||
|
|
@ -92,6 +98,10 @@ func main() {
|
|||
mux.HandleFunc("POST /process-inventory", srv.handleProcessInventory)
|
||||
mux.HandleFunc("POST /inventory/{character_name}/item", srv.handleUpsertItem)
|
||||
mux.HandleFunc("DELETE /inventory/{character_name}/item/{item_id}", srv.handleDeleteItem)
|
||||
// Suitbuilder (port of suitbuilder.py router, mounted at /suitbuilder).
|
||||
mux.HandleFunc("POST /suitbuilder/search", srv.handleSuitSearch)
|
||||
mux.HandleFunc("GET /suitbuilder/characters", srv.handleSuitCharacters)
|
||||
mux.HandleFunc("GET /suitbuilder/sets", srv.handleSuitSets)
|
||||
|
||||
httpSrv := &http.Server{Addr: addr, Handler: withLogging(mux), ReadHeaderTimeout: 10 * time.Second}
|
||||
go func() {
|
||||
|
|
@ -181,10 +191,12 @@ func (s *Server) dbErr(w http.ResponseWriter, where string, err error) {
|
|||
}
|
||||
|
||||
type enumMaps struct {
|
||||
sets map[string]string
|
||||
objectClasses map[int]string
|
||||
materials map[int]string
|
||||
spells map[int]map[string]any
|
||||
sets map[string]string
|
||||
objectClasses map[int]string
|
||||
materials map[int]string
|
||||
spells map[int]map[string]any
|
||||
equipMaskMap map[int]string
|
||||
equipMaskOrdered []equipMaskEntry
|
||||
}
|
||||
|
||||
// loadEnums reads the comprehensive enum DB and extracts AttributeSetInfo
|
||||
|
|
@ -234,6 +246,19 @@ func loadEnums(path string) (enumMaps, error) {
|
|||
em.spells[n] = v
|
||||
}
|
||||
}
|
||||
// EquipMask: mask -> technical name. Skip EXPR: keys; order by ascending mask
|
||||
// (the JSON order) so multi-bit bit-flag decode joins parts deterministically.
|
||||
em.equipMaskMap = map[int]string{}
|
||||
for k, v := range db.Enums["EquipMask"].Values {
|
||||
if strings.HasPrefix(k, "EXPR:") {
|
||||
continue
|
||||
}
|
||||
if n, err := strconv.Atoi(k); err == nil {
|
||||
em.equipMaskMap[n] = v
|
||||
em.equipMaskOrdered = append(em.equipMaskOrdered, equipMaskEntry{Mask: n, Name: v})
|
||||
}
|
||||
}
|
||||
sort.Slice(em.equipMaskOrdered, func(i, j int) bool { return em.equipMaskOrdered[i].Mask < em.equipMaskOrdered[j].Mask })
|
||||
return em, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue