MosswartOverlord/go-services/inventory-go/slotname.go
Erik 57f53ff36b 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>
2026-06-24 14:03:59 +02:00

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 "-"
}