MosswartOverlord/go-services/inventory-go/search.go
Erik 57f53ff36b feat(inventory-go): port the suitbuilder solver (/suitbuilder/search) — validated
Full Go port of suitbuilder.py's ConstraintSatisfactionSolver (the LIVE solver
behind the suitbuilder UI; main.py's /optimize/suits is legacy/unused):

- suit_model.go: CoverageMask + reductions, SuitItem/ItemBucket/SuitState,
  SpellBitmapIndex, ScoringWeights, SearchConstraints, CompletedSuit.to_dict,
  ItemPreFilter, set name<->id maps. Every sort carries (character_name, name)
  tiebreakers for deterministic results.
- suit_solver.go: the 5-phase pipeline — load_items (fed in-process by the Go
  /search/items), create_buckets (+multi-slot/generic-jewelry expansion),
  apply_reduction_options, sort_buckets, and the depth-first recursive_search
  with both Mag-SuitBuilder pruning rules, can_add_item constraints (set limits,
  jewelry spell contribution, strict spell mode), scoring, and finalize.
- suit_http.go: POST /suitbuilder/search (SSE: phase/log/suit/progress/complete),
  GET /suitbuilder/characters, GET /suitbuilder/sets.
- search.go: refactor handleSearchItems -> shared runSearch (the solver reuses
  it so both see identical rows); emit slot_name (get_sophisticated_slot_options
  + translate_equipment_slot); fix the trinket slot_names clause to exclude
  %bracelet% (matches Python).
- slotname.go: the EquipMask-based slot translation, loaded from the enum DB.

Validation: 9/9 scenarios stream byte-identical suits vs the Python service on
production data (no-spell, multi-character, locked slots with/without spells,
spell constraints, alternate set pairs, primary-only). ~45x faster than Python.

Three subtle bugs found and fixed during validation:
- slot_name is load-bearing, not display: jewelry's computed_slot_name is empty,
  so load_items falls back to slot_name to bucket rings/neck/wrists/trinket.
- Python scoring uses floor division (total_armor // 100); total_armor goes
  negative (non-armor items carry armor_level -1) so Go's truncation was +1 off.
- the trinket fetch must exclude bracelets or they duplicate the Wrist buckets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 14:03:59 +02:00

677 lines
27 KiB
Go

package main
import (
"context"
"fmt"
"net/http"
"net/url"
"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((rd.int_values->>'218103822')::int, 0) AS equippable_slots,
CASE
WHEN rd.original_json IS NOT NULL
AND rd.original_json->'IntValues'->>'218103822' IS NOT NULL
AND (rd.original_json->'IntValues'->>'218103822')::int > 0
THEN
CASE (rd.original_json->'IntValues'->>'218103822')::int
WHEN 1 THEN 'Head' WHEN 2 THEN 'Neck' WHEN 4 THEN 'Shirt'
WHEN 16 THEN 'Chest' WHEN 32 THEN 'Hands' WHEN 256 THEN 'Feet'
WHEN 512 THEN 'Chest' WHEN 1024 THEN 'Abdomen' WHEN 2048 THEN 'Upper Arms'
WHEN 4096 THEN 'Lower Arms' WHEN 8192 THEN 'Upper Legs' WHEN 16384 THEN 'Lower Legs'
WHEN 33554432 THEN 'Shield'
WHEN 15 THEN 'Chest, Abdomen, Upper Arms, Lower Arms'
WHEN 30 THEN 'Shirt'
WHEN 14336 THEN 'Chest, Abdomen, Upper Arms, Lower Arms'
WHEN 25600 THEN 'Abdomen, Upper Legs, Lower Legs'
ELSE CONCAT_WS(', ',
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 1 = 1 THEN 'Head' END,
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 512 = 512 THEN 'Chest' END,
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 1024 = 1024 THEN 'Abdomen' END,
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 2048 = 2048 THEN 'Upper Arms' END,
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 4096 = 4096 THEN 'Lower Arms' END,
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 32 = 32 THEN 'Hands' END,
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 8192 = 8192 THEN 'Upper Legs' END,
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 16384 = 16384 THEN 'Lower Legs' END,
CASE WHEN (rd.original_json->'IntValues'->>'218103822')::int & 256 = 256 THEN 'Feet' END)
END
WHEN i.object_class = 4 THEN
CASE
WHEN i.current_wielded_location = 32768 THEN 'Neck'
WHEN i.current_wielded_location = 262144 THEN 'Left Ring'
WHEN i.current_wielded_location = 524288 THEN 'Right Ring'
WHEN i.current_wielded_location = 786432 THEN 'Left Ring, Right Ring'
WHEN i.current_wielded_location = 131072 THEN 'Left Wrist'
WHEN i.current_wielded_location = 1048576 THEN 'Right Wrist'
WHEN i.current_wielded_location = 1179648 THEN 'Left Wrist, Right Wrist'
WHEN i.name ILIKE '%amulet%' OR i.name ILIKE '%necklace%' OR i.name ILIKE '%gorget%' THEN 'Neck'
WHEN i.name ILIKE '%ring%' AND i.name NOT ILIKE '%keyring%' AND i.name NOT ILIKE '%signet%' THEN 'Left Ring, Right Ring'
WHEN i.name ILIKE '%bracelet%' THEN 'Left Wrist, Right Wrist'
WHEN i.name ILIKE '%trinket%' THEN 'Trinket'
ELSE 'Jewelry'
END
WHEN i.object_class = 6 THEN 'Melee Weapon'
WHEN i.object_class = 7 THEN 'Missile Weapon'
WHEN i.object_class = 8 THEN 'Held'
WHEN i.current_wielded_location = 67108864 THEN 'Two-Handed'
WHEN i.name ILIKE '%cloak%' THEN 'Cloak'
ELSE '-'
END AS computed_slot_name,
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,
-- Ordered passive Spells from the raw item (matches extract_item_properties:
-- spell_names = [translate_spell(id) for id in original_json["Spells"]], in
-- array order, with duplicates preserved). Internal; stripped after enrich.
(SELECT STRING_AGG(elem, ',' ORDER BY ord)
FROM jsonb_array_elements_text(
CASE WHEN jsonb_typeof(rd.original_json->'Spells') = 'array'
THEN rd.original_json->'Spells' ELSE '[]'::jsonb END)
WITH ORDINALITY AS t(elem, ord)) AS spell_ids_ordered
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) {
res, err := s.runSearch(r.Context(), r.URL.Query())
if err != nil {
s.dbErr(w, "search/items", err)
return
}
writeJSON(w, http.StatusOK, res)
}
// runSearch executes /search/items and returns the response object (items +
// pagination, or an {error,...} object for invalid params). Shared by the HTTP
// handler and the suitbuilder solver's load_items, so both see identical rows.
func (s *Server) runSearch(ctx context.Context, q url.Values) (map[string]any, error) {
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 {
return map[string]any{"error": "Empty characters list provided", "items": []any{}, "total_count": 0}, nil
}
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") {
return map[string]any{"error": "Must specify character, characters, or set include_all_characters=true", "items": []any{}, "total_count": 0}, nil
}
// --- 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, weaponTypeClause(q.Get("weapon_type")))
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)")
}
// --- item_set / item_sets (translate id->name, bug-for-bug) ---
if v := q.Get("item_set"); v != "" {
conds = append(conds, "item_set = "+ab.add(s.translateSetID(v)))
} else if v := q.Get("item_sets"); v != "" {
ids := splitNonEmpty(v)
if len(ids) != 1 {
conds = append(conds, "1 = 0") // 0 or >1 set ids => impossible
} else {
conds = append(conds, "item_set = "+ab.add(s.translateSetID(ids[0])))
}
}
// --- slot_names (OR of per-slot approaches over computed_slot_name) ---
if v := q.Get("slot_names"); v != "" {
var slotClauses []string
for _, name := range splitNonEmpty(v) {
slotClauses = append(slotClauses, slotNameClause(name, ab))
}
if len(slotClauses) > 0 {
conds = append(conds, "("+strings.Join(slotClauses, " OR ")+")")
}
}
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(ctx, 30*time.Second)
defer cancel()
// Underwear filters (shirt_only/pants_only/underwear_only) are injected into
// the CTE body itself (they filter on raw i./rd. columns), mirroring Python's
// cte_where_clause insertion. Mutually exclusive, shirt > pants > underwear.
cteBody := cteSelect
if cw := underwearCTEWhere(q); cw != "" {
cteBody += "\n" + cw
}
cte := "WITH items_with_slots AS (" + cteBody + ")\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 {
return nil, err
}
// count uses the SAME CTE (incl. the underwear injection) + conditions, so
// total_count is always consistent with the returned items. Python builds a
// SEPARATE count CTE (main.py:3747) that omits the underwear injection and
// uses a simpler computed_slot_name, so its total_count is inconsistent with
// its own items for underwear/slot_names filters (e.g. shirt_only reports the
// whole table). We deliberately do NOT replicate that bug. Normal browse
// filters apply to both CTEs identically, so those counts match Python.
// 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 {
return nil, err
}
items := s.enrichRows(rows)
return map[string]any{
"items": items,
"total_count": totalCount,
"page": page,
"limit": limit,
"has_next": int64(page*limit) < totalCount,
"has_previous": page > 1,
}, nil
}
// enrichRows applies the direct-column transforms (computed booleans, condition,
// timestamp), the material-name prefix, and the item_set name, then strips
// internal columns. Deeper enrichment (spells, slot_name, weapon damage/mana,
// rating fallbacks) is a later slice.
func (s *Server) enrichRows(rows []map[string]any) []map[string]any {
out := make([]map[string]any, 0, len(rows))
for _, row := range rows {
row["is_equipped"] = toInt64(row["current_wielded_location"]) > 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)
}
// object_class_name — gem(11) context uses the ORIGINAL item name, so
// compute before the material prefix below (translate_object_class).
if oc := int(toInt64(row["object_class"])); oc != 0 {
row["object_class_name"] = s.translateObjectClass(oc, toStr(row["name"]))
}
// material_name + material prefix on name (material is already a
// translated string in the DB; enrich_db_item:2371-2602).
if mat := toStr(row["material"]); mat != "" {
row["material_name"] = mat
if name := toStr(row["name"]); name != "" &&
!strings.HasPrefix(strings.ToLower(name), strings.ToLower(mat)) {
row["original_name"] = name
row["name"] = mat + " " + name
}
}
// item_set_name (enrich_db_item:2551-2562).
if iset := strings.TrimSpace(toStr(row["item_set"])); iset != "" {
if n, ok := s.attributeSets[iset]; ok {
row["item_set_name"] = n
} else {
row["item_set_name"] = "Set " + iset
}
}
// spells / spell_names from the ordered passive Spells array
// (enrich_db_item:3942-3951; only set when the item has spells).
if raw := toStr(row["spell_ids_ordered"]); raw != "" {
parts := strings.Split(raw, ",")
spells := make([]map[string]any, 0, len(parts))
names := make([]string, 0, len(parts))
for _, p := range parts {
id, err := strconv.Atoi(strings.TrimSpace(p))
if err != nil {
continue
}
sp := s.translateSpell(id)
spells = append(spells, sp)
if n, _ := sp["name"].(string); n != "" {
names = append(names, n)
}
}
if len(spells) > 0 {
row["spells"] = spells
row["spell_names"] = names
}
}
delete(row, "spell_ids_ordered")
// slot_name — sophisticated equipment-slot translation (main.py:3977-4033).
// Load-bearing for the suitbuilder: jewelry has an empty computed_slot_name,
// so load_items falls back to this to bucket rings/neck/wrists/trinket.
eq := int(toInt64(row["equippable_slots"]))
hasMat := toStr(row["material"]) != ""
row["slot_name"] = s.computeSlotName(eq, int(toInt64(row["coverage_mask"])), hasMat)
delete(row, "equippable_slots")
// Gear-total display ratings (main.py:4035-4072): damage_rating,
// crit_damage_rating, heal_boost_rating only. The CTE already does
// GREATEST(individual, gear-key 370/374/376), so the gear-positive rescue
// branch is dead — the net rule is simply -1 -> null. The other three
// solver ratings (damage_resist/crit_damage_resist/vitality) stay -1.
for _, f := range []string{"damage_rating", "crit_damage_rating", "heal_boost_rating"} {
if toInt64(row[f]) == -1 {
row[f] = nil
}
}
delete(row, "db_item_id")
out = append(out, row)
}
return out
}
// translateObjectClass mirrors translate_object_class: ObjectClass enum lookup,
// with the context-aware Gem(11) classification by item name. The aetheria-by-
// IntValues path (for gem-class items not named crystal/gem/mana stone) is not
// reproduced here (it needs original_json) — a documented rare edge.
func (s *Server) translateObjectClass(oc int, name string) string {
base, ok := s.objectClasses[oc]
if !ok {
return fmt.Sprintf("Unknown_ObjectClass_%d", oc)
}
if base == "Gem" && oc == 11 {
n := strings.ToLower(name)
switch {
case strings.Contains(n, "mana stone"):
return "Mana Stone"
case strings.Contains(n, "crystal"):
return "Crystal"
case strings.Contains(n, "gem"):
return "Gem"
case strings.Contains(n, "aetheria"):
return "Aetheria"
}
return "Gem"
}
return base
}
// translateSetID mirrors translate_equipment_set_id (AttributeSetInfo lookup,
// ID-string fallback).
func (s *Server) translateSetID(setID string) string {
if name, ok := s.attributeSets[setID]; ok {
return name
}
return setID
}
// underwearCTEWhere returns the WHERE clause injected into the search CTE for
// the shirt_only / pants_only / underwear_only filters (main.py:3220-3251).
// Coverage bits on key 218103821: UnderwearUpperLegs=2, UnderwearLowerLegs=4,
// UnderwearChest=8, UnderwearAbdomen=16.
func underwearCTEWhere(q map[string][]string) string {
switch {
case qBool(q, "shirt_only"):
return `WHERE i.object_class = 3
AND ((rd.int_values->>'218103821')::int & 8) > 0
AND NOT ((rd.int_values->>'218103821')::int & 6) = 6
AND i.name NOT ILIKE '%robe%'
AND i.name NOT ILIKE '%cloak%'
AND i.name NOT ILIKE '%pallium%'
AND i.name NOT ILIKE '%armet%'
AND i.name NOT ILIKE '%pants%'
AND i.name NOT ILIKE '%breeches%'`
case qBool(q, "pants_only"):
return `WHERE i.object_class = 3
AND ((rd.int_values->>'218103821')::int & 2) = 2
AND i.name NOT ILIKE '%robe%'
AND i.name NOT ILIKE '%cloak%'
AND i.name NOT ILIKE '%pallium%'
AND i.name NOT ILIKE '%armet%'`
case qBool(q, "underwear_only"):
return `WHERE i.object_class = 3
AND ((rd.int_values->>'218103821')::int & 30) > 0
AND i.name NOT ILIKE '%robe%'
AND i.name NOT ILIKE '%cloak%'
AND i.name NOT ILIKE '%pallium%'
AND i.name NOT ILIKE '%armet%'`
}
return ""
}
func weaponTypeClause(wt string) string {
exists := func(skill int) string {
return fmt.Sprintf("(object_class = 1 AND EXISTS (SELECT 1 FROM item_raw_data wrd WHERE wrd.item_id = db_item_id AND (wrd.int_values->>'218103840')::int = %d))", skill)
}
switch strings.ToLower(wt) {
case "heavy":
return exists(44)
case "light":
return exists(45)
case "finesse":
return exists(46)
case "two_handed":
return exists(41)
case "bow":
return "(object_class = 9 AND name ILIKE '%bow%' AND name NOT ILIKE '%crossbow%')"
case "crossbow":
return "(object_class = 9 AND name ILIKE '%crossbow%')"
case "thrown":
return "(object_class = 9 AND (name ILIKE '%atlatl%' OR name ILIKE '%throwing%' OR name ILIKE '%javelin%' OR name ILIKE '%shuriken%' OR name ILIKE '%dart%' OR name ILIKE '%slingshot%'))"
case "caster":
return "object_class = 31"
default:
return "object_class IN (1, 9, 31)"
}
}
func slotNameClause(name string, ab *argBuilder) string {
switch strings.ToLower(name) {
case "ring":
return "((computed_slot_name ILIKE '%Ring%') OR (object_class = 4 AND name ILIKE '%ring%' AND name NOT ILIKE '%keyring%' AND name NOT ILIKE '%signet%'))"
case "bracelet", "wrist":
return "((computed_slot_name ILIKE '%Wrist%') OR (object_class = 4 AND name ILIKE '%bracelet%'))"
case "neck":
return "((computed_slot_name ILIKE " + ab.add("%neck%") + ") OR (object_class = 4 AND (name ILIKE '%amulet%' OR name ILIKE '%necklace%' OR name ILIKE '%gorget%')))"
case "trinket":
// Approach 5 (jewelry fallback) MUST exclude %bracelet% — without it the
// Trinket fetch sweeps in bracelets, which then duplicate the Wrist buckets
// (also fetched via slot_names=Bracelet) and the DFS re-emits suits.
return "((computed_slot_name ILIKE " + ab.add("%trinket%") + ") OR (current_wielded_location = 67108864) OR (object_class = 4 AND (name ILIKE '%trinket%' OR name ILIKE '%compass%' OR name ILIKE '%goggles%')) OR (object_class = 11 AND name ILIKE '%trinket%') OR (object_class = 4 AND name NOT ILIKE '%ring%' AND name NOT ILIKE '%bracelet%' AND name NOT ILIKE '%amulet%' AND name NOT ILIKE '%necklace%' AND name NOT ILIKE '%gorget%'))"
case "cloak":
return "((computed_slot_name ILIKE " + ab.add("%cloak%") + ") OR (name ILIKE '%cloak%') OR (computed_slot_name = 'Cloak'))"
default:
return "(computed_slot_name ILIKE " + ab.add("%"+name+"%") + ")"
}
}
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
}