MosswartOverlord/go-services/inventory-go/process.go
Erik b90b52c515 feat(go-services): inventory-go item-processor (extract_item_properties) — exact
Ports the core item processing: raw item JSON -> normalized columns for all 7
tables, with the exact per-table sentinel->NULL rules, material/item_set string
translation, the Spells/ActiveSpells union (is_active), and compute_base_values
(the spell_effects buff-reversal for base_armor_level/base_max_damage/
base_attack_bonus/etc., with the data embedded and the 167772170-vs-167772172
attack-bonus id discrepancy preserved). loadEnums now also loads MaterialType.
A loopback POST /debug/process returns the normalized columns for validation.

Validated against production's STORED rows (read-only, no writes): 0 mismatches
across 200 items for every sampled column of items, item_enhancements (incl.
translated material + set), item_combat_stats (incl. base_* values), and
item_ratings.

This unlocks ingestion (the processor produces the rows) and the remaining
search-response enrichment (spells/weapon/mana from the same extractor).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 12:23:38 +02:00

421 lines
14 KiB
Go

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