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

@ -16,7 +16,9 @@ import (
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"sort"
"strconv" "strconv"
"strings"
"syscall" "syscall"
"time" "time"
@ -31,6 +33,8 @@ type Server struct {
objectClasses map[int]string // ObjectClass: id -> name objectClasses map[int]string // ObjectClass: id -> name
materials map[int]string // MaterialType: id -> name materials map[int]string // MaterialType: id -> name
spells map[int]map[string]any // SpellTable: spell-id -> raw spell value object 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 log *slog.Logger
} }
@ -57,7 +61,9 @@ func main() {
srv.objectClasses = e.objectClasses srv.objectClasses = e.objectClasses
srv.materials = e.materials srv.materials = e.materials
srv.spells = e.spells 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 == "" { if dsn == "" {
@ -92,6 +98,10 @@ func main() {
mux.HandleFunc("POST /process-inventory", srv.handleProcessInventory) mux.HandleFunc("POST /process-inventory", srv.handleProcessInventory)
mux.HandleFunc("POST /inventory/{character_name}/item", srv.handleUpsertItem) mux.HandleFunc("POST /inventory/{character_name}/item", srv.handleUpsertItem)
mux.HandleFunc("DELETE /inventory/{character_name}/item/{item_id}", srv.handleDeleteItem) 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} httpSrv := &http.Server{Addr: addr, Handler: withLogging(mux), ReadHeaderTimeout: 10 * time.Second}
go func() { go func() {
@ -181,10 +191,12 @@ func (s *Server) dbErr(w http.ResponseWriter, where string, err error) {
} }
type enumMaps struct { type enumMaps struct {
sets map[string]string sets map[string]string
objectClasses map[int]string objectClasses map[int]string
materials map[int]string materials map[int]string
spells map[int]map[string]any spells map[int]map[string]any
equipMaskMap map[int]string
equipMaskOrdered []equipMaskEntry
} }
// loadEnums reads the comprehensive enum DB and extracts AttributeSetInfo // loadEnums reads the comprehensive enum DB and extracts AttributeSetInfo
@ -234,6 +246,19 @@ func loadEnums(path string) (enumMaps, error) {
em.spells[n] = v 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 return em, nil
} }

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -57,6 +58,7 @@ SELECT DISTINCT
COALESCE(enh.tinks, -1) AS tinks, COALESCE(enh.tinks, -1) AS tinks,
COALESCE(enh.item_set, '') AS item_set, COALESCE(enh.item_set, '') AS item_set,
COALESCE((rd.int_values->>'218103821')::int, 0) AS coverage_mask, COALESCE((rd.int_values->>'218103821')::int, 0) AS coverage_mask,
COALESCE((rd.int_values->>'218103822')::int, 0) AS equippable_slots,
CASE CASE
WHEN rd.original_json IS NOT NULL WHEN rd.original_json IS NOT NULL
AND rd.original_json->'IntValues'->>'218103822' 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) { 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{} ab := &argBuilder{}
var conds []string var conds []string
@ -152,8 +165,7 @@ func (s *Server) handleSearchItems(w http.ResponseWriter, r *http.Request) {
} else if cs := q.Get("characters"); cs != "" { } else if cs := q.Get("characters"); cs != "" {
names := splitNonEmpty(cs) names := splitNonEmpty(cs)
if len(names) == 0 { if len(names) == 0 {
writeJSON(w, http.StatusOK, map[string]any{"error": "Empty characters list provided", "items": []any{}, "total_count": 0}) return map[string]any{"error": "Empty characters list provided", "items": []any{}, "total_count": 0}, nil
return
} }
ph := make([]string, len(names)) ph := make([]string, len(names))
for i, n := range 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, ", ")+")") conds = append(conds, "character_name IN ("+strings.Join(ph, ", ")+")")
} else if !qBool(q, "include_all_characters") { } 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 map[string]any{"error": "Must specify character, characters, or set include_all_characters=true", "items": []any{}, "total_count": 0}, nil
return
} }
// --- text --- // --- text ---
@ -312,7 +323,7 @@ func (s *Server) handleSearchItems(w http.ResponseWriter, r *http.Request) {
limit := clampInt(qIntDefault(q, "limit", 200), 1, 50000) limit := clampInt(qIntDefault(q, "limit", 200), 1, 50000)
offset := (page - 1) * limit offset := (page - 1) * limit
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel() defer cancel()
// Underwear filters (shirt_only/pants_only/underwear_only) are injected into // 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) " LIMIT " + ab.add(limit) + " OFFSET " + ab.add(offset)
rows, err := queryRowsAsMaps(ctx, s.pool, mainSQL, ab.args...) rows, err := queryRowsAsMaps(ctx, s.pool, mainSQL, ab.args...)
if err != nil { if err != nil {
s.dbErr(w, "search/items", err) return nil, err
return
} }
// count uses the SAME CTE (incl. the underwear injection) + conditions, so // 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 countSQL := cte + "SELECT COUNT(DISTINCT db_item_id) FROM items_with_slots" + where
var totalCount int64 var totalCount int64
if err := s.pool.QueryRow(ctx, countSQL, ab.args[:len(ab.args)-2]...).Scan(&totalCount); err != nil { 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 nil, err
return
} }
items := s.enrichRows(rows) items := s.enrichRows(rows)
writeJSON(w, http.StatusOK, map[string]any{ return map[string]any{
"items": items, "items": items,
"total_count": totalCount, "total_count": totalCount,
"page": page, "page": page,
"limit": limit, "limit": limit,
"has_next": int64(page*limit) < totalCount, "has_next": int64(page*limit) < totalCount,
"has_previous": page > 1, "has_previous": page > 1,
}) }, nil
} }
// enrichRows applies the direct-column transforms (computed booleans, condition, // 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") 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") delete(row, "db_item_id")
out = append(out, row) out = append(out, row)
} }
@ -538,7 +566,10 @@ func slotNameClause(name string, ab *argBuilder) string {
case "neck": 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%')))" 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": 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": case "cloak":
return "((computed_slot_name ILIKE " + ab.add("%cloak%") + ") OR (name ILIKE '%cloak%') OR (computed_slot_name = 'Cloak'))" return "((computed_slot_name ILIKE " + ab.add("%cloak%") + ") OR (name ILIKE '%cloak%') OR (computed_slot_name = 'Cloak'))"
default: default:

View file

@ -0,0 +1,183 @@
package main
import (
"fmt"
"math/bits"
"strings"
)
// Port of main.py's sophisticated equipment-slot translation, used to emit the
// `slot_name` field. This is load-bearing for the suitbuilder: jewelry items get
// an empty computed_slot_name (their EquipMask isn't an armor-coverage value, so
// the SQL CONCAT_WS yields ''), and load_items falls back to slot_name
// (`computed_slot_name or slot_name`) to bucket them as Left Ring / Neck / etc.
// equipMaskEntry is one EquipMask enum row, kept in ascending-mask order so the
// bit-flag decode joins parts deterministically (Left before Right).
type equipMaskEntry struct {
Mask int
Name string
}
// equipFriendly maps technical EquipMask names to display names
// (translate_equipment_slot's name_mapping, identical in both branches).
var equipFriendly = map[string]string{
"HeadWear": "Head", "ChestWear": "Chest", "ChestArmor": "Chest",
"AbdomenWear": "Abdomen", "AbdomenArmor": "Abdomen",
"UpperArmWear": "Upper Arms", "UpperArmArmor": "Upper Arms",
"LowerArmWear": "Lower Arms", "LowerArmArmor": "Lower Arms",
"HandWear": "Hands", "UpperLegWear": "Upper Legs", "UpperLegArmor": "Upper Legs",
"LowerLegWear": "Lower Legs", "LowerLegArmor": "Lower Legs", "FootWear": "Feet",
"NeckWear": "Neck", "WristWearLeft": "Left Wrist", "WristWearRight": "Right Wrist",
"FingerWearLeft": "Left Ring", "FingerWearRight": "Right Ring",
"MeleeWeapon": "Melee Weapon", "Shield": "Shield", "MissileWeapon": "Missile Weapon",
"MissileAmmo": "Ammo", "Held": "Held", "TwoHanded": "Two-Handed",
"TrinketOne": "Trinket", "Cloak": "Cloak", "Robe": "Robe",
}
var commonSlots = map[int]string{
30: "Shirt",
786432: "Left Ring, Right Ring",
262144: "Left Ring",
524288: "Right Ring",
}
func friendlySlot(name string) string {
if f, ok := equipFriendly[name]; ok {
return f
}
return name
}
func isBodyArmorEquipMask(v int) bool { return v&0x00007F21 != 0 }
func isBodyArmorCoverageMask(v int) bool { return v&0x0001FF00 != 0 }
func totalBitsSet(v int) int { return bits.OnesCount(uint(uint32(v))) }
// getCoverageReductionOptions mirrors main.py:658.
func getCoverageReductionOptions(coverage int) []int {
const (
oUpperArms = 4096
oLowerArms = 8192
oUpperLegs = 256
oLowerLegs = 512
oChest = 1024
oAbdomen = 2048
head = 16384
hands = 32768
feet = 65536
)
if totalBitsSet(coverage) <= 1 || !isBodyArmorCoverageMask(coverage) {
return []int{coverage}
}
switch coverage {
case oUpperArms | oLowerArms:
return []int{oUpperArms, oLowerArms}
case oUpperLegs | oLowerLegs:
return []int{oUpperLegs, oLowerLegs}
case oLowerLegs | feet:
return []int{feet}
case oChest | oAbdomen:
return []int{oChest}
case oChest | oAbdomen | oUpperArms:
return []int{oChest}
case oChest | oUpperArms | oLowerArms:
return []int{oChest}
case oChest | oUpperArms:
return []int{oChest}
case oAbdomen | oUpperLegs | oLowerLegs:
return []int{oAbdomen, oUpperLegs, oLowerLegs}
case oChest | oAbdomen | oUpperArms | oLowerArms:
return []int{oChest}
case oAbdomen | oUpperLegs:
return []int{oAbdomen}
}
return []int{coverage}
}
// coverageToEquipMask mirrors main.py:717.
func coverageToEquipMask(coverage int) int {
m := map[int]int{
16384: 1, 1024: 512, 4096: 2048, 8192: 4096, 32768: 32,
2048: 1024, 256: 8192, 512: 16384, 65536: 256,
}
if v, ok := m[coverage]; ok {
return v
}
return coverage
}
// getSophisticatedSlotOptions mirrors main.py:734.
func getSophisticatedSlotOptions(equippableSlots, coverageValue int, hasMaterial bool) []int {
const lowerLegWear, footWear = 128, 256
if equippableSlots == (lowerLegWear | footWear) {
return []int{footWear}
}
if isBodyArmorEquipMask(equippableSlots) && totalBitsSet(equippableSlots) > 1 {
if !hasMaterial {
return []int{equippableSlots}
}
var slotOpts []int
for _, o := range getCoverageReductionOptions(coverageValue) {
slotOpts = append(slotOpts, coverageToEquipMask(o))
}
if len(slotOpts) > 0 {
return slotOpts
}
return []int{equippableSlots}
}
return []int{equippableSlots}
}
// translateEquipmentSlot mirrors main.py:807.
func (s *Server) translateEquipmentSlot(loc int) string {
if loc == 0 {
return "Inventory"
}
if name, ok := s.equipMaskMap[loc]; ok {
return friendlySlot(name)
}
if cs, ok := commonSlots[loc]; ok {
return cs
}
var parts []string
for _, e := range s.equipMaskOrdered {
if e.Mask > 0 && loc&e.Mask == e.Mask {
parts = append(parts, friendlySlot(e.Name))
}
}
if len(parts) > 0 {
return strings.Join(parts, ", ")
}
if loc >= 268435456 {
switch loc {
case 268435456:
return "Aetheria Blue"
case 536870912:
return "Aetheria Yellow"
case 1073741824:
return "Aetheria Red"
default:
return fmt.Sprintf("Special Slot (%d)", loc)
}
}
return "-"
}
// computeSlotName mirrors the slot_name block in search_items (main.py:3977-4033).
func (s *Server) computeSlotName(equippableSlots, coverageValue int, hasMaterial bool) string {
if equippableSlots <= 0 {
return "-"
}
opts := getSophisticatedSlotOptions(equippableSlots, coverageValue, hasMaterial)
var names []string
for _, o := range opts {
n := s.translateEquipmentSlot(o)
if n != "" && !containsString(names, n) {
names = append(names, n)
}
}
if len(names) > 0 {
return strings.Join(names, ", ")
}
return "-"
}

View file

@ -0,0 +1,92 @@
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})
}

View file

@ -0,0 +1,593 @@
package main
import (
"fmt"
"hash/fnv"
"math/bits"
"sort"
"strings"
)
// Port of inventory-service/suitbuilder.py data model. This is the LIVE solver
// (mounted at /suitbuilder/search; main.py's /optimize/suits is legacy/unused).
// Every sort carries (character_name, name) tiebreakers so results are
// deterministic and reproducible, exactly as the Python source documents.
// --- Equipment set name<->id maps (suitbuilder.py SET_NAMES / _convert_set_name_to_id) ---
var setNames = map[int]string{
14: "Adept's", 16: "Defender's", 13: "Soldier's", 21: "Wise",
40: "Heroic Protector", 41: "Heroic Destroyer", 46: "Relic Alduressa",
47: "Ancient Relic", 48: "Noble Relic", 15: "Archer's", 19: "Hearty",
20: "Dexterous", 22: "Swift", 24: "Reinforced", 26: "Flame Proof",
29: "Lightning Proof",
}
// nameToSetID is the reverse map used by load_items to turn the search's
// item_set field into a numeric set id (note the " Set" suffix, verbatim).
var nameToSetID = map[string]int{
"Adept's Set": 14, "Defender's Set": 16, "Soldier's Set": 13, "Wise Set": 21,
"Heroic Protector Set": 40, "Heroic Destroyer Set": 41, "Relic Alduressa Set": 46,
"Ancient Relic Set": 47, "Noble Relic Set": 48, "Archer's Set": 15,
"Hearty Set": 19, "Dexterous Set": 20, "Swift Set": 22, "Reinforced Set": 24,
"Flame Proof Set": 26, "Lightning Proof Set": 29,
}
// getSetName mirrors suitbuilder.get_set_name (None/0 -> "").
func getSetName(setID int) string {
if setID == 0 {
return ""
}
if n, ok := setNames[setID]; ok {
return n
}
return fmt.Sprintf("Set %d", setID)
}
func convertSetNameToID(setName string) int { return nameToSetID[setName] }
// --- CoverageMask (suitbuilder.py:81) ---
const (
covUnderwearUpperLegs = 0x00000002
covUnderwearLowerLegs = 0x00000004
covUnderwearChest = 0x00000008
covUnderwearAbdomen = 0x00000010
covUnderwearUpperArms = 0x00000020
covUnderwearLowerArms = 0x00000040
covOuterUpperLegs = 0x00000100
covOuterLowerLegs = 0x00000200
covOuterChest = 0x00000400
covOuterAbdomen = 0x00000800
covOuterUpperArms = 0x00001000
covOuterLowerArms = 0x00002000
covHead = 0x00004000
covHands = 0x00008000
covFeet = 0x00010000
// Aliases matching slot names (suitbuilder.py:110-115).
covChest = covOuterChest
covAbdomen = covOuterAbdomen
covUpperArms = covOuterUpperArms
covLowerArms = covOuterLowerArms
covUpperLegs = covOuterUpperLegs
covLowerLegs = covOuterLowerLegs
magRobePattern = 0x00013F00
)
// coverageReductionOptions mirrors CoverageMask.reduction_options().
func coverageReductionOptions(v int) []int {
if bits.OnesCount(uint(v)) <= 1 {
return nil
}
if coverageIsRobe(v) {
return nil
}
switch v {
case covUpperArms | covLowerArms:
return []int{covUpperArms, covLowerArms}
case covUpperLegs | covLowerLegs:
return []int{covUpperLegs, covLowerLegs}
case covLowerLegs | covFeet:
return []int{covFeet}
case covChest | covAbdomen:
return []int{covChest}
case covChest | covAbdomen | covUpperArms:
return []int{covChest}
case covChest | covUpperArms | covLowerArms:
return []int{covChest}
case covChest | covUpperArms:
return []int{covChest}
case covAbdomen | covUpperLegs | covLowerLegs:
return []int{covAbdomen, covUpperLegs, covLowerLegs}
case covChest | covAbdomen | covUpperArms | covLowerArms:
return []int{covChest}
case covAbdomen | covUpperLegs:
return []int{covAbdomen}
}
return nil
}
// coverageIsRobe mirrors CoverageMask.is_robe() (exact pattern == component
// pattern == 0x13F00; otherwise the 6+ coverage-areas fallback).
func coverageIsRobe(v int) bool {
if v == magRobePattern {
return true
}
return bits.OnesCount(uint(v)) >= 6
}
// coverageToSlotName mirrors CoverageMask.to_slot_name() (single coverage only).
func coverageToSlotName(v int) string {
switch v {
case covHead:
return "Head"
case covChest:
return "Chest"
case covUpperArms:
return "Upper Arms"
case covLowerArms:
return "Lower Arms"
case covHands:
return "Hands"
case covAbdomen:
return "Abdomen"
case covUpperLegs:
return "Upper Legs"
case covLowerLegs:
return "Lower Legs"
case covFeet:
return "Feet"
}
return ""
}
// --- SuitItem (suitbuilder.py:221) ---
type SuitItem struct {
ID string // unique per (character,name); used for uniqueness checks
Name string
CharacterName string
Slot string
Coverage int // 0 == None
HasCoverage bool
SetID int // 0 == None
ArmorLevel int
Ratings map[string]int
SpellBitmap uint64
SpellNames []string
IsLocked bool
Material string
}
func (it *SuitItem) ratingsSum() int {
s := 0
for _, v := range it.Ratings {
s += v
}
return s
}
func (it *SuitItem) ratingsSumExcept(skip string) int {
s := 0
for k, v := range it.Ratings {
if k != skip {
s += v
}
}
return s
}
func (it *SuitItem) clone(slot string, name string, coverage int, hasCov bool) *SuitItem {
r := make(map[string]int, len(it.Ratings))
for k, v := range it.Ratings {
r[k] = v
}
sn := make([]string, len(it.SpellNames))
copy(sn, it.SpellNames)
return &SuitItem{
ID: it.ID, Name: name, CharacterName: it.CharacterName, Slot: slot,
Coverage: coverage, HasCoverage: hasCov, SetID: it.SetID, ArmorLevel: it.ArmorLevel,
Ratings: r, SpellBitmap: it.SpellBitmap, SpellNames: sn, IsLocked: it.IsLocked,
Material: it.Material,
}
}
// --- ItemBucket (suitbuilder.py:247) ---
type ItemBucket struct {
Slot string
Items []*SuitItem
IsArmor bool
}
var clothingSortSlots = map[string]bool{"Shirt": true, "Pants": true}
// sortItems mirrors ItemBucket.sort_items() (reverse=True over the key tuple,
// stable so equal keys keep prior order).
func (b *ItemBucket) sortItems() {
items := b.Items
if _, isClothing := clothingSortSlots[b.Slot]; isClothing {
sort.SliceStable(items, func(i, j int) bool {
return descTuple(
cmpInt(items[i].Ratings["damage_rating"], items[j].Ratings["damage_rating"]),
cmpInt(len(items[i].SpellNames), len(items[j].SpellNames)),
cmpInt(items[i].ratingsSumExcept("damage_rating"), items[j].ratingsSumExcept("damage_rating")),
cmpStr(items[i].CharacterName, items[j].CharacterName),
cmpStr(items[i].Name, items[j].Name),
)
})
} else if b.IsArmor {
sort.SliceStable(items, func(i, j int) bool {
return descTuple(
cmpInt(items[i].ArmorLevel, items[j].ArmorLevel),
cmpInt(items[i].Ratings["crit_damage_rating"], items[j].Ratings["crit_damage_rating"]),
cmpInt(len(items[i].SpellNames), len(items[j].SpellNames)),
cmpInt(items[i].ratingsSum(), items[j].ratingsSum()),
cmpStr(items[i].CharacterName, items[j].CharacterName),
cmpStr(items[i].Name, items[j].Name),
)
})
} else {
sort.SliceStable(items, func(i, j int) bool {
return descTuple(
cmpInt(len(items[i].SpellNames), len(items[j].SpellNames)),
cmpInt(items[i].ratingsSum(), items[j].ratingsSum()),
cmpStr(items[i].CharacterName, items[j].CharacterName),
cmpStr(items[i].Name, items[j].Name),
)
})
}
}
// descTuple returns true if the left tuple sorts before the right under Python's
// reverse=True (i.e. the larger tuple comes first). cmp* return -1/0/1.
func descTuple(cmps ...int) bool {
for _, c := range cmps {
if c != 0 {
return c > 0 // larger first
}
}
return false
}
func cmpInt(a, b int) int {
switch {
case a < b:
return -1
case a > b:
return 1
}
return 0
}
func cmpStr(a, b string) int { return strings.Compare(a, b) }
// --- SpellBitmapIndex (suitbuilder.py:299) ---
type SpellBitmapIndex struct {
spellToBit map[string]uint64
order []struct {
bit uint64
name string
}
nextBit uint
}
func newSpellBitmapIndex() *SpellBitmapIndex {
return &SpellBitmapIndex{spellToBit: map[string]uint64{}}
}
func (s *SpellBitmapIndex) registerSpell(name string) uint64 {
if b, ok := s.spellToBit[name]; ok {
return b
}
var bit uint64
if s.nextBit < 64 {
bit = uint64(1) << s.nextBit
} // >=64: bit stays 0 (only non-required spells ever reach here; required
// spells are registered first, so their low bits are always exact).
s.spellToBit[name] = bit
s.order = append(s.order, struct {
bit uint64
name string
}{bit, name})
s.nextBit++
return bit
}
func (s *SpellBitmapIndex) getBitmap(spells []string) uint64 {
var m uint64
for _, sp := range spells {
m |= s.registerSpell(sp)
}
return m
}
func (s *SpellBitmapIndex) getSpellNames(bitmap uint64) []string {
var out []string
for _, e := range s.order {
if e.bit != 0 && bitmap&e.bit != 0 {
out = append(out, e.name)
}
}
return out
}
// --- SuitState (suitbuilder.py:342) ---
type SuitState struct {
Items map[string]*SuitItem
SpellBitmap uint64
SetCounts map[int]int
TotalArmor int
TotalRatings map[string]int
Occupied map[string]bool
}
func newSuitState() *SuitState {
return &SuitState{
Items: map[string]*SuitItem{}, SetCounts: map[int]int{},
TotalRatings: map[string]int{}, Occupied: map[string]bool{},
}
}
func (st *SuitState) push(it *SuitItem) {
st.Items[it.Slot] = it
st.Occupied[it.Slot] = true
st.SpellBitmap |= it.SpellBitmap
if it.SetID != 0 {
st.SetCounts[it.SetID]++
}
st.TotalArmor += it.ArmorLevel
for k, v := range it.Ratings {
st.TotalRatings[k] += v
}
}
func (st *SuitState) pop(slot string) {
it, ok := st.Items[slot]
if !ok {
return
}
delete(st.Items, slot)
delete(st.Occupied, slot)
// Rebuild spell bitmap (overlaps prevent simple subtraction).
st.SpellBitmap = 0
for _, r := range st.Items {
st.SpellBitmap |= r.SpellBitmap
}
if it.SetID != 0 {
st.SetCounts[it.SetID]--
if st.SetCounts[it.SetID] == 0 {
delete(st.SetCounts, it.SetID)
}
}
st.TotalArmor -= it.ArmorLevel
for k, v := range it.Ratings {
if _, present := st.TotalRatings[k]; present {
st.TotalRatings[k] -= v
if st.TotalRatings[k] <= 0 {
delete(st.TotalRatings, k)
}
}
}
}
// --- ScoringWeights / SearchConstraints (suitbuilder.py:409,426) ---
type ScoringWeights struct {
ArmorSetComplete int
MissingSetPenalty int
CritDamage1 int
CritDamage2 int
DamageRating1 int
DamageRating2 int
DamageRating3 int
}
func defaultScoringWeights() ScoringWeights {
return ScoringWeights{
ArmorSetComplete: 1000, MissingSetPenalty: -200,
CritDamage1: 10, CritDamage2: 20,
DamageRating1: 10, DamageRating2: 20, DamageRating3: 30,
}
}
type LockedSlotInfo struct {
SetID int `json:"set_id"`
Spells []string `json:"spells"`
}
type SearchConstraints struct {
Characters []string `json:"characters"`
PrimarySet int `json:"primary_set"`
SecondarySet int `json:"secondary_set"`
RequiredSpells []string `json:"required_spells"`
LockedSlots map[string]LockedSlotInfo `json:"locked_slots"`
IncludeEquipped bool `json:"include_equipped"`
IncludeInventory bool `json:"include_inventory"`
MinArmor *int `json:"min_armor"`
MaxArmor *int `json:"max_armor"`
MinCritDamage *int `json:"min_crit_damage"`
MaxCritDamage *int `json:"max_crit_damage"`
MinDamageRating *int `json:"min_damage_rating"`
MaxDamageRating *int `json:"max_damage_rating"`
ScoringWeights *ScoringWeights `json:"scoring_weights"`
MaxResults int `json:"max_results"`
SearchTimeout int `json:"search_timeout"`
}
// --- CompletedSuit (suitbuilder.py:446) ---
type CompletedSuit struct {
Items map[string]*SuitItem
Score int
TotalArmor int
TotalRatings map[string]int
SetCounts map[int]int
FulfilledSpells []string
MissingSpells []string
}
func fnvInt(s string) int {
h := fnv.New32a()
_, _ = h.Write([]byte(s))
return int(h.Sum32())
}
// toDict mirrors CompletedSuit.to_dict(). The opaque id fields are derived
// deterministically (Python uses salted hash(); we use FNV) — never compared in
// validation.
func (c *CompletedSuit) toDict() map[string]any {
transferByChar := map[string][]string{}
totalItems := 0
// Slots iterated in Python dict order; use a sorted-slot order for stable
// transfer instructions (instructions are display-only).
for _, it := range c.Items {
transferByChar[it.CharacterName] = append(transferByChar[it.CharacterName], it.Name)
totalItems++
}
chars := make([]string, 0, len(transferByChar))
for ch := range transferByChar {
chars = append(chars, ch)
}
sort.Strings(chars)
instructions := []string{}
step := 1
for _, ch := range chars {
for _, name := range transferByChar[ch] {
instructions = append(instructions, fmt.Sprintf("%d. Transfer %s from %s to new character", step, name, ch))
step++
}
}
instructions = append(instructions, fmt.Sprintf("%d. Equip all transferred items on new character", step))
slotKeys := make([]string, 0, len(c.Items))
for slot := range c.Items {
slotKeys = append(slotKeys, slot)
}
sort.Strings(slotKeys)
itemsOut := map[string]any{}
for _, slot := range slotKeys {
it := c.Items[slot]
var setIDOut any
if it.SetID != 0 {
setIDOut = it.SetID
}
itemsOut[slot] = map[string]any{
"id": fnvInt(it.ID),
"name": it.Name,
"source_character": it.CharacterName,
"armor_level": it.ArmorLevel,
"ratings": it.Ratings,
"spells": it.SpellNames,
"set_id": setIDOut,
"set_name": getSetName(it.SetID),
}
}
return map[string]any{
"id": fnvInt(strings.Join(slotKeys, "|")),
"score": c.Score,
"items": itemsOut,
"stats": map[string]any{
"total_armor": c.TotalArmor,
"total_crit_damage": c.TotalRatings["crit_damage_rating"],
"total_damage_rating": c.TotalRatings["damage_rating"],
"primary_set_count": 0,
"secondary_set_count": 0,
"spell_coverage": len(c.FulfilledSpells),
},
"missing": c.MissingSpells,
"notes": []any{},
"transfer_summary": map[string]any{
"total_items": totalItems,
"from_characters": transferByChar,
},
"instructions": instructions,
}
}
// --- ItemPreFilter (suitbuilder.py:519) ---
func removeSurpassedItems(items []*SuitItem) []*SuitItem {
out := make([]*SuitItem, 0, len(items))
for _, it := range items {
surpassed := false
for _, cmp := range items {
if cmp == it {
continue
}
if isSurpassedBy(it, cmp) {
surpassed = true
break
}
}
if !surpassed {
out = append(out, it)
}
}
return out
}
func isSurpassedBy(item, cmp *SuitItem) bool {
if item.Slot != cmp.Slot {
return false
}
if item.SetID != cmp.SetID {
return false
}
if !spellsSurpassOrEqual(cmp.SpellNames, item.SpellNames) {
return false
}
betterInSomething := false
for _, key := range []string{"crit_damage_rating", "damage_rating"} {
ir := item.Ratings[key]
cr := cmp.Ratings[key]
if cr > ir {
betterInSomething = true
} else if ir > cr {
return false
}
}
if item.ArmorLevel > 0 && cmp.ArmorLevel > 0 {
if cmp.ArmorLevel > item.ArmorLevel {
betterInSomething = true
} else if item.ArmorLevel > cmp.ArmorLevel {
return false
}
}
return betterInSomething
}
func spellsSurpassOrEqual(spells1, spells2 []string) bool {
for _, s2 := range spells2 {
found := false
for _, s1 := range spells1 {
if s1 == s2 || spellSurpasses(s1, s2) {
found = true
break
}
}
if !found {
return false
}
}
return true
}
func spellSurpasses(s1, s2 string) bool {
if strings.Contains(s1, "Legendary") && (strings.Contains(s2, "Epic") || strings.Contains(s2, "Major")) {
b1 := strings.ReplaceAll(s1, "Legendary ", "")
b2 := strings.ReplaceAll(strings.ReplaceAll(s2, "Epic ", ""), "Major ", "")
return b1 == b2
}
if strings.Contains(s1, "Epic") && strings.Contains(s2, "Major") {
b1 := strings.ReplaceAll(s1, "Epic ", "")
b2 := strings.ReplaceAll(s2, "Major ", "")
return b1 == b2
}
return false
}

View file

@ -0,0 +1,864 @@
package main
import (
"context"
"net/url"
"sort"
"strconv"
"strings"
"time"
)
// Solver is the Go port of suitbuilder.py ConstraintSatisfactionSolver (the live
// /suitbuilder/search DFS). It streams events via emit; cancellation is checked
// through cancelled (the request context).
type Solver struct {
s *Server
c SearchConstraints
spellIndex *SpellBitmapIndex
bestSuits []*CompletedSuit
evaluated int
weights ScoringWeights
neededSpellBitmap uint64
bestSuitItemCount int
highestArmorCount int
armorBucketsItems int
lockedSetCounts map[int]int
lockedSpells map[string]bool
effPrimary int
effSecondary int
start time.Time
emit func(event string, data map[string]any)
cancelled func() bool
stopped bool
}
func newSolver(s *Server, c SearchConstraints, emit func(string, map[string]any), cancelled func() bool) *Solver {
w := defaultScoringWeights()
if c.ScoringWeights != nil {
w = *c.ScoringWeights
}
if c.MaxResults == 0 {
c.MaxResults = 50
}
sv := &Solver{
s: s, c: c, spellIndex: newSpellBitmapIndex(), weights: w,
lockedSetCounts: map[int]int{}, lockedSpells: map[string]bool{},
effPrimary: 5, effSecondary: 4, start: time.Now(),
emit: emit, cancelled: cancelled,
}
// Required spells register first, so they always get the low bits.
sv.neededSpellBitmap = sv.spellIndex.getBitmap(c.RequiredSpells)
return sv
}
var armorSlotSet = map[string]bool{
"Head": true, "Chest": true, "Upper Arms": true, "Lower Arms": true,
"Hands": true, "Abdomen": true, "Upper Legs": true, "Lower Legs": true, "Feet": true,
}
var jewelrySlotSet = map[string]bool{
"Neck": true, "Left Ring": true, "Right Ring": true,
"Left Wrist": true, "Right Wrist": true, "Trinket": true,
}
func (sv *Solver) elapsed() float64 { return time.Since(sv.start).Seconds() }
// Search drives the 5-phase pipeline, emitting events as it goes.
func (sv *Solver) Search(ctx context.Context) {
sv.emit("phase", map[string]any{"phase": "loading", "message": "Loading items from database...", "phase_number": 1, "total_phases": 5})
items, err := sv.loadItems(ctx)
if err != nil {
sv.emit("error", map[string]any{"message": err.Error()})
return
}
sv.emit("phase", map[string]any{"phase": "loaded", "message": "Loaded items", "items_count": len(items), "phase_number": 1, "total_phases": 5})
sv.emit("log", map[string]any{"level": "info", "message": "Loaded items from characters", "timestamp": sv.elapsed()})
if len(items) == 0 {
sv.emit("error", map[string]any{"message": "No items found for specified characters"})
return
}
sv.emit("phase", map[string]any{"phase": "buckets", "message": "Creating equipment buckets...", "phase_number": 2, "total_phases": 5})
buckets := sv.createBuckets(items)
summary := map[string]any{}
for _, b := range buckets {
summary[b.Slot] = len(b.Items)
}
sv.emit("phase", map[string]any{"phase": "buckets_done", "message": "Created buckets", "bucket_count": len(buckets), "bucket_summary": summary, "phase_number": 2, "total_phases": 5})
sv.emit("phase", map[string]any{"phase": "reducing", "message": "Applying armor reduction rules...", "phase_number": 3, "total_phases": 5})
buckets = sv.applyReductionOptions(buckets)
sv.emit("phase", map[string]any{"phase": "sorting", "message": "Optimizing search order...", "phase_number": 4, "total_phases": 5})
buckets = sv.sortBuckets(buckets)
// Locked slots: drop those buckets, accumulate locked set/spell contributions.
if len(sv.c.LockedSlots) > 0 {
locked := map[string]bool{}
for slot := range sv.c.LockedSlots {
locked[slot] = true
}
kept := buckets[:0]
for _, b := range buckets {
if !locked[b.Slot] {
kept = append(kept, b)
}
}
buckets = kept
for _, info := range sv.c.LockedSlots {
if info.SetID != 0 {
sv.lockedSetCounts[info.SetID]++
}
for _, sp := range info.Spells {
sv.lockedSpells[sp] = true
}
}
}
sv.effPrimary, sv.effSecondary = 5, 4
if sv.c.PrimarySet != 0 {
sv.effPrimary = max0(5 - sv.lockedSetCounts[sv.c.PrimarySet])
}
if sv.c.SecondarySet != 0 {
sv.effSecondary = max0(4 - sv.lockedSetCounts[sv.c.SecondarySet])
}
sv.emit("phase", map[string]any{"phase": "searching", "message": "Searching for optimal suits...", "total_buckets": len(buckets), "phase_number": 5, "total_phases": 5})
sv.emit("log", map[string]any{"level": "info", "message": "Starting search", "timestamp": sv.elapsed()})
sv.recursiveSearch(buckets, 0, newSuitState())
sv.emit("complete", map[string]any{"suits_found": len(sv.bestSuits), "duration": round1(sv.elapsed())})
}
// loadItems mirrors suitbuilder.load_items: fetch via the in-process search with
// the exact same filter param sets, convert to SuitItem, register spell bitmaps,
// pre-filter, and sort into armor+jewelry+clothing order.
func (sv *Solver) loadItems(ctx context.Context) ([]*SuitItem, error) {
s := sv.s
primaryName, secondaryName := "", ""
if sv.c.PrimarySet != 0 {
primaryName = s.translateSetID(strconv.Itoa(sv.c.PrimarySet))
}
if sv.c.SecondarySet != 0 {
secondaryName = s.translateSetID(strconv.Itoa(sv.c.SecondarySet))
}
equipmentStatus := ""
if sv.c.IncludeEquipped && sv.c.IncludeInventory {
equipmentStatus = ""
} else if sv.c.IncludeEquipped {
equipmentStatus = "equipped"
} else if sv.c.IncludeInventory {
equipmentStatus = "unequipped"
}
var apiItems []map[string]any
fetch := func(extra map[string]string) error {
q := url.Values{}
if len(sv.c.Characters) > 0 {
q.Set("characters", strings.Join(sv.c.Characters, ","))
} else {
q.Set("include_all_characters", "true")
}
if equipmentStatus != "" {
q.Set("equipment_status", equipmentStatus)
}
q.Set("limit", "1000")
for k, v := range extra {
q.Set(k, v)
}
res, err := s.runSearch(ctx, q)
if err != nil {
return err
}
if items, ok := res["items"].([]map[string]any); ok {
apiItems = append(apiItems, items...)
}
return nil
}
if primaryName != "" {
if err := fetch(map[string]string{"item_set": primaryName}); err != nil {
return nil, err
}
}
if secondaryName != "" {
if err := fetch(map[string]string{"item_set": secondaryName}); err != nil {
return nil, err
}
}
// Clothing: DR3 shirts/pants only.
_ = fetch(map[string]string{"shirt_only": "true", "min_damage_rating": "3"})
_ = fetch(map[string]string{"pants_only": "true", "min_damage_rating": "3"})
// Jewelry: one fetch per type via slot_names.
for _, slot := range []string{"Ring", "Bracelet", "Neck", "Trinket"} {
_ = fetch(map[string]string{"jewelry_only": "true", "slot_names": slot})
}
items := make([]*SuitItem, 0, len(apiItems))
for _, api := range apiItems {
name := toStr(api["name"])
char := toStr(api["character_name"])
coverageVal := int(toInt64(api["coverage_mask"]))
slot := toStr(api["computed_slot_name"])
if slot == "" {
slot = toStr(api["slot_name"])
}
if slot == "" {
slot = "Unknown"
}
if int(toInt64(api["object_class"])) == 3 {
switch coverageVal {
case 104:
slot = "Shirt"
case 19, 22:
slot = "Pants"
}
}
rg := func(k string) int {
v := api[k]
if v == nil {
return 0
}
return int(toInt64(v))
}
var spellNames []string
if sn, ok := api["spell_names"].([]string); ok {
spellNames = sn
}
it := &SuitItem{
ID: char + "_" + name,
Name: name,
CharacterName: char,
Slot: slot,
Coverage: coverageVal,
HasCoverage: coverageVal != 0,
SetID: convertSetNameToID(toStr(api["item_set"])),
ArmorLevel: int(toInt64(api["armor_level"])),
Ratings: map[string]int{
"crit_damage_rating": rg("crit_damage_rating"),
"damage_rating": rg("damage_rating"),
"damage_resist_rating": rg("damage_resist_rating"),
"crit_damage_resist_rating": rg("crit_damage_resist_rating"),
"heal_boost_rating": rg("heal_boost_rating"),
"vitality_rating": rg("vitality_rating"),
},
SpellNames: spellNames,
Material: toStr(api["material_name"]),
}
items = append(items, it)
}
for _, it := range items {
if len(it.SpellNames) > 0 {
it.SpellBitmap = sv.spellIndex.getBitmap(it.SpellNames)
}
}
filtered := removeSurpassedItems(items)
jewelryFallback := map[string]bool{"Ring": true, "Bracelet": true, "Jewelry": true, "Necklace": true, "Amulet": true}
matches := func(slot string, set, fallback map[string]bool) bool {
if set[slot] {
return true
}
if strings.Contains(slot, ", ") {
for _, p := range strings.Split(slot, ", ") {
if set[strings.TrimSpace(p)] {
return true
}
}
}
if fallback != nil && fallback[slot] {
return true
}
return false
}
var armor, jewelry, clothing []*SuitItem
for _, it := range filtered {
if matches(it.Slot, armorSlotSet, nil) {
armor = append(armor, it)
}
if matches(it.Slot, jewelrySlotSet, jewelryFallback) {
jewelry = append(jewelry, it)
}
if matches(it.Slot, clothingSortSlots, nil) {
clothing = append(clothing, it)
}
}
sortBySpellThenName := func(list []*SuitItem) {
sort.SliceStable(list, func(i, j int) bool {
return descTuple(
cmpInt(len(list[i].SpellNames), len(list[j].SpellNames)),
cmpStr(list[i].CharacterName, list[j].CharacterName),
cmpStr(list[i].Name, list[j].Name),
)
})
}
sortBySpellThenName(armor)
sortBySpellThenName(jewelry)
sort.SliceStable(clothing, func(i, j int) bool {
return descTuple(
cmpInt(clothing[i].Ratings["damage_rating"], clothing[j].Ratings["damage_rating"]),
cmpStr(clothing[i].CharacterName, clothing[j].CharacterName),
cmpStr(clothing[i].Name, clothing[j].Name),
)
})
out := make([]*SuitItem, 0, len(armor)+len(jewelry)+len(clothing))
out = append(out, armor...)
out = append(out, jewelry...)
out = append(out, clothing...)
return out, nil
}
var allSlots = []string{
"Head", "Chest", "Upper Arms", "Lower Arms", "Hands",
"Abdomen", "Upper Legs", "Lower Legs", "Feet",
"Neck", "Left Ring", "Right Ring", "Left Wrist", "Right Wrist", "Trinket",
"Shirt", "Pants",
}
func (sv *Solver) createBuckets(items []*SuitItem) []*ItemBucket {
slotItems := map[string][]*SuitItem{}
inSlots := map[string]bool{}
for _, slot := range allSlots {
slotItems[slot] = nil
inSlots[slot] = true
}
genericJewelry := map[string][]string{
"Ring": {"Left Ring", "Right Ring"},
"Bracelet": {"Left Wrist", "Right Wrist"},
"Jewelry": {"Neck", "Left Ring", "Right Ring", "Left Wrist", "Right Wrist", "Trinket"},
"Necklace": {"Neck"},
"Amulet": {"Neck"},
}
for _, it := range items {
if inSlots[it.Slot] {
slotItems[it.Slot] = append(slotItems[it.Slot], it)
} else if strings.Contains(it.Slot, ", ") {
for _, p := range strings.Split(it.Slot, ", ") {
p = strings.TrimSpace(p)
if inSlots[p] {
slotItems[p] = append(slotItems[p], it.clone(p, it.Name, it.Coverage, it.HasCoverage))
}
}
} else if targets, ok := genericJewelry[it.Slot]; ok {
for _, t := range targets {
slotItems[t] = append(slotItems[t], it.clone(t, it.Name, it.Coverage, it.HasCoverage))
}
} else {
lower := strings.ToLower(it.Slot)
for _, known := range allSlots {
if strings.Contains(lower, strings.ToLower(known)) {
slotItems[known] = append(slotItems[known], it.clone(known, it.Name, it.Coverage, it.HasCoverage))
}
}
}
}
buckets := make([]*ItemBucket, 0, len(allSlots))
for _, slot := range allSlots {
b := &ItemBucket{Slot: slot, Items: slotItems[slot], IsArmor: armorSlotSet[slot]}
b.sortItems()
buckets = append(buckets, b)
}
// armor first, then item count ascending (overridden by sortBuckets, but the
// stable item order set here feeds the later stable re-sorts).
sort.SliceStable(buckets, func(i, j int) bool {
ai, aj := boolToInt(!buckets[i].IsArmor), boolToInt(!buckets[j].IsArmor)
if ai != aj {
return ai < aj
}
return len(buckets[i].Items) < len(buckets[j].Items)
})
sv.armorBucketsItems = 0
for _, b := range buckets {
if b.IsArmor && len(b.Items) > 0 {
sv.armorBucketsItems++
}
}
return buckets
}
func (sv *Solver) applyReductionOptions(buckets []*ItemBucket) []*ItemBucket {
var newBuckets []*ItemBucket
findBucket := func(slot string) *ItemBucket {
for _, b := range newBuckets {
if b.Slot == slot {
return b
}
}
return nil
}
for _, bucket := range buckets {
if !bucket.IsArmor {
newBuckets = append(newBuckets, bucket)
continue
}
var original, reducible []*SuitItem
for _, it := range bucket.Items {
if it.HasCoverage && it.Material != "" && len(coverageReductionOptions(it.Coverage)) > 0 {
reducible = append(reducible, it)
} else {
original = append(original, it)
}
}
if len(original) > 0 || len(reducible) == 0 {
nb := &ItemBucket{Slot: bucket.Slot, Items: original, IsArmor: bucket.IsArmor}
nb.sortItems()
newBuckets = append(newBuckets, nb)
}
for _, it := range reducible {
for _, rc := range coverageReductionOptions(it.Coverage) {
reducedSlot := coverageToSlotName(rc)
if reducedSlot == "" {
continue
}
reduced := it.clone(reducedSlot, it.Name+" (tailored to "+reducedSlot+")", rc, true)
target := findBucket(reducedSlot)
if target == nil {
target = &ItemBucket{Slot: reducedSlot, IsArmor: true}
newBuckets = append(newBuckets, target)
}
target.Items = append(target.Items, reduced)
}
}
}
for _, b := range newBuckets {
b.sortItems()
}
return newBuckets
}
var coreArmorPriority = []string{"Chest", "Head", "Hands", "Feet", "Upper Arms", "Lower Arms", "Abdomen", "Upper Legs", "Lower Legs"}
var jewelryPriority = []string{"Neck", "Left Ring", "Right Ring", "Left Wrist", "Right Wrist", "Trinket"}
var clothingPriority = []string{"Shirt", "Pants"}
func (sv *Solver) sortBuckets(buckets []*ItemBucket) []*ItemBucket {
for _, bucket := range buckets {
items := bucket.Items
sort.SliceStable(items, func(i, j int) bool {
pi, pj := sv.setPriority(items[i].SetID), sv.setPriority(items[j].SetID)
if pi != pj {
return pi < pj
}
if c := cmpInt(items[i].Ratings["crit_damage_rating"], items[j].Ratings["crit_damage_rating"]); c != 0 {
return c > 0
}
if c := cmpInt(items[i].Ratings["damage_rating"], items[j].Ratings["damage_rating"]); c != 0 {
return c > 0
}
return items[i].ArmorLevel > items[j].ArmorLevel
})
}
sort.SliceStable(buckets, func(i, j int) bool {
gi, ii := bucketPriority(buckets[i].Slot)
gj, ij := bucketPriority(buckets[j].Slot)
if gi != gj {
return gi < gj
}
if ii != ij {
return ii < ij
}
return len(buckets[i].Items) < len(buckets[j].Items)
})
return buckets
}
func (sv *Solver) setPriority(setID int) int {
if setID != 0 && setID == sv.c.PrimarySet {
return 0
}
if setID != 0 && setID == sv.c.SecondarySet {
return 1
}
return 2
}
func bucketPriority(slot string) (int, int) {
for i, s := range coreArmorPriority {
if s == slot {
return 0, i
}
}
for i, s := range jewelryPriority {
if s == slot {
return 1, i
}
}
for i, s := range clothingPriority {
if s == slot {
return 2, i
}
}
return 3, 0
}
func (sv *Solver) recursiveSearch(buckets []*ItemBucket, idx int, state *SuitState) {
if sv.stopped {
return
}
if sv.cancelled != nil && sv.cancelled() {
sv.stopped = true
return
}
if sv.highestArmorCount > 0 {
currentCount := len(state.Items)
remaining := sv.armorBucketsItems - minInt(idx, sv.armorBucketsItems)
minRequired := sv.highestArmorCount - remaining
if currentCount+1 < minRequired {
return
}
}
remainingBuckets := len(buckets) - idx
maxPossible := len(state.Items) + remainingBuckets
if sv.bestSuitItemCount > 0 && maxPossible < sv.bestSuitItemCount {
return
}
if idx >= len(buckets) {
suit := sv.finalizeSuit(state)
if suit != nil && sv.isBetterThanExisting(suit) {
sv.bestSuits = append(sv.bestSuits, suit)
if len(suit.Items) > sv.bestSuitItemCount {
sv.bestSuitItemCount = len(suit.Items)
}
armorPieces := 0
for slot := range suit.Items {
if armorSlotSet[slot] {
armorPieces++
}
}
if armorPieces > sv.highestArmorCount {
sv.highestArmorCount = armorPieces
}
sort.SliceStable(sv.bestSuits, func(i, j int) bool { return sv.bestSuits[i].Score > sv.bestSuits[j].Score })
if len(sv.bestSuits) > sv.c.MaxResults {
sv.bestSuits = sv.bestSuits[:sv.c.MaxResults]
}
sv.emit("suit", sv.suitData(suit))
sv.emit("log", map[string]any{"level": "success", "message": "Found suit", "timestamp": sv.elapsed()})
}
return
}
sv.evaluated++
if sv.evaluated%100 == 0 {
if sv.cancelled != nil && sv.cancelled() {
sv.stopped = true
return
}
bestScore := 0
if len(sv.bestSuits) > 0 {
bestScore = sv.bestSuits[0].Score
}
var curBucket any
if idx < len(buckets) {
curBucket = buckets[idx].Slot
}
el := sv.elapsed()
rate := 0.0
if el > 0 {
rate = round1(float64(sv.evaluated) / el)
}
sv.emit("progress", map[string]any{
"evaluated": sv.evaluated, "found": len(sv.bestSuits), "current_depth": idx,
"total_buckets": len(buckets), "current_items": len(state.Items), "elapsed": el,
"rate": rate, "current_bucket": curBucket, "best_score": bestScore,
})
if sv.evaluated%500 == 0 {
sv.emit("log", map[string]any{"level": "info", "message": "Evaluating combinations", "timestamp": el})
}
}
bucket := buckets[idx]
accepted := 0
for _, it := range bucket.Items {
if sv.canAddItem(it, state) {
accepted++
state.push(it)
sv.recursiveSearch(buckets, idx+1, state)
state.pop(it.Slot)
if sv.stopped {
return
}
}
}
if accepted == 0 {
sv.recursiveSearch(buckets, idx+1, state)
}
}
func (sv *Solver) canAddItem(it *SuitItem, state *SuitState) bool {
if state.Occupied[it.Slot] {
return false
}
for _, ex := range state.Items {
if ex.ID == it.ID {
return false
}
}
if it.SetID != 0 {
current := state.SetCounts[it.SetID]
if it.SetID == sv.c.PrimarySet {
if current >= sv.effPrimary {
return false
}
} else if it.SetID == sv.c.SecondarySet {
if current >= sv.effSecondary {
return false
}
} else {
if jewelrySlotSet[it.Slot] {
if !sv.jewelryContributesRequiredSpell(it, state) {
return false
}
} else {
return false
}
}
} else {
if it.Slot == "Shirt" || it.Slot == "Pants" {
// clothing allowed without set id
} else if jewelrySlotSet[it.Slot] {
if !sv.jewelryContributesRequiredSpell(it, state) {
return false
}
} else {
return false
}
}
if len(sv.c.RequiredSpells) > 0 && len(it.SpellNames) > 0 {
if !sv.canGetBeneficialSpellFrom(it, state) {
return false
}
}
return true
}
func (sv *Solver) canGetBeneficialSpellFrom(it *SuitItem, state *SuitState) bool {
if len(it.SpellNames) == 0 {
return true
}
if len(sv.c.RequiredSpells) == 0 {
return true
}
newBeneficial := it.SpellBitmap & sv.neededSpellBitmap & ^state.SpellBitmap
return newBeneficial != 0
}
func (sv *Solver) jewelryContributesRequiredSpell(it *SuitItem, state *SuitState) bool {
if len(sv.c.RequiredSpells) == 0 {
return false
}
if len(it.SpellNames) == 0 {
return false
}
for _, sp := range it.SpellNames {
bit := sv.spellIndex.getBitmap([]string{sp})
if bit&sv.neededSpellBitmap != 0 && state.SpellBitmap&bit == 0 {
return true
}
}
return false
}
func (sv *Solver) finalizeSuit(state *SuitState) *CompletedSuit {
if len(state.Items) == 0 {
return nil
}
score := sv.calculateScore(state)
var fulfilled, missing []string
if len(sv.c.RequiredSpells) > 0 {
fulfilled = sv.spellIndex.getSpellNames(state.SpellBitmap & sv.neededSpellBitmap)
missing = sv.spellIndex.getSpellNames(sv.neededSpellBitmap & ^state.SpellBitmap)
if len(sv.lockedSpells) > 0 {
for sp := range sv.lockedSpells {
missing = removeString(missing, sp)
if !containsString(fulfilled, sp) {
fulfilled = append(fulfilled, sp)
}
}
}
}
items := make(map[string]*SuitItem, len(state.Items))
for k, v := range state.Items {
items[k] = v
}
ratings := map[string]int{}
for k, v := range state.TotalRatings {
ratings[k] = v
}
setCounts := map[int]int{}
for k, v := range state.SetCounts {
setCounts[k] = v
}
return &CompletedSuit{
Items: items, Score: score, TotalArmor: state.TotalArmor,
TotalRatings: ratings, SetCounts: setCounts,
FulfilledSpells: fulfilled, MissingSpells: missing,
}
}
func (sv *Solver) calculateScore(state *SuitState) int {
score := 0
w := sv.weights
foundPrimary, foundSecondary := 0, 0
if sv.c.PrimarySet != 0 {
foundPrimary = state.SetCounts[sv.c.PrimarySet]
}
if sv.c.SecondarySet != 0 {
foundSecondary = state.SetCounts[sv.c.SecondarySet]
}
if foundPrimary >= sv.effPrimary {
score += w.ArmorSetComplete
if foundPrimary > sv.effPrimary {
score -= (foundPrimary - sv.effPrimary) * 500
}
} else if sv.c.PrimarySet != 0 && foundPrimary > 0 {
score += (sv.effPrimary - foundPrimary) * w.MissingSetPenalty
}
if foundSecondary >= sv.effSecondary {
score += w.ArmorSetComplete
if foundSecondary > sv.effSecondary {
score -= (foundSecondary - sv.effSecondary) * 500
}
} else if sv.c.SecondarySet != 0 && foundSecondary > 0 {
score += (sv.effSecondary - foundSecondary) * w.MissingSetPenalty
}
for _, it := range state.Items {
switch it.Ratings["crit_damage_rating"] {
case 1:
score += w.CritDamage1
case 2:
score += w.CritDamage2
}
}
for _, it := range state.Items {
if it.Slot == "Shirt" || it.Slot == "Pants" {
switch it.Ratings["damage_rating"] {
case 1:
score += w.DamageRating1
case 2:
score += w.DamageRating2
case 3:
score += w.DamageRating3
}
}
}
if len(sv.c.RequiredSpells) > 0 {
score += popcount(state.SpellBitmap&sv.neededSpellBitmap) * 100
}
score += len(state.Items) * 5
// Python uses floor division (//); total_armor can be negative because
// non-armor items carry armor_level = -1. Go's / truncates toward zero, so a
// slightly-negative total would be +1 too high.
score += floorDiv(state.TotalArmor, 100)
if score < 0 {
return 0
}
return score
}
func (sv *Solver) isBetterThanExisting(suit *CompletedSuit) bool {
if len(sv.bestSuits) < sv.c.MaxResults {
return true
}
lowest := sv.bestSuits[len(sv.bestSuits)-1]
if len(suit.Items) > len(lowest.Items) {
return true
}
return suit.Score > lowest.Score
}
// suitData builds the streamed suit payload (CompletedSuit.to_dict plus the
// constraint-derived stats overrides from recursive_search).
func (sv *Solver) suitData(suit *CompletedSuit) map[string]any {
d := suit.toDict()
stats := d["stats"].(map[string]any)
primaryCount, secondaryCount := 0, 0
if sv.c.PrimarySet != 0 {
primaryCount = suit.SetCounts[sv.c.PrimarySet] + sv.lockedSetCounts[sv.c.PrimarySet]
}
if sv.c.SecondarySet != 0 {
secondaryCount = suit.SetCounts[sv.c.SecondarySet] + sv.lockedSetCounts[sv.c.SecondarySet]
}
var primaryName, secondaryName any
if sv.c.PrimarySet != 0 {
primaryName = sv.s.translateSetID(strconv.Itoa(sv.c.PrimarySet))
}
if sv.c.SecondarySet != 0 {
secondaryName = sv.s.translateSetID(strconv.Itoa(sv.c.SecondarySet))
}
stats["primary_set_count"] = primaryCount
stats["secondary_set_count"] = secondaryCount
stats["primary_set"] = primaryName
stats["secondary_set"] = secondaryName
stats["locked_slots"] = len(sv.c.LockedSlots)
stats["primary_locked"] = sv.lockedSetCounts[sv.c.PrimarySet]
stats["secondary_locked"] = sv.lockedSetCounts[sv.c.SecondarySet]
return d
}
// --- small helpers ---
func max0(v int) int {
if v < 0 {
return 0
}
return v
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
// floorDiv matches Python's // (floor toward -inf), unlike Go's / (toward zero).
func floorDiv(a, b int) int {
q := a / b
if a%b != 0 && (a < 0) != (b < 0) {
q--
}
return q
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
func popcount(v uint64) int {
c := 0
for v != 0 {
v &= v - 1
c++
}
return c
}
func round1(v float64) float64 {
return float64(int64(v*10+0.5)) / 10
}
func containsString(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
func removeString(list []string, s string) []string {
for i, x := range list {
if x == s {
return append(list[:i], list[i+1:]...)
}
}
return list
}