feat(inventory-go): port the suitbuilder solver (/suitbuilder/search) — validated
Full Go port of suitbuilder.py's ConstraintSatisfactionSolver (the LIVE solver behind the suitbuilder UI; main.py's /optimize/suits is legacy/unused): - suit_model.go: CoverageMask + reductions, SuitItem/ItemBucket/SuitState, SpellBitmapIndex, ScoringWeights, SearchConstraints, CompletedSuit.to_dict, ItemPreFilter, set name<->id maps. Every sort carries (character_name, name) tiebreakers for deterministic results. - suit_solver.go: the 5-phase pipeline — load_items (fed in-process by the Go /search/items), create_buckets (+multi-slot/generic-jewelry expansion), apply_reduction_options, sort_buckets, and the depth-first recursive_search with both Mag-SuitBuilder pruning rules, can_add_item constraints (set limits, jewelry spell contribution, strict spell mode), scoring, and finalize. - suit_http.go: POST /suitbuilder/search (SSE: phase/log/suit/progress/complete), GET /suitbuilder/characters, GET /suitbuilder/sets. - search.go: refactor handleSearchItems -> shared runSearch (the solver reuses it so both see identical rows); emit slot_name (get_sophisticated_slot_options + translate_equipment_slot); fix the trinket slot_names clause to exclude %bracelet% (matches Python). - slotname.go: the EquipMask-based slot translation, loaded from the enum DB. Validation: 9/9 scenarios stream byte-identical suits vs the Python service on production data (no-spell, multi-character, locked slots with/without spells, spell constraints, alternate set pairs, primary-only). ~45x faster than Python. Three subtle bugs found and fixed during validation: - slot_name is load-bearing, not display: jewelry's computed_slot_name is empty, so load_items falls back to slot_name to bucket rings/neck/wrists/trinket. - Python scoring uses floor division (total_armor // 100); total_armor goes negative (non-armor items carry armor_level -1) so Go's truncation was +1 off. - the trinket fetch must exclude bracelets or they duplicate the Wrist buckets. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
2473b80519
commit
57f53ff36b
6 changed files with 1806 additions and 18 deletions
864
go-services/inventory-go/suit_solver.go
Normal file
864
go-services/inventory-go/suit_solver.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue