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>
864 lines
24 KiB
Go
864 lines
24 KiB
Go
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
|
|
}
|