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>
183 lines
5.3 KiB
Go
183 lines
5.3 KiB
Go
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 "-"
|
|
}
|