diff --git a/go-services/inventory-go/main.go b/go-services/inventory-go/main.go index cd2d5b00..4ac34f7c 100644 --- a/go-services/inventory-go/main.go +++ b/go-services/inventory-go/main.go @@ -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 } diff --git a/go-services/inventory-go/search.go b/go-services/inventory-go/search.go index 6168aaea..a5422e67 100644 --- a/go-services/inventory-go/search.go +++ b/go-services/inventory-go/search.go @@ -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: diff --git a/go-services/inventory-go/slotname.go b/go-services/inventory-go/slotname.go new file mode 100644 index 00000000..449eb8ad --- /dev/null +++ b/go-services/inventory-go/slotname.go @@ -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 "-" +} diff --git a/go-services/inventory-go/suit_http.go b/go-services/inventory-go/suit_http.go new file mode 100644 index 00000000..d071040a --- /dev/null +++ b/go-services/inventory-go/suit_http.go @@ -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: \ndata: \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}) +} diff --git a/go-services/inventory-go/suit_model.go b/go-services/inventory-go/suit_model.go new file mode 100644 index 00000000..21b7e4c6 --- /dev/null +++ b/go-services/inventory-go/suit_model.go @@ -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 +} diff --git a/go-services/inventory-go/suit_solver.go b/go-services/inventory-go/suit_solver.go new file mode 100644 index 00000000..42b82618 --- /dev/null +++ b/go-services/inventory-go/suit_solver.go @@ -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 +}