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 }