diff --git a/go-services/inventory-go/main.go b/go-services/inventory-go/main.go index e5935a54..2f41cbd0 100644 --- a/go-services/inventory-go/main.go +++ b/go-services/inventory-go/main.go @@ -28,6 +28,7 @@ type Server struct { pool *pgxpool.Pool attributeSets map[string]string // AttributeSetInfo: set-id -> set name objectClasses map[int]string // ObjectClass: id -> name + materials map[int]string // MaterialType: id -> name log *slog.Logger } @@ -45,14 +46,15 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() - srv := &Server{log: logger, attributeSets: map[string]string{}, objectClasses: map[int]string{}} + srv := &Server{log: logger, attributeSets: map[string]string{}, objectClasses: map[int]string{}, materials: map[int]string{}} - if sets, classes, err := loadEnums(enumPath); err != nil { - logger.Warn("could not load enum DB (set/class names will be unknown)", "err", err, "path", enumPath) + if e, err := loadEnums(enumPath); err != nil { + logger.Warn("could not load enum DB (set/class/material names will be unknown)", "err", err, "path", enumPath) } else { - srv.attributeSets = sets - srv.objectClasses = classes - logger.Info("loaded enum DB", "sets", len(sets), "object_classes", len(classes)) + srv.attributeSets = e.sets + srv.objectClasses = e.objectClasses + srv.materials = e.materials + logger.Info("loaded enum DB", "sets", len(e.sets), "object_classes", len(e.objectClasses), "materials", len(e.materials)) } if dsn == "" { @@ -74,6 +76,7 @@ func main() { mux.HandleFunc("GET /sets/list", srv.handleSetsList) mux.HandleFunc("GET /characters/list", srv.handleCharactersList) mux.HandleFunc("GET /search/items", srv.handleSearchItems) + mux.HandleFunc("POST /debug/process", srv.handleDebugProcess) httpSrv := &http.Server{Addr: addr, Handler: withLogging(mux), ReadHeaderTimeout: 10 * time.Second} go func() { @@ -162,41 +165,50 @@ func (s *Server) dbErr(w http.ResponseWriter, where string, err error) { writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "Internal server error"}) } +type enumMaps struct { + sets map[string]string + objectClasses map[int]string + materials map[int]string +} + // loadEnums reads the comprehensive enum DB and extracts AttributeSetInfo -// (set-id -> name) and ObjectClass (id -> name), mirroring -// load_comprehensive_enums (dictionaries first, then enums). -func loadEnums(path string) (sets map[string]string, classes map[int]string, err error) { +// (set-id -> name), ObjectClass (id -> name), and MaterialType (id -> name), +// mirroring load_comprehensive_enums (dictionaries first, then enums). +func loadEnums(path string) (enumMaps, error) { + var em enumMaps b, err := os.ReadFile(path) if err != nil { - return nil, nil, err + return em, err + } + type valmap struct { + Values map[string]string `json:"values"` } var db struct { - Dictionaries map[string]struct { - Values map[string]string `json:"values"` - } `json:"dictionaries"` - Enums map[string]struct { - Values map[string]string `json:"values"` - } `json:"enums"` - ObjectClasses struct { - Values map[string]string `json:"values"` - } `json:"object_classes"` + Dictionaries map[string]valmap `json:"dictionaries"` + Enums map[string]valmap `json:"enums"` + ObjectClasses valmap `json:"object_classes"` } if err := json.Unmarshal(b, &db); err != nil { - return nil, nil, err + return em, err } - sets = map[string]string{} + em.sets = map[string]string{} if d, ok := db.Dictionaries["AttributeSetInfo"]; ok && len(d.Values) > 0 { - sets = d.Values + em.sets = d.Values } else if e, ok := db.Enums["AttributeSetInfo"]; ok { - sets = e.Values + em.sets = e.Values } - classes = map[int]string{} - for k, v := range db.ObjectClasses.Values { - if n, err := strconv.Atoi(k); err == nil { - classes[n] = v + intMap := func(v valmap) map[int]string { + m := map[int]string{} + for k, val := range v.Values { + if n, err := strconv.Atoi(k); err == nil { + m[n] = val + } } + return m } - return sets, classes, nil + em.objectClasses = intMap(db.ObjectClasses) + em.materials = intMap(db.Enums["MaterialType"]) + return em, nil } func envOr(key, def string) string { diff --git a/go-services/inventory-go/process.go b/go-services/inventory-go/process.go new file mode 100644 index 00000000..95bab38a --- /dev/null +++ b/go-services/inventory-go/process.go @@ -0,0 +1,421 @@ +package main + +import ( + "encoding/json" + "io" + "math" + "net/http" + "strconv" + "strings" +) + +// Item-processor: a faithful port of inventory-service extract_item_properties + +// the process_inventory column population. Given a raw item dict it produces the +// normalized rows for the 7 tables, applying the exact per-table sentinel->NULL +// rules. Validated against production's stored rows (read-only) via /debug/process. + +// --- value-bag accessors (JSON object keys are strings) --- + +func bag(raw map[string]any, name string) map[string]any { + if m, ok := raw[name].(map[string]any); ok { + return m + } + return map[string]any{} +} +func ivI(iv map[string]any, key string, def int) int { + if v, ok := iv[key]; ok { + if f, ok := v.(float64); ok { + return int(f) + } + } + return def +} +func dvF(dv map[string]any, key string, def float64) float64 { + if v, ok := dv[key]; ok { + if f, ok := v.(float64); ok { + return f + } + } + return def +} +func rawI(raw map[string]any, key string, def int) int { + if v, ok := raw[key]; ok { + if f, ok := v.(float64); ok { + return int(f) + } + } + return def +} +func rawF(raw map[string]any, key string, def float64) float64 { + if v, ok := raw[key]; ok { + if f, ok := v.(float64); ok { + return f + } + } + return def +} +func rawS(raw map[string]any, key string) string { + if s, ok := raw[key].(string); ok { + return s + } + return "" +} +func rawB(raw map[string]any, key string) bool { + b, _ := raw[key].(bool) + return b +} + +// IV first, else top-level field, else default (e.g. max_damage). +func ivElseTopI(iv, raw map[string]any, ivKey, topKey string, def int) int { + if v, ok := iv[ivKey]; ok { + if f, ok := v.(float64); ok { + return int(f) + } + } + return rawI(raw, topKey, def) +} +func dvElseTopF(dv, raw map[string]any, dvKey, topKey string, def float64) float64 { + if v, ok := dv[dvKey]; ok { + if f, ok := v.(float64); ok { + return f + } + } + return rawF(raw, topKey, def) +} + +// translateMaterial: materials[id] else "Unknown_Material_{id}". +func (s *Server) translateMaterial(id int) string { + if n, ok := s.materials[id]; ok { + return n + } + return "Unknown_Material_" + strconv.Itoa(id) +} + +func toIntList(v any) []int { + arr, ok := v.([]any) + if !ok { + return nil + } + out := make([]int, 0, len(arr)) + for _, e := range arr { + if f, ok := e.(float64); ok { + out = append(out, int(f)) + } + } + return out +} + +// processItem produces the normalized columns for all 7 tables, post-null. +func (s *Server) processItem(raw map[string]any) map[string]any { + iv := bag(raw, "IntValues") + dv := bag(raw, "DoubleValues") + + items := map[string]any{ + "item_id": rawValue(raw, "Id"), + "name": rawS(raw, "Name"), + "icon": rawI(raw, "Icon", 0), + "object_class": rawI(raw, "ObjectClass", 0), + "value": rawI(raw, "Value", 0), + "burden": rawI(raw, "Burden", 0), + "has_id_data": rawB(raw, "HasIdData"), + "last_id_time": rawI(raw, "LastIdTime", 0), + "current_wielded_location": ivI(iv, "10", 0), + "container_id": rawI(raw, "ContainerId", 0), + "slot": ivI(iv, "231735296", -1), + "bonded": ivI(iv, "33", 0), + "attuned": ivI(iv, "114", 0), + "unique": ivI(iv, "279", 0) != 0, + "stack_size": ivI(iv, "12", 1), + "max_stack_size": ivI(iv, "11", 1), + "items_capacity": nilNeg(ivI(iv, "6", -1)), + "containers_capacity": nilNeg(ivI(iv, "7", -1)), + "structure": nilNeg(ivI(iv, "92", -1)), + "max_structure": nilNeg(ivI(iv, "91", -1)), + "rare_id": nilNeg(ivI(iv, "17", -1)), + "lifespan": nilNeg(ivI(iv, "267", -1)), + "remaining_lifespan": nilNeg(ivI(iv, "268", -1)), + } + + // combat (sentinel defaults), then base values merged. + wt := ivI(iv, "218103835", -1) + if wt > 100 { + wt = 100 + } + combat := map[string]any{ + "max_damage": ivElseTopI(iv, raw, "218103842", "MaxDamage", -1), + "damage_type": ivI(iv, "218103832", -1), + "damage_bonus": dvElseTopF(dv, raw, "167772174", "DamageBonus", -1.0), + "elemental_damage_bonus": ivI(iv, "204", -1), + "elemental_damage_vs_monsters": dvF(dv, "152", -1.0), + "variance": dvF(dv, "167772171", -1.0), + "cleaving": ivI(iv, "292", -1), + "crit_damage_rating": ivI(iv, "314", -1), + "damage_over_time": ivI(iv, "318", -1), + "attack_bonus": dvElseTopF(dv, raw, "167772170", "AttackBonus", -1.0), + "weapon_time": wt, + "weapon_skill": ivI(iv, "218103840", -1), + "armor_level": topElseIvI(raw, iv, "ArmorLevel", "28", -1), + "melee_defense_bonus": dvF(dv, "29", -1.0), + "missile_defense_bonus": dvF(dv, "149", -1.0), + "magic_defense_bonus": dvF(dv, "150", -1.0), + "resist_magic": ivI(iv, "36", -1), + "crit_resist_rating": ivI(iv, "315", -1), + "crit_damage_resist_rating": ivI(iv, "316", -1), + "dot_resist_rating": ivI(iv, "350", -1), + "life_resist_rating": ivI(iv, "351", -1), + "nether_resist_rating": ivI(iv, "331", -1), + "heal_over_time": ivI(iv, "312", -1), + "healing_resist_rating": ivI(iv, "317", -1), + "mana_conversion_bonus": dvF(dv, "144", -1.0), + "pk_damage_rating": ivI(iv, "381", -1), + "pk_damage_resist_rating": ivI(iv, "382", -1), + "gear_pk_damage_rating": ivI(iv, "383", -1), + "gear_pk_damage_resist_rating": ivI(iv, "384", -1), + } + s.mergeBaseValues(raw, combat) + + requirements := map[string]any{ + "wield_level": rawI(raw, "WieldLevel", -1), + "skill_level": rawI(raw, "SkillLevel", -1), + "lore_requirement": rawI(raw, "LoreRequirement", -1), + "equip_skill": rawValueStr(raw, "EquipSkill"), + } + + // material + item_set translated strings. + var material any + if m := rawS(raw, "Material"); m != "" { + material = m + } else if v, ok := iv["131"]; ok { + if f, ok := v.(float64); ok && int(f) != 0 { + name := s.translateMaterial(int(f)) + if !strings.HasPrefix(name, "Unknown_Material_") { + material = name + } + } + } + var itemSet any + if v, ok := iv["265"]; ok { + if f, ok := v.(float64); ok && int(f) != 0 { + id := strconv.Itoa(int(f)) + if n, ok := s.attributeSets[id]; ok { + itemSet = n + } else { + itemSet = id + } + } + } + enhancements := map[string]any{ + "material": material, + "imbue": rawValueStr(raw, "Imbue"), + "tinks": rawI(raw, "Tinks", -1), + "workmanship": rawF(raw, "Workmanship", -1.0), + "num_times_tinkered": ivI(iv, "171", -1), + "free_tinkers_bitfield": ivI(iv, "264", -1), + "num_items_in_material": ivI(iv, "170", -1), + "imbue_attempts": ivI(iv, "205", -1), + "imbue_successes": ivI(iv, "206", -1), + "imbued_effect2": ivI(iv, "303", -1), + "imbued_effect3": ivI(iv, "304", -1), + "imbued_effect4": ivI(iv, "305", -1), + "imbued_effect5": ivI(iv, "306", -1), + "imbue_stacking_bits": ivI(iv, "311", -1), + "item_set": itemSet, + "equipment_set_extra": ivI(iv, "321", -1), + "aetheria_bitfield": ivI(iv, "322", -1), + "heritage_specific_armor": ivI(iv, "324", -1), + "shared_cooldown": ivI(iv, "280", -1), + } + + ratingKeys := map[string]string{ + "damage_rating": "307", "damage_resist_rating": "308", "crit_rating": "313", + "crit_resist_rating": "315", "crit_damage_rating": "314", "crit_damage_resist_rating": "316", + "heal_boost_rating": "323", "vitality_rating": "341", "healing_rating": "342", + "weakness_rating": "329", "nether_over_time": "330", "healing_resist_rating": "317", + "nether_resist_rating": "331", "dot_resist_rating": "350", "life_resist_rating": "351", + "sneak_attack_rating": "356", "recklessness_rating": "357", "deception_rating": "358", + "pk_damage_rating": "381", "pk_damage_resist_rating": "382", "gear_pk_damage_rating": "383", + "gear_pk_damage_resist_rating": "384", "gear_damage": "370", "gear_damage_resist": "371", + "gear_crit": "372", "gear_crit_resist": "373", "gear_crit_damage": "374", + "gear_crit_damage_resist": "375", "gear_healing_boost": "376", "gear_max_health": "379", + "gear_nether_resist": "377", "gear_life_resist": "378", "gear_overpower": "388", + "gear_overpower_resist": "389", + } + ratings := map[string]any{} + for col, k := range ratingKeys { + ratings[col] = ivI(iv, k, -1) + } + + // spells: union of Spells + ActiveSpells, is_active = in ActiveSpells. + spells := toIntList(raw["Spells"]) + active := toIntList(raw["ActiveSpells"]) + activeSet := map[int]bool{} + for _, id := range active { + activeSet[id] = true + } + seen := map[int]bool{} + var spellRows []map[string]any + for _, id := range append(append([]int{}, spells...), active...) { + if seen[id] { + continue + } + seen[id] = true + spellRows = append(spellRows, map[string]any{"spell_id": id, "is_active": activeSet[id]}) + } + + return map[string]any{ + "items": items, + "combat": nullify(combat, sentinelCombat), + "requirements": nullify(requirements, sentinelReq), + "enhancements": nullify(enhancements, sentinelEnh), // always present + "ratings": nullify(ratings, sentinelRating), + "spells": spellRows, + } +} + +func topElseIvI(raw, iv map[string]any, topKey, ivKey string, def int) int { + if v, ok := raw[topKey]; ok { + if f, ok := v.(float64); ok { + return int(f) + } + } + return ivI(iv, ivKey, def) +} + +func rawValue(raw map[string]any, key string) any { + if v, ok := raw[key]; ok { + if f, ok := v.(float64); ok { + return int64(f) + } + return v + } + if v, ok := raw[strings.ToLower(key)]; ok { // Id -> id fallback + if f, ok := v.(float64); ok { + return int64(f) + } + return v + } + return nil +} +func rawValueStr(raw map[string]any, key string) any { + if s, ok := raw[key].(string); ok && s != "" { + return s + } + return nil +} + +func nilNeg(v int) any { + if v == -1 { + return nil + } + return v +} + +// per-table sentinel predicates: true => value should become NULL. +func sentinelCombat(v any) bool { return isNeg1(v) || isNeg1f(v) } +func sentinelReq(v any) bool { return isNeg1(v) || v == nil || v == "" } +func sentinelEnh(v any) bool { return isNeg1(v) || isNeg1f(v) || v == nil || v == "" } +func sentinelRating(v any) bool { return isNeg1(v) || isNeg1f(v) || v == nil } + +func isNeg1(v any) bool { i, ok := v.(int); return ok && i == -1 } +func isNeg1f(v any) bool { f, ok := v.(float64); return ok && f == -1.0 } + +// nullify replaces sentinel values with nil. Returns nil for the whole table if +// every value is sentinel (the per-table "skip insert" guard) — EXCEPT +// enhancements, which always inserts; we keep its map even if all-null. +func nullify(m map[string]any, isSentinel func(any) bool) map[string]any { + any_ := false + out := make(map[string]any, len(m)) + for k, v := range m { + if isSentinel(v) { + out[k] = nil + } else { + out[k] = v + any_ = true + } + } + if !any_ { + // caller distinguishes; combat/req/ratings skip (nil), enhancements keeps. + return nil + } + return out +} + +// mergeBaseValues reverses active-spell buffs into base_* columns (compute_base_values). +type spellEffect struct { + key int + change, bonus float64 +} + +var intEffects = map[int]spellEffect{ + 1616: {218103842, 20, 0}, 2096: {218103842, 22, 0}, 5183: {218103842, 24, 0}, 4395: {218103842, 24, 0}, 3688: {218103842, 300, 0}, + 2598: {218103842, 2, 2}, 2586: {218103842, 4, 4}, 4661: {218103842, 7, 7}, 6089: {218103842, 10, 10}, + 1486: {28, 200, 0}, 2108: {28, 220, 0}, 4407: {28, 240, 0}, + 2604: {28, 20, 20}, 2592: {28, 40, 40}, 4667: {28, 60, 60}, 6095: {28, 80, 80}, +} +var doubleEffects = map[int]spellEffect{ + 3258: {152, 0.06, 0}, 3259: {152, 0.07, 0}, 5182: {152, 0.08, 0}, 4414: {152, 0.08, 0}, 3735: {152, 0.15, 0}, + 3251: {152, 0.01, 0.01}, 3250: {152, 0.03, 0.03}, 4670: {152, 0.05, 0.05}, 6098: {152, 0.07, 0.07}, + 1592: {167772172, 0.15, 0}, 2106: {167772172, 0.17, 0}, 4405: {167772172, 0.20, 0}, + 2603: {167772172, 0.03, 0.03}, 2591: {167772172, 0.05, 0.05}, 4666: {167772172, 0.07, 0.07}, 6094: {167772172, 0.09, 0.09}, + 1605: {29, 0.15, 0}, 2101: {29, 0.17, 0}, 4400: {29, 0.20, 0}, 3699: {29, 0.25, 0}, + 2600: {29, 0.03, 0.03}, 3985: {29, 0.04, 0.04}, 2588: {29, 0.05, 0.05}, 4663: {29, 0.07, 0.07}, 6091: {29, 0.09, 0.09}, + 1480: {144, 1.60, 0}, 2117: {144, 1.70, 0}, 4418: {144, 1.80, 0}, + 3201: {144, 1.05, 1.05}, 3199: {144, 1.10, 1.10}, 3202: {144, 1.15, 1.15}, 3200: {144, 1.20, 1.20}, 6086: {144, 1.25, 1.25}, 6087: {144, 1.30, 1.30}, +} + +func (s *Server) mergeBaseValues(raw, combat map[string]any) { + spells := toIntList(raw["Spells"]) + active := toIntList(raw["ActiveSpells"]) + for _, p := range []struct { + prop string + key int + }{{"max_damage", 218103842}, {"armor_level", 28}} { + val, ok := combat[p.prop].(int) + if !ok || val == -1 { + continue + } + for _, sid := range active { + if e, ok := intEffects[sid]; ok && e.key == p.key { + val -= int(e.change) + } + } + for _, sid := range spells { + if e, ok := intEffects[sid]; ok && e.key == p.key && e.bonus != 0 { + val += int(e.bonus) + } + } + combat["base_"+p.prop] = val + } + for _, p := range []struct { + prop string + key int + }{{"attack_bonus", 167772172}, {"melee_defense_bonus", 29}, {"elemental_damage_vs_monsters", 152}, {"mana_conversion_bonus", 144}} { + val, ok := combat[p.prop].(float64) + if !ok || val == -1.0 { + continue + } + for _, sid := range active { + if e, ok := doubleEffects[sid]; ok && e.key == p.key { + val -= e.change + } + } + for _, sid := range spells { + if e, ok := doubleEffects[sid]; ok && e.key == p.key && e.bonus != 0 { + val += e.bonus + } + } + combat["base_"+p.prop] = math.Round(val*10000) / 10000 + } +} + +// POST /debug/process — returns the normalized columns for a raw item JSON body +// (loopback validation against production's stored rows; never writes). +func (s *Server) handleDebugProcess(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(io.LimitReader(r.Body, 8<<20)) + var raw map[string]any + if json.Unmarshal(body, &raw) != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid JSON"}) + return + } + writeJSON(w, http.StatusOK, s.processItem(raw)) +}