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
|
|
@ -16,7 +16,9 @@ import (
|
|||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
|
|
@ -31,6 +33,8 @@ type Server struct {
|
|||
objectClasses map[int]string // ObjectClass: id -> name
|
||||
materials map[int]string // MaterialType: id -> name
|
||||
spells map[int]map[string]any // SpellTable: spell-id -> raw spell value object
|
||||
equipMaskMap map[int]string // EquipMask: mask -> technical name (exact lookup)
|
||||
equipMaskOrdered []equipMaskEntry // EquipMask in ascending-mask order (bit-flag decode)
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
|
|
@ -57,7 +61,9 @@ func main() {
|
|||
srv.objectClasses = e.objectClasses
|
||||
srv.materials = e.materials
|
||||
srv.spells = e.spells
|
||||
logger.Info("loaded enum DB", "sets", len(e.sets), "object_classes", len(e.objectClasses), "materials", len(e.materials), "spells", len(e.spells))
|
||||
srv.equipMaskMap = e.equipMaskMap
|
||||
srv.equipMaskOrdered = e.equipMaskOrdered
|
||||
logger.Info("loaded enum DB", "sets", len(e.sets), "object_classes", len(e.objectClasses), "materials", len(e.materials), "spells", len(e.spells), "equip_masks", len(e.equipMaskOrdered))
|
||||
}
|
||||
|
||||
if dsn == "" {
|
||||
|
|
@ -92,6 +98,10 @@ func main() {
|
|||
mux.HandleFunc("POST /process-inventory", srv.handleProcessInventory)
|
||||
mux.HandleFunc("POST /inventory/{character_name}/item", srv.handleUpsertItem)
|
||||
mux.HandleFunc("DELETE /inventory/{character_name}/item/{item_id}", srv.handleDeleteItem)
|
||||
// Suitbuilder (port of suitbuilder.py router, mounted at /suitbuilder).
|
||||
mux.HandleFunc("POST /suitbuilder/search", srv.handleSuitSearch)
|
||||
mux.HandleFunc("GET /suitbuilder/characters", srv.handleSuitCharacters)
|
||||
mux.HandleFunc("GET /suitbuilder/sets", srv.handleSuitSets)
|
||||
|
||||
httpSrv := &http.Server{Addr: addr, Handler: withLogging(mux), ReadHeaderTimeout: 10 * time.Second}
|
||||
go func() {
|
||||
|
|
@ -181,10 +191,12 @@ func (s *Server) dbErr(w http.ResponseWriter, where string, err error) {
|
|||
}
|
||||
|
||||
type enumMaps struct {
|
||||
sets map[string]string
|
||||
objectClasses map[int]string
|
||||
materials map[int]string
|
||||
spells map[int]map[string]any
|
||||
sets map[string]string
|
||||
objectClasses map[int]string
|
||||
materials map[int]string
|
||||
spells map[int]map[string]any
|
||||
equipMaskMap map[int]string
|
||||
equipMaskOrdered []equipMaskEntry
|
||||
}
|
||||
|
||||
// loadEnums reads the comprehensive enum DB and extracts AttributeSetInfo
|
||||
|
|
@ -234,6 +246,19 @@ func loadEnums(path string) (enumMaps, error) {
|
|||
em.spells[n] = v
|
||||
}
|
||||
}
|
||||
// EquipMask: mask -> technical name. Skip EXPR: keys; order by ascending mask
|
||||
// (the JSON order) so multi-bit bit-flag decode joins parts deterministically.
|
||||
em.equipMaskMap = map[int]string{}
|
||||
for k, v := range db.Enums["EquipMask"].Values {
|
||||
if strings.HasPrefix(k, "EXPR:") {
|
||||
continue
|
||||
}
|
||||
if n, err := strconv.Atoi(k); err == nil {
|
||||
em.equipMaskMap[n] = v
|
||||
em.equipMaskOrdered = append(em.equipMaskOrdered, equipMaskEntry{Mask: n, Name: v})
|
||||
}
|
||||
}
|
||||
sort.Slice(em.equipMaskOrdered, func(i, j int) bool { return em.equipMaskOrdered[i].Mask < em.equipMaskOrdered[j].Mask })
|
||||
return em, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -57,6 +58,7 @@ SELECT DISTINCT
|
|||
COALESCE(enh.tinks, -1) AS tinks,
|
||||
COALESCE(enh.item_set, '') AS item_set,
|
||||
COALESCE((rd.int_values->>'218103821')::int, 0) AS coverage_mask,
|
||||
COALESCE((rd.int_values->>'218103822')::int, 0) AS equippable_slots,
|
||||
CASE
|
||||
WHEN rd.original_json IS NOT NULL
|
||||
AND rd.original_json->'IntValues'->>'218103822' IS NOT NULL
|
||||
|
|
@ -142,7 +144,18 @@ func (b *argBuilder) add(v any) string {
|
|||
}
|
||||
|
||||
func (s *Server) handleSearchItems(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
res, err := s.runSearch(r.Context(), r.URL.Query())
|
||||
if err != nil {
|
||||
s.dbErr(w, "search/items", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// runSearch executes /search/items and returns the response object (items +
|
||||
// pagination, or an {error,...} object for invalid params). Shared by the HTTP
|
||||
// handler and the suitbuilder solver's load_items, so both see identical rows.
|
||||
func (s *Server) runSearch(ctx context.Context, q url.Values) (map[string]any, error) {
|
||||
ab := &argBuilder{}
|
||||
var conds []string
|
||||
|
||||
|
|
@ -152,8 +165,7 @@ func (s *Server) handleSearchItems(w http.ResponseWriter, r *http.Request) {
|
|||
} else if cs := q.Get("characters"); cs != "" {
|
||||
names := splitNonEmpty(cs)
|
||||
if len(names) == 0 {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"error": "Empty characters list provided", "items": []any{}, "total_count": 0})
|
||||
return
|
||||
return map[string]any{"error": "Empty characters list provided", "items": []any{}, "total_count": 0}, nil
|
||||
}
|
||||
ph := make([]string, len(names))
|
||||
for i, n := range names {
|
||||
|
|
@ -161,8 +173,7 @@ func (s *Server) handleSearchItems(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
conds = append(conds, "character_name IN ("+strings.Join(ph, ", ")+")")
|
||||
} else if !qBool(q, "include_all_characters") {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"error": "Must specify character, characters, or set include_all_characters=true", "items": []any{}, "total_count": 0})
|
||||
return
|
||||
return map[string]any{"error": "Must specify character, characters, or set include_all_characters=true", "items": []any{}, "total_count": 0}, nil
|
||||
}
|
||||
|
||||
// --- text ---
|
||||
|
|
@ -312,7 +323,7 @@ func (s *Server) handleSearchItems(w http.ResponseWriter, r *http.Request) {
|
|||
limit := clampInt(qIntDefault(q, "limit", 200), 1, 50000)
|
||||
offset := (page - 1) * limit
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Underwear filters (shirt_only/pants_only/underwear_only) are injected into
|
||||
|
|
@ -327,8 +338,7 @@ func (s *Server) handleSearchItems(w http.ResponseWriter, r *http.Request) {
|
|||
" LIMIT " + ab.add(limit) + " OFFSET " + ab.add(offset)
|
||||
rows, err := queryRowsAsMaps(ctx, s.pool, mainSQL, ab.args...)
|
||||
if err != nil {
|
||||
s.dbErr(w, "search/items", err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// count uses the SAME CTE (incl. the underwear injection) + conditions, so
|
||||
|
|
@ -342,20 +352,19 @@ func (s *Server) handleSearchItems(w http.ResponseWriter, r *http.Request) {
|
|||
countSQL := cte + "SELECT COUNT(DISTINCT db_item_id) FROM items_with_slots" + where
|
||||
var totalCount int64
|
||||
if err := s.pool.QueryRow(ctx, countSQL, ab.args[:len(ab.args)-2]...).Scan(&totalCount); err != nil {
|
||||
s.dbErr(w, "search/items count", err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := s.enrichRows(rows)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
return map[string]any{
|
||||
"items": items,
|
||||
"total_count": totalCount,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"has_next": int64(page*limit) < totalCount,
|
||||
"has_previous": page > 1,
|
||||
})
|
||||
}, nil
|
||||
}
|
||||
|
||||
// enrichRows applies the direct-column transforms (computed booleans, condition,
|
||||
|
|
@ -428,6 +437,25 @@ func (s *Server) enrichRows(rows []map[string]any) []map[string]any {
|
|||
}
|
||||
delete(row, "spell_ids_ordered")
|
||||
|
||||
// slot_name — sophisticated equipment-slot translation (main.py:3977-4033).
|
||||
// Load-bearing for the suitbuilder: jewelry has an empty computed_slot_name,
|
||||
// so load_items falls back to this to bucket rings/neck/wrists/trinket.
|
||||
eq := int(toInt64(row["equippable_slots"]))
|
||||
hasMat := toStr(row["material"]) != ""
|
||||
row["slot_name"] = s.computeSlotName(eq, int(toInt64(row["coverage_mask"])), hasMat)
|
||||
delete(row, "equippable_slots")
|
||||
|
||||
// Gear-total display ratings (main.py:4035-4072): damage_rating,
|
||||
// crit_damage_rating, heal_boost_rating only. The CTE already does
|
||||
// GREATEST(individual, gear-key 370/374/376), so the gear-positive rescue
|
||||
// branch is dead — the net rule is simply -1 -> null. The other three
|
||||
// solver ratings (damage_resist/crit_damage_resist/vitality) stay -1.
|
||||
for _, f := range []string{"damage_rating", "crit_damage_rating", "heal_boost_rating"} {
|
||||
if toInt64(row[f]) == -1 {
|
||||
row[f] = nil
|
||||
}
|
||||
}
|
||||
|
||||
delete(row, "db_item_id")
|
||||
out = append(out, row)
|
||||
}
|
||||
|
|
@ -538,7 +566,10 @@ func slotNameClause(name string, ab *argBuilder) string {
|
|||
case "neck":
|
||||
return "((computed_slot_name ILIKE " + ab.add("%neck%") + ") OR (object_class = 4 AND (name ILIKE '%amulet%' OR name ILIKE '%necklace%' OR name ILIKE '%gorget%')))"
|
||||
case "trinket":
|
||||
return "((computed_slot_name ILIKE " + ab.add("%trinket%") + ") OR (current_wielded_location = 67108864) OR (object_class = 4 AND (name ILIKE '%trinket%' OR name ILIKE '%compass%' OR name ILIKE '%goggles%')) OR (object_class = 11 AND name ILIKE '%trinket%') OR (object_class = 4 AND name NOT ILIKE '%ring%' AND name NOT ILIKE '%amulet%' AND name NOT ILIKE '%necklace%' AND name NOT ILIKE '%gorget%'))"
|
||||
// Approach 5 (jewelry fallback) MUST exclude %bracelet% — without it the
|
||||
// Trinket fetch sweeps in bracelets, which then duplicate the Wrist buckets
|
||||
// (also fetched via slot_names=Bracelet) and the DFS re-emits suits.
|
||||
return "((computed_slot_name ILIKE " + ab.add("%trinket%") + ") OR (current_wielded_location = 67108864) OR (object_class = 4 AND (name ILIKE '%trinket%' OR name ILIKE '%compass%' OR name ILIKE '%goggles%')) OR (object_class = 11 AND name ILIKE '%trinket%') OR (object_class = 4 AND name NOT ILIKE '%ring%' AND name NOT ILIKE '%bracelet%' AND name NOT ILIKE '%amulet%' AND name NOT ILIKE '%necklace%' AND name NOT ILIKE '%gorget%'))"
|
||||
case "cloak":
|
||||
return "((computed_slot_name ILIKE " + ab.add("%cloak%") + ") OR (name ILIKE '%cloak%') OR (computed_slot_name = 'Cloak'))"
|
||||
default:
|
||||
|
|
|
|||
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 "-"
|
||||
}
|
||||
92
go-services/inventory-go/suit_http.go
Normal file
92
go-services/inventory-go/suit_http.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Suitbuilder endpoints — port of suitbuilder.py's router (mounted at
|
||||
// /suitbuilder in the Python service). The live UI hits /inv/suitbuilder/* on
|
||||
// the tracker, which proxies here; we expose the same contract for parallel
|
||||
// validation.
|
||||
|
||||
// POST /suitbuilder/search — streams SSE events (event: <type>\ndata: <json>\n\n).
|
||||
func (s *Server) handleSuitSearch(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
// Pydantic defaults applied before decode; json.Unmarshal only overwrites
|
||||
// fields present in the body.
|
||||
c := SearchConstraints{IncludeEquipped: true, IncludeInventory: true, MaxResults: 50, SearchTimeout: 300}
|
||||
if err := json.Unmarshal(body, &c); err != nil {
|
||||
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"detail": "invalid SearchConstraints"})
|
||||
return
|
||||
}
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "streaming unsupported"})
|
||||
return
|
||||
}
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", "text/event-stream")
|
||||
h.Set("Cache-Control", "no-cache")
|
||||
h.Set("Connection", "keep-alive")
|
||||
h.Set("Access-Control-Allow-Origin", "*")
|
||||
h.Set("Access-Control-Allow-Headers", "Cache-Control")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
var mu sync.Mutex
|
||||
emit := func(event string, data map[string]any) {
|
||||
b, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
b, _ = json.Marshal(map[string]any{"message": "Serialization error: " + err.Error()})
|
||||
event = "error"
|
||||
}
|
||||
mu.Lock()
|
||||
fmt.Fprintf(w, "event: %s\n", event)
|
||||
fmt.Fprintf(w, "data: %s\n\n", b)
|
||||
flusher.Flush()
|
||||
mu.Unlock()
|
||||
}
|
||||
cancelled := func() bool {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
sv := newSolver(s, c, emit, cancelled)
|
||||
sv.Search(r.Context())
|
||||
}
|
||||
|
||||
// GET /suitbuilder/characters (suitbuilder.py:2085).
|
||||
func (s *Server) handleSuitCharacters(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
rows, err := queryRowsAsMaps(ctx, s.pool, `SELECT DISTINCT character_name FROM items ORDER BY character_name`)
|
||||
if err != nil {
|
||||
s.dbErr(w, "suitbuilder/characters", err)
|
||||
return
|
||||
}
|
||||
chars := make([]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
chars = append(chars, row["character_name"])
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"characters": chars})
|
||||
}
|
||||
|
||||
// GET /suitbuilder/sets (suitbuilder.py:2195) — the hardcoded set list.
|
||||
func (s *Server) handleSuitSets(w http.ResponseWriter, r *http.Request) {
|
||||
order := []int{14, 16, 13, 21, 40, 41, 46, 47, 48, 15, 19, 20, 22, 24, 26, 29}
|
||||
sets := make([]map[string]any, 0, len(order))
|
||||
for _, id := range order {
|
||||
sets = append(sets, map[string]any{"id": id, "name": setNames[id]})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"sets": sets})
|
||||
}
|
||||
593
go-services/inventory-go/suit_model.go
Normal file
593
go-services/inventory-go/suit_model.go
Normal file
|
|
@ -0,0 +1,593 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"math/bits"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Port of inventory-service/suitbuilder.py data model. This is the LIVE solver
|
||||
// (mounted at /suitbuilder/search; main.py's /optimize/suits is legacy/unused).
|
||||
// Every sort carries (character_name, name) tiebreakers so results are
|
||||
// deterministic and reproducible, exactly as the Python source documents.
|
||||
|
||||
// --- Equipment set name<->id maps (suitbuilder.py SET_NAMES / _convert_set_name_to_id) ---
|
||||
|
||||
var setNames = map[int]string{
|
||||
14: "Adept's", 16: "Defender's", 13: "Soldier's", 21: "Wise",
|
||||
40: "Heroic Protector", 41: "Heroic Destroyer", 46: "Relic Alduressa",
|
||||
47: "Ancient Relic", 48: "Noble Relic", 15: "Archer's", 19: "Hearty",
|
||||
20: "Dexterous", 22: "Swift", 24: "Reinforced", 26: "Flame Proof",
|
||||
29: "Lightning Proof",
|
||||
}
|
||||
|
||||
// nameToSetID is the reverse map used by load_items to turn the search's
|
||||
// item_set field into a numeric set id (note the " Set" suffix, verbatim).
|
||||
var nameToSetID = map[string]int{
|
||||
"Adept's Set": 14, "Defender's Set": 16, "Soldier's Set": 13, "Wise Set": 21,
|
||||
"Heroic Protector Set": 40, "Heroic Destroyer Set": 41, "Relic Alduressa Set": 46,
|
||||
"Ancient Relic Set": 47, "Noble Relic Set": 48, "Archer's Set": 15,
|
||||
"Hearty Set": 19, "Dexterous Set": 20, "Swift Set": 22, "Reinforced Set": 24,
|
||||
"Flame Proof Set": 26, "Lightning Proof Set": 29,
|
||||
}
|
||||
|
||||
// getSetName mirrors suitbuilder.get_set_name (None/0 -> "").
|
||||
func getSetName(setID int) string {
|
||||
if setID == 0 {
|
||||
return ""
|
||||
}
|
||||
if n, ok := setNames[setID]; ok {
|
||||
return n
|
||||
}
|
||||
return fmt.Sprintf("Set %d", setID)
|
||||
}
|
||||
|
||||
func convertSetNameToID(setName string) int { return nameToSetID[setName] }
|
||||
|
||||
// --- CoverageMask (suitbuilder.py:81) ---
|
||||
|
||||
const (
|
||||
covUnderwearUpperLegs = 0x00000002
|
||||
covUnderwearLowerLegs = 0x00000004
|
||||
covUnderwearChest = 0x00000008
|
||||
covUnderwearAbdomen = 0x00000010
|
||||
covUnderwearUpperArms = 0x00000020
|
||||
covUnderwearLowerArms = 0x00000040
|
||||
covOuterUpperLegs = 0x00000100
|
||||
covOuterLowerLegs = 0x00000200
|
||||
covOuterChest = 0x00000400
|
||||
covOuterAbdomen = 0x00000800
|
||||
covOuterUpperArms = 0x00001000
|
||||
covOuterLowerArms = 0x00002000
|
||||
covHead = 0x00004000
|
||||
covHands = 0x00008000
|
||||
covFeet = 0x00010000
|
||||
|
||||
// Aliases matching slot names (suitbuilder.py:110-115).
|
||||
covChest = covOuterChest
|
||||
covAbdomen = covOuterAbdomen
|
||||
covUpperArms = covOuterUpperArms
|
||||
covLowerArms = covOuterLowerArms
|
||||
covUpperLegs = covOuterUpperLegs
|
||||
covLowerLegs = covOuterLowerLegs
|
||||
|
||||
magRobePattern = 0x00013F00
|
||||
)
|
||||
|
||||
// coverageReductionOptions mirrors CoverageMask.reduction_options().
|
||||
func coverageReductionOptions(v int) []int {
|
||||
if bits.OnesCount(uint(v)) <= 1 {
|
||||
return nil
|
||||
}
|
||||
if coverageIsRobe(v) {
|
||||
return nil
|
||||
}
|
||||
switch v {
|
||||
case covUpperArms | covLowerArms:
|
||||
return []int{covUpperArms, covLowerArms}
|
||||
case covUpperLegs | covLowerLegs:
|
||||
return []int{covUpperLegs, covLowerLegs}
|
||||
case covLowerLegs | covFeet:
|
||||
return []int{covFeet}
|
||||
case covChest | covAbdomen:
|
||||
return []int{covChest}
|
||||
case covChest | covAbdomen | covUpperArms:
|
||||
return []int{covChest}
|
||||
case covChest | covUpperArms | covLowerArms:
|
||||
return []int{covChest}
|
||||
case covChest | covUpperArms:
|
||||
return []int{covChest}
|
||||
case covAbdomen | covUpperLegs | covLowerLegs:
|
||||
return []int{covAbdomen, covUpperLegs, covLowerLegs}
|
||||
case covChest | covAbdomen | covUpperArms | covLowerArms:
|
||||
return []int{covChest}
|
||||
case covAbdomen | covUpperLegs:
|
||||
return []int{covAbdomen}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// coverageIsRobe mirrors CoverageMask.is_robe() (exact pattern == component
|
||||
// pattern == 0x13F00; otherwise the 6+ coverage-areas fallback).
|
||||
func coverageIsRobe(v int) bool {
|
||||
if v == magRobePattern {
|
||||
return true
|
||||
}
|
||||
return bits.OnesCount(uint(v)) >= 6
|
||||
}
|
||||
|
||||
// coverageToSlotName mirrors CoverageMask.to_slot_name() (single coverage only).
|
||||
func coverageToSlotName(v int) string {
|
||||
switch v {
|
||||
case covHead:
|
||||
return "Head"
|
||||
case covChest:
|
||||
return "Chest"
|
||||
case covUpperArms:
|
||||
return "Upper Arms"
|
||||
case covLowerArms:
|
||||
return "Lower Arms"
|
||||
case covHands:
|
||||
return "Hands"
|
||||
case covAbdomen:
|
||||
return "Abdomen"
|
||||
case covUpperLegs:
|
||||
return "Upper Legs"
|
||||
case covLowerLegs:
|
||||
return "Lower Legs"
|
||||
case covFeet:
|
||||
return "Feet"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --- SuitItem (suitbuilder.py:221) ---
|
||||
|
||||
type SuitItem struct {
|
||||
ID string // unique per (character,name); used for uniqueness checks
|
||||
Name string
|
||||
CharacterName string
|
||||
Slot string
|
||||
Coverage int // 0 == None
|
||||
HasCoverage bool
|
||||
SetID int // 0 == None
|
||||
ArmorLevel int
|
||||
Ratings map[string]int
|
||||
SpellBitmap uint64
|
||||
SpellNames []string
|
||||
IsLocked bool
|
||||
Material string
|
||||
}
|
||||
|
||||
func (it *SuitItem) ratingsSum() int {
|
||||
s := 0
|
||||
for _, v := range it.Ratings {
|
||||
s += v
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (it *SuitItem) ratingsSumExcept(skip string) int {
|
||||
s := 0
|
||||
for k, v := range it.Ratings {
|
||||
if k != skip {
|
||||
s += v
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (it *SuitItem) clone(slot string, name string, coverage int, hasCov bool) *SuitItem {
|
||||
r := make(map[string]int, len(it.Ratings))
|
||||
for k, v := range it.Ratings {
|
||||
r[k] = v
|
||||
}
|
||||
sn := make([]string, len(it.SpellNames))
|
||||
copy(sn, it.SpellNames)
|
||||
return &SuitItem{
|
||||
ID: it.ID, Name: name, CharacterName: it.CharacterName, Slot: slot,
|
||||
Coverage: coverage, HasCoverage: hasCov, SetID: it.SetID, ArmorLevel: it.ArmorLevel,
|
||||
Ratings: r, SpellBitmap: it.SpellBitmap, SpellNames: sn, IsLocked: it.IsLocked,
|
||||
Material: it.Material,
|
||||
}
|
||||
}
|
||||
|
||||
// --- ItemBucket (suitbuilder.py:247) ---
|
||||
|
||||
type ItemBucket struct {
|
||||
Slot string
|
||||
Items []*SuitItem
|
||||
IsArmor bool
|
||||
}
|
||||
|
||||
var clothingSortSlots = map[string]bool{"Shirt": true, "Pants": true}
|
||||
|
||||
// sortItems mirrors ItemBucket.sort_items() (reverse=True over the key tuple,
|
||||
// stable so equal keys keep prior order).
|
||||
func (b *ItemBucket) sortItems() {
|
||||
items := b.Items
|
||||
if _, isClothing := clothingSortSlots[b.Slot]; isClothing {
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
return descTuple(
|
||||
cmpInt(items[i].Ratings["damage_rating"], items[j].Ratings["damage_rating"]),
|
||||
cmpInt(len(items[i].SpellNames), len(items[j].SpellNames)),
|
||||
cmpInt(items[i].ratingsSumExcept("damage_rating"), items[j].ratingsSumExcept("damage_rating")),
|
||||
cmpStr(items[i].CharacterName, items[j].CharacterName),
|
||||
cmpStr(items[i].Name, items[j].Name),
|
||||
)
|
||||
})
|
||||
} else if b.IsArmor {
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
return descTuple(
|
||||
cmpInt(items[i].ArmorLevel, items[j].ArmorLevel),
|
||||
cmpInt(items[i].Ratings["crit_damage_rating"], items[j].Ratings["crit_damage_rating"]),
|
||||
cmpInt(len(items[i].SpellNames), len(items[j].SpellNames)),
|
||||
cmpInt(items[i].ratingsSum(), items[j].ratingsSum()),
|
||||
cmpStr(items[i].CharacterName, items[j].CharacterName),
|
||||
cmpStr(items[i].Name, items[j].Name),
|
||||
)
|
||||
})
|
||||
} else {
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
return descTuple(
|
||||
cmpInt(len(items[i].SpellNames), len(items[j].SpellNames)),
|
||||
cmpInt(items[i].ratingsSum(), items[j].ratingsSum()),
|
||||
cmpStr(items[i].CharacterName, items[j].CharacterName),
|
||||
cmpStr(items[i].Name, items[j].Name),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// descTuple returns true if the left tuple sorts before the right under Python's
|
||||
// reverse=True (i.e. the larger tuple comes first). cmp* return -1/0/1.
|
||||
func descTuple(cmps ...int) bool {
|
||||
for _, c := range cmps {
|
||||
if c != 0 {
|
||||
return c > 0 // larger first
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func cmpInt(a, b int) int {
|
||||
switch {
|
||||
case a < b:
|
||||
return -1
|
||||
case a > b:
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmpStr(a, b string) int { return strings.Compare(a, b) }
|
||||
|
||||
// --- SpellBitmapIndex (suitbuilder.py:299) ---
|
||||
|
||||
type SpellBitmapIndex struct {
|
||||
spellToBit map[string]uint64
|
||||
order []struct {
|
||||
bit uint64
|
||||
name string
|
||||
}
|
||||
nextBit uint
|
||||
}
|
||||
|
||||
func newSpellBitmapIndex() *SpellBitmapIndex {
|
||||
return &SpellBitmapIndex{spellToBit: map[string]uint64{}}
|
||||
}
|
||||
|
||||
func (s *SpellBitmapIndex) registerSpell(name string) uint64 {
|
||||
if b, ok := s.spellToBit[name]; ok {
|
||||
return b
|
||||
}
|
||||
var bit uint64
|
||||
if s.nextBit < 64 {
|
||||
bit = uint64(1) << s.nextBit
|
||||
} // >=64: bit stays 0 (only non-required spells ever reach here; required
|
||||
// spells are registered first, so their low bits are always exact).
|
||||
s.spellToBit[name] = bit
|
||||
s.order = append(s.order, struct {
|
||||
bit uint64
|
||||
name string
|
||||
}{bit, name})
|
||||
s.nextBit++
|
||||
return bit
|
||||
}
|
||||
|
||||
func (s *SpellBitmapIndex) getBitmap(spells []string) uint64 {
|
||||
var m uint64
|
||||
for _, sp := range spells {
|
||||
m |= s.registerSpell(sp)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (s *SpellBitmapIndex) getSpellNames(bitmap uint64) []string {
|
||||
var out []string
|
||||
for _, e := range s.order {
|
||||
if e.bit != 0 && bitmap&e.bit != 0 {
|
||||
out = append(out, e.name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- SuitState (suitbuilder.py:342) ---
|
||||
|
||||
type SuitState struct {
|
||||
Items map[string]*SuitItem
|
||||
SpellBitmap uint64
|
||||
SetCounts map[int]int
|
||||
TotalArmor int
|
||||
TotalRatings map[string]int
|
||||
Occupied map[string]bool
|
||||
}
|
||||
|
||||
func newSuitState() *SuitState {
|
||||
return &SuitState{
|
||||
Items: map[string]*SuitItem{}, SetCounts: map[int]int{},
|
||||
TotalRatings: map[string]int{}, Occupied: map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
func (st *SuitState) push(it *SuitItem) {
|
||||
st.Items[it.Slot] = it
|
||||
st.Occupied[it.Slot] = true
|
||||
st.SpellBitmap |= it.SpellBitmap
|
||||
if it.SetID != 0 {
|
||||
st.SetCounts[it.SetID]++
|
||||
}
|
||||
st.TotalArmor += it.ArmorLevel
|
||||
for k, v := range it.Ratings {
|
||||
st.TotalRatings[k] += v
|
||||
}
|
||||
}
|
||||
|
||||
func (st *SuitState) pop(slot string) {
|
||||
it, ok := st.Items[slot]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
delete(st.Items, slot)
|
||||
delete(st.Occupied, slot)
|
||||
// Rebuild spell bitmap (overlaps prevent simple subtraction).
|
||||
st.SpellBitmap = 0
|
||||
for _, r := range st.Items {
|
||||
st.SpellBitmap |= r.SpellBitmap
|
||||
}
|
||||
if it.SetID != 0 {
|
||||
st.SetCounts[it.SetID]--
|
||||
if st.SetCounts[it.SetID] == 0 {
|
||||
delete(st.SetCounts, it.SetID)
|
||||
}
|
||||
}
|
||||
st.TotalArmor -= it.ArmorLevel
|
||||
for k, v := range it.Ratings {
|
||||
if _, present := st.TotalRatings[k]; present {
|
||||
st.TotalRatings[k] -= v
|
||||
if st.TotalRatings[k] <= 0 {
|
||||
delete(st.TotalRatings, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- ScoringWeights / SearchConstraints (suitbuilder.py:409,426) ---
|
||||
|
||||
type ScoringWeights struct {
|
||||
ArmorSetComplete int
|
||||
MissingSetPenalty int
|
||||
CritDamage1 int
|
||||
CritDamage2 int
|
||||
DamageRating1 int
|
||||
DamageRating2 int
|
||||
DamageRating3 int
|
||||
}
|
||||
|
||||
func defaultScoringWeights() ScoringWeights {
|
||||
return ScoringWeights{
|
||||
ArmorSetComplete: 1000, MissingSetPenalty: -200,
|
||||
CritDamage1: 10, CritDamage2: 20,
|
||||
DamageRating1: 10, DamageRating2: 20, DamageRating3: 30,
|
||||
}
|
||||
}
|
||||
|
||||
type LockedSlotInfo struct {
|
||||
SetID int `json:"set_id"`
|
||||
Spells []string `json:"spells"`
|
||||
}
|
||||
|
||||
type SearchConstraints struct {
|
||||
Characters []string `json:"characters"`
|
||||
PrimarySet int `json:"primary_set"`
|
||||
SecondarySet int `json:"secondary_set"`
|
||||
RequiredSpells []string `json:"required_spells"`
|
||||
LockedSlots map[string]LockedSlotInfo `json:"locked_slots"`
|
||||
IncludeEquipped bool `json:"include_equipped"`
|
||||
IncludeInventory bool `json:"include_inventory"`
|
||||
MinArmor *int `json:"min_armor"`
|
||||
MaxArmor *int `json:"max_armor"`
|
||||
MinCritDamage *int `json:"min_crit_damage"`
|
||||
MaxCritDamage *int `json:"max_crit_damage"`
|
||||
MinDamageRating *int `json:"min_damage_rating"`
|
||||
MaxDamageRating *int `json:"max_damage_rating"`
|
||||
ScoringWeights *ScoringWeights `json:"scoring_weights"`
|
||||
MaxResults int `json:"max_results"`
|
||||
SearchTimeout int `json:"search_timeout"`
|
||||
}
|
||||
|
||||
// --- CompletedSuit (suitbuilder.py:446) ---
|
||||
|
||||
type CompletedSuit struct {
|
||||
Items map[string]*SuitItem
|
||||
Score int
|
||||
TotalArmor int
|
||||
TotalRatings map[string]int
|
||||
SetCounts map[int]int
|
||||
FulfilledSpells []string
|
||||
MissingSpells []string
|
||||
}
|
||||
|
||||
func fnvInt(s string) int {
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(s))
|
||||
return int(h.Sum32())
|
||||
}
|
||||
|
||||
// toDict mirrors CompletedSuit.to_dict(). The opaque id fields are derived
|
||||
// deterministically (Python uses salted hash(); we use FNV) — never compared in
|
||||
// validation.
|
||||
func (c *CompletedSuit) toDict() map[string]any {
|
||||
transferByChar := map[string][]string{}
|
||||
totalItems := 0
|
||||
// Slots iterated in Python dict order; use a sorted-slot order for stable
|
||||
// transfer instructions (instructions are display-only).
|
||||
for _, it := range c.Items {
|
||||
transferByChar[it.CharacterName] = append(transferByChar[it.CharacterName], it.Name)
|
||||
totalItems++
|
||||
}
|
||||
chars := make([]string, 0, len(transferByChar))
|
||||
for ch := range transferByChar {
|
||||
chars = append(chars, ch)
|
||||
}
|
||||
sort.Strings(chars)
|
||||
instructions := []string{}
|
||||
step := 1
|
||||
for _, ch := range chars {
|
||||
for _, name := range transferByChar[ch] {
|
||||
instructions = append(instructions, fmt.Sprintf("%d. Transfer %s from %s to new character", step, name, ch))
|
||||
step++
|
||||
}
|
||||
}
|
||||
instructions = append(instructions, fmt.Sprintf("%d. Equip all transferred items on new character", step))
|
||||
|
||||
slotKeys := make([]string, 0, len(c.Items))
|
||||
for slot := range c.Items {
|
||||
slotKeys = append(slotKeys, slot)
|
||||
}
|
||||
sort.Strings(slotKeys)
|
||||
itemsOut := map[string]any{}
|
||||
for _, slot := range slotKeys {
|
||||
it := c.Items[slot]
|
||||
var setIDOut any
|
||||
if it.SetID != 0 {
|
||||
setIDOut = it.SetID
|
||||
}
|
||||
itemsOut[slot] = map[string]any{
|
||||
"id": fnvInt(it.ID),
|
||||
"name": it.Name,
|
||||
"source_character": it.CharacterName,
|
||||
"armor_level": it.ArmorLevel,
|
||||
"ratings": it.Ratings,
|
||||
"spells": it.SpellNames,
|
||||
"set_id": setIDOut,
|
||||
"set_name": getSetName(it.SetID),
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"id": fnvInt(strings.Join(slotKeys, "|")),
|
||||
"score": c.Score,
|
||||
"items": itemsOut,
|
||||
"stats": map[string]any{
|
||||
"total_armor": c.TotalArmor,
|
||||
"total_crit_damage": c.TotalRatings["crit_damage_rating"],
|
||||
"total_damage_rating": c.TotalRatings["damage_rating"],
|
||||
"primary_set_count": 0,
|
||||
"secondary_set_count": 0,
|
||||
"spell_coverage": len(c.FulfilledSpells),
|
||||
},
|
||||
"missing": c.MissingSpells,
|
||||
"notes": []any{},
|
||||
"transfer_summary": map[string]any{
|
||||
"total_items": totalItems,
|
||||
"from_characters": transferByChar,
|
||||
},
|
||||
"instructions": instructions,
|
||||
}
|
||||
}
|
||||
|
||||
// --- ItemPreFilter (suitbuilder.py:519) ---
|
||||
|
||||
func removeSurpassedItems(items []*SuitItem) []*SuitItem {
|
||||
out := make([]*SuitItem, 0, len(items))
|
||||
for _, it := range items {
|
||||
surpassed := false
|
||||
for _, cmp := range items {
|
||||
if cmp == it {
|
||||
continue
|
||||
}
|
||||
if isSurpassedBy(it, cmp) {
|
||||
surpassed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !surpassed {
|
||||
out = append(out, it)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isSurpassedBy(item, cmp *SuitItem) bool {
|
||||
if item.Slot != cmp.Slot {
|
||||
return false
|
||||
}
|
||||
if item.SetID != cmp.SetID {
|
||||
return false
|
||||
}
|
||||
if !spellsSurpassOrEqual(cmp.SpellNames, item.SpellNames) {
|
||||
return false
|
||||
}
|
||||
betterInSomething := false
|
||||
for _, key := range []string{"crit_damage_rating", "damage_rating"} {
|
||||
ir := item.Ratings[key]
|
||||
cr := cmp.Ratings[key]
|
||||
if cr > ir {
|
||||
betterInSomething = true
|
||||
} else if ir > cr {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if item.ArmorLevel > 0 && cmp.ArmorLevel > 0 {
|
||||
if cmp.ArmorLevel > item.ArmorLevel {
|
||||
betterInSomething = true
|
||||
} else if item.ArmorLevel > cmp.ArmorLevel {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return betterInSomething
|
||||
}
|
||||
|
||||
func spellsSurpassOrEqual(spells1, spells2 []string) bool {
|
||||
for _, s2 := range spells2 {
|
||||
found := false
|
||||
for _, s1 := range spells1 {
|
||||
if s1 == s2 || spellSurpasses(s1, s2) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func spellSurpasses(s1, s2 string) bool {
|
||||
if strings.Contains(s1, "Legendary") && (strings.Contains(s2, "Epic") || strings.Contains(s2, "Major")) {
|
||||
b1 := strings.ReplaceAll(s1, "Legendary ", "")
|
||||
b2 := strings.ReplaceAll(strings.ReplaceAll(s2, "Epic ", ""), "Major ", "")
|
||||
return b1 == b2
|
||||
}
|
||||
if strings.Contains(s1, "Epic") && strings.Contains(s2, "Major") {
|
||||
b1 := strings.ReplaceAll(s1, "Epic ", "")
|
||||
b2 := strings.ReplaceAll(s2, "Major ", "")
|
||||
return b1 == b2
|
||||
}
|
||||
return false
|
||||
}
|
||||
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