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
183
go-services/inventory-go/slotname.go
Normal file
183
go-services/inventory-go/slotname.go
Normal file
|
|
@ -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 "-"
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue