MosswartOverlord/go-services/inventory-go/suit_solver.go
Erik 75a735d589 feat(suitbuilder): apply CD-tier filter in loadItems (before domination)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 20:35:20 +02:00

870 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
allowedCD map[int]bool // nil == no CD filter (default / all tiers)
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)
sv.allowedCD = allowedCritSet(c.AllowedCritDamage)
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)
}
}
// Drop armor whose CD tier is disallowed BEFORE domination, so a CD2 piece
// can't surpass-and-remove an allowed CD1 piece we'd then exclude.
items = filterArmorByCD(items, sv.allowedCD)
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
}