diff --git a/go-services/inventory-go/main.go b/go-services/inventory-go/main.go index c1f364ca..7a227f80 100644 --- a/go-services/inventory-go/main.go +++ b/go-services/inventory-go/main.go @@ -70,6 +70,7 @@ func main() { mux.HandleFunc("GET /health", srv.handleHealth) mux.HandleFunc("GET /sets/list", srv.handleSetsList) mux.HandleFunc("GET /characters/list", srv.handleCharactersList) + mux.HandleFunc("GET /search/items", srv.handleSearchItems) httpSrv := &http.Server{Addr: addr, Handler: withLogging(mux), ReadHeaderTimeout: 10 * time.Second} go func() { diff --git a/go-services/inventory-go/search.go b/go-services/inventory-go/search.go new file mode 100644 index 00000000..f7311107 --- /dev/null +++ b/go-services/inventory-go/search.go @@ -0,0 +1,393 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "strconv" + "strings" + "time" +) + +// /search/items — port of inventory-service main.py:2892. This slice implements +// the search QUERY (the CTE + all SQL filters + sort + pagination + count) and +// returns each row's direct DB columns plus the computed booleans. The deep +// per-row translation (material_name, spells, slot_name, ...) from +// extract_item_properties is layered on in a later slice; the filter/count logic +// — "which items match" — is validated here against the Python service. + +// cteSelect is the items_with_slots CTE body (everything up to FROM/JOINs). The +// rating columns are extracted from the item_raw_data int_values JSONB exactly +// as Python does (paired ids via GREATEST, singletons via COALESCE). +const cteSelect = ` +SELECT DISTINCT + i.id AS db_item_id, i.character_name, i.name, i.icon, i.object_class, i.value, i.burden, + i.current_wielded_location, i.bonded, i.attuned, i."unique", i.stack_size, i.max_stack_size, + i.structure, i.max_structure, i.rare_id, i.timestamp AS last_updated, + COALESCE(cs.max_damage, -1) AS max_damage, + COALESCE(cs.armor_level, -1) AS armor_level, + COALESCE(cs.attack_bonus, -1.0) AS attack_bonus, + COALESCE(cs.melee_defense_bonus, -1.0) AS melee_defense_bonus, + COALESCE(cs.weapon_time, -1) AS weapon_time, + COALESCE(cs.base_armor_level, cs.armor_level, -1) AS base_armor_level, + COALESCE(cs.base_max_damage, cs.max_damage, -1) AS base_max_damage, + GREATEST(COALESCE((rd.int_values->>'314')::int, -1), COALESCE((rd.int_values->>'374')::int, -1)) AS crit_damage_rating, + GREATEST(COALESCE((rd.int_values->>'307')::int, -1), COALESCE((rd.int_values->>'370')::int, -1)) AS damage_rating, + GREATEST(COALESCE((rd.int_values->>'323')::int, -1), COALESCE((rd.int_values->>'376')::int, -1)) AS heal_boost_rating, + COALESCE((rd.int_values->>'379')::int, -1) AS vitality_rating, + GREATEST(COALESCE((rd.int_values->>'308')::int, -1), COALESCE((rd.int_values->>'371')::int, -1)) AS damage_resist_rating, + COALESCE((rd.int_values->>'315')::int, -1) AS crit_resist_rating, + GREATEST(COALESCE((rd.int_values->>'316')::int, -1), COALESCE((rd.int_values->>'375')::int, -1)) AS crit_damage_resist_rating, + COALESCE((rd.int_values->>'317')::int, -1) AS healing_resist_rating, + COALESCE((rd.int_values->>'331')::int, -1) AS nether_resist_rating, + COALESCE((rd.int_values->>'342')::int, -1) AS healing_rating, + COALESCE((rd.int_values->>'350')::int, -1) AS dot_resist_rating, + COALESCE((rd.int_values->>'351')::int, -1) AS life_resist_rating, + COALESCE((rd.int_values->>'356')::int, -1) AS sneak_attack_rating, + COALESCE((rd.int_values->>'357')::int, -1) AS recklessness_rating, + COALESCE((rd.int_values->>'358')::int, -1) AS deception_rating, + COALESCE((rd.int_values->>'381')::int, -1) AS pk_damage_rating, + COALESCE((rd.int_values->>'382')::int, -1) AS pk_damage_resist_rating, + COALESCE((rd.int_values->>'383')::int, -1) AS gear_pk_damage_rating, + COALESCE((rd.int_values->>'384')::int, -1) AS gear_pk_damage_resist_rating, + COALESCE(req.wield_level, -1) AS wield_level, + COALESCE(enh.material, '') AS material, + COALESCE(enh.workmanship, -1.0) AS workmanship, + COALESCE(enh.imbue, '') AS imbue, + COALESCE(enh.tinks, -1) AS tinks, + COALESCE(enh.item_set, '') AS item_set, + COALESCE((rd.int_values->>'218103821')::int, 0) AS coverage_mask, + COALESCE((SELECT STRING_AGG(CAST(sp_inner.spell_id AS VARCHAR), ',' ORDER BY sp_inner.spell_id) + FROM item_spells sp_inner WHERE sp_inner.item_id = i.id), '') AS computed_spell_names +FROM items i +LEFT JOIN item_combat_stats cs ON i.id = cs.item_id +LEFT JOIN item_requirements req ON i.id = req.item_id +LEFT JOIN item_enhancements enh ON i.id = enh.item_id +LEFT JOIN item_ratings rt ON i.id = rt.item_id +LEFT JOIN item_raw_data rd ON i.id = rd.item_id` + +var sortMapping = map[string]string{ + "name": "name", "character_name": "character_name", "value": "value", + "damage": "max_damage", "armor": "armor_level", "armor_level": "armor_level", + "workmanship": "workmanship", "level": "wield_level", "damage_rating": "damage_rating", + "crit_damage_rating": "crit_damage_rating", "heal_boost_rating": "heal_boost_rating", + "vitality_rating": "vitality_rating", "damage_resist_rating": "damage_resist_rating", + "crit_damage_resist_rating": "crit_damage_resist_rating", "item_set": "item_set", + "coverage": "coverage_mask", "item_type_name": "object_class", + "last_updated": "last_updated", "spell_names": "computed_spell_names", +} + +// argBuilder accumulates positional ($N) query args. +type argBuilder struct{ args []any } + +func (b *argBuilder) add(v any) string { + b.args = append(b.args, v) + return "$" + strconv.Itoa(len(b.args)) +} + +func (s *Server) handleSearchItems(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + ab := &argBuilder{} + var conds []string + + // --- character (mutually exclusive cascade) --- + if c := q.Get("character"); c != "" { + conds = append(conds, "character_name = "+ab.add(c)) + } 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 + } + ph := make([]string, len(names)) + for i, n := range names { + ph[i] = ab.add(n) + } + 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 + } + + // --- text --- + if t := q.Get("text"); t != "" { + p := ab.add("%" + t + "%") + conds = append(conds, "(CONCAT(COALESCE(material,''),' ',name) ILIKE "+p+" OR name ILIKE "+p+" OR COALESCE(material,'') ILIKE "+p+")") + } + + // --- category (mutually exclusive) --- + switch { + case qBool(q, "armor_only"): + conds = append(conds, "(object_class = 2 AND COALESCE(armor_level,0) > 0)") + case qBool(q, "jewelry_only"): + conds = append(conds, "object_class = 4") + case qBool(q, "weapon_only"): + conds = append(conds, "object_class IN (1, 9, 31)") // weapon_type refinement: later slice + case qBool(q, "clothing_only"): + conds = append(conds, "(object_class = 3 AND name NOT ILIKE '%cloak%' AND name NOT ILIKE '%robe%' AND name NOT ILIKE '%pallium%' AND name NOT ILIKE '%armet%' AND (name ILIKE '%shirt%' OR name ILIKE '%pants%' OR name ILIKE '%breeches%' OR name ILIKE '%baggy%' OR name ILIKE '%tunic%'))") + } + + // --- equipment status / slot --- + switch q.Get("equipment_status") { + case "equipped": + conds = append(conds, "current_wielded_location > 0") + case "unequipped": + conds = append(conds, "current_wielded_location = 0") + } + if v, ok := qInt(q, "equipment_slot"); ok { + conds = append(conds, "current_wielded_location = "+ab.add(v)) + } + + // --- combat + all rating filters (column >= :param) --- + geFilters := []struct{ param, col string }{ + {"min_damage", "max_damage"}, {"min_armor", "armor_level"}, + {"min_crit_damage_rating", "crit_damage_rating"}, {"min_damage_rating", "damage_rating"}, + {"min_heal_boost_rating", "heal_boost_rating"}, {"min_vitality_rating", "vitality_rating"}, + {"min_damage_resist_rating", "damage_resist_rating"}, {"min_crit_resist_rating", "crit_resist_rating"}, + {"min_crit_damage_resist_rating", "crit_damage_resist_rating"}, {"min_healing_resist_rating", "healing_resist_rating"}, + {"min_nether_resist_rating", "nether_resist_rating"}, {"min_healing_rating", "healing_rating"}, + {"min_dot_resist_rating", "dot_resist_rating"}, {"min_life_resist_rating", "life_resist_rating"}, + {"min_sneak_attack_rating", "sneak_attack_rating"}, {"min_recklessness_rating", "recklessness_rating"}, + {"min_deception_rating", "deception_rating"}, {"min_pk_damage_rating", "pk_damage_rating"}, + {"min_pk_damage_resist_rating", "pk_damage_resist_rating"}, {"min_gear_pk_damage_rating", "gear_pk_damage_rating"}, + {"min_gear_pk_damage_resist_rating", "gear_pk_damage_resist_rating"}, {"min_tinks", "tinks"}, + {"min_value", "value"}, {"min_workmanship", "workmanship"}, + } + for _, f := range geFilters { + if v := q.Get(f.param); v != "" { + if n, err := strconv.ParseFloat(v, 64); err == nil { + conds = append(conds, f.col+" >= "+ab.add(n)) + } + } + } + leFilters := []struct{ param, col string }{ + {"max_damage", "max_damage"}, {"max_armor", "armor_level"}, + {"max_value", "value"}, {"max_burden", "burden"}, + } + for _, f := range leFilters { + if v, ok := qInt(q, f.param); ok { + conds = append(conds, f.col+" <= "+ab.add(v)) + } + } + if v := q.Get("min_attack_bonus"); v != "" { + if n, err := strconv.ParseFloat(v, 64); err == nil { + conds = append(conds, "attack_bonus >= "+ab.add(n)) + } + } + + // --- requirements (wield level) --- + if v, ok := qInt(q, "max_level"); ok { + conds = append(conds, "(wield_level <= "+ab.add(v)+" OR wield_level IS NULL)") + } + if v, ok := qInt(q, "min_level"); ok { + conds = append(conds, "wield_level >= "+ab.add(v)) + } + + // --- enhancements --- + if m := q.Get("material"); m != "" { + conds = append(conds, "material ILIKE "+ab.add("%"+m+"%")) + } + if v := q.Get("has_imbue"); v != "" { + if qBool(q, "has_imbue") { + conds = append(conds, "(imbue IS NOT NULL AND imbue != '')") + } else { + conds = append(conds, "(imbue IS NULL OR imbue = '')") + } + } + + // --- item state --- + if v := q.Get("bonded"); v != "" { + conds = append(conds, ternary(qBool(q, "bonded"), "bonded > 0", "bonded = 0")) + } + if v := q.Get("attuned"); v != "" { + conds = append(conds, ternary(qBool(q, "attuned"), "attuned > 0", "attuned = 0")) + } + if v := q.Get("unique"); v != "" { + conds = append(conds, `"unique" = `+ab.add(qBool(q, "unique"))) + } + if v := q.Get("is_rare"); v != "" { + conds = append(conds, ternary(qBool(q, "is_rare"), "rare_id IS NOT NULL AND rare_id > 0", "(rare_id IS NULL OR rare_id <= 0)")) + } + if v, ok := qInt(q, "min_condition"); ok { + conds = append(conds, "((structure * 100.0 / NULLIF(max_structure, 0)) >= "+ab.add(v)+" OR max_structure IS NULL)") + } + + where := "" + if len(conds) > 0 { + where = " WHERE " + strings.Join(conds, " AND ") + } + + // --- sort --- + sortField, ok := sortMapping[q.Get("sort_by")] + if !ok { + sortField = "name" + } + dir, nulls := "ASC", "NULLS LAST" + if strings.EqualFold(q.Get("sort_dir"), "desc") { + dir, nulls = "DESC", "NULLS FIRST" + } + orderBy := fmt.Sprintf(" ORDER BY %s %s %s, character_name, db_item_id", sortField, dir, nulls) + + // --- pagination --- + page := clampInt(qIntDefault(q, "page", 1), 1, 1<<30) + limit := clampInt(qIntDefault(q, "limit", 200), 1, 50000) + offset := (page - 1) * limit + + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + cte := "WITH items_with_slots AS (" + cteSelect + ")\n" + mainSQL := cte + "SELECT * FROM items_with_slots" + where + orderBy + + " 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 + } + + // count uses the same CTE + conditions (full CTE is correct; Python trims it + // only as an optimization). LIMIT/OFFSET args are unused here. + 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 + } + + items := make([]map[string]any, 0, len(rows)) + for _, row := range row2items(rows) { + items = append(items, row) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "items": items, + "total_count": totalCount, + "page": page, + "limit": limit, + "has_next": int64(page*limit) < totalCount, + "has_previous": page > 1, + }) +} + +// row2items applies the direct-column transforms (computed booleans, condition, +// timestamp formatting) and strips internal columns. Deep enrichment is a later +// slice. +func row2items(rows []map[string]any) []map[string]any { + for _, row := range rows { + cw := toInt64(row["current_wielded_location"]) + row["is_equipped"] = cw > 0 + row["is_bonded"] = toInt64(row["bonded"]) > 0 + row["is_attuned"] = toInt64(row["attuned"]) > 0 + row["is_rare"] = toInt64(row["rare_id"]) > 0 + st, mx := row["structure"], row["max_structure"] + if st != nil && mx != nil && toFloat(mx) != 0 { + row["condition_percent"] = roundTo(toFloat(st)*100/toFloat(mx), 1) + } else { + row["condition_percent"] = nil + } + if t, ok := row["last_updated"].(time.Time); ok { + row["last_updated"] = pyISO(t) + } + delete(row, "db_item_id") + } + return rows +} + +func splitNonEmpty(s string) []string { + var out []string + for _, p := range strings.Split(s, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +func qBool(q map[string][]string, key string) bool { + v := "" + if vs, ok := q[key]; ok && len(vs) > 0 { + v = vs[0] + } + switch strings.ToLower(v) { + case "1", "true", "yes", "on": + return true + } + return false +} + +func qInt(q map[string][]string, key string) (int, bool) { + if vs, ok := q[key]; ok && len(vs) > 0 && vs[0] != "" { + if n, err := strconv.Atoi(vs[0]); err == nil { + return n, true + } + } + return 0, false +} + +func qIntDefault(q map[string][]string, key string, def int) int { + if n, ok := qInt(q, key); ok { + return n + } + return def +} + +func ternary(c bool, a, b string) string { + if c { + return a + } + return b +} + +func clampInt(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +func toInt64(v any) int64 { + switch x := v.(type) { + case int64: + return x + case int32: + return int64(x) + case int: + return int64(x) + case float64: + return int64(x) + } + return 0 +} + +func toFloat(v any) float64 { + switch x := v.(type) { + case float64: + return x + case float32: + return float64(x) + case int64: + return float64(x) + case int32: + return float64(x) + case int: + return float64(x) + } + return 0 +} + +func roundTo(v float64, places int) float64 { + p := 1.0 + for i := 0; i < places; i++ { + p *= 10 + } + r := v * p + if r < 0 { + r -= 0.5 + } else { + r += 0.5 + } + return float64(int64(r)) / p +}