MosswartOverlord/go-services/inventory-go/suit_model.go
Erik 593e99894f feat(suitbuilder): add allowed_crit_damage constraint field
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 20:35:20 +02:00

592 lines
16 KiB
Go

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"`
AllowedCritDamage []int `json:"allowed_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
}