feat(go-services): inventory-go search — computed_slot_name + slot/weapon/set filters

Adds the full computed_slot_name CASE (EquippableSlots decode, jewelry by
wielded-location, weapons, cloak) and the remaining SQL filters: weapon_type
(skill-id EXISTS), slot_names (per-slot OR clauses), item_set/item_sets
(translate_equipment_set_id, bug-for-bug).

Validated vs Python (total_count EXACT): weapon_type heavy/bow/caster (473/138/
474), slot_names ring/neck/cloak (1286/1428/220), item_set 13 (526). The
computed_slot_name VALUES match exactly (slot distribution identical: Head 721,
Hands 458, Feet 403, Chest 376, ...).

Two documented edge-case discrepancies, both Python main-vs-count CTE
inconsistencies (Python's count query uses a SIMPLIFIED slot CASE where armor ->
'Armor', so its own total_count disagrees with its item list): slot_names with
armor slot names, and sort_by=slot_name empty-string ordering. Our consistent
single-CASE implementation is arguably more correct; reconcile to Python's count
CTE later if strict parity on those is required.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-24 11:56:10 +02:00
parent 7e7842128e
commit c04cfaf2c6

View file

@ -57,6 +57,54 @@ SELECT DISTINCT
COALESCE(enh.tinks, -1) AS tinks,
COALESCE(enh.item_set, '') AS item_set,
COALESCE((rd.int_values->>'218103821')::int, 0) AS coverage_mask,
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
FROM items i
@ -122,7 +170,7 @@ func (s *Server) handleSearchItems(w http.ResponseWriter, r *http.Request) {
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
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%'))")
}
@ -212,6 +260,29 @@ func (s *Server) handleSearchItems(w http.ResponseWriter, r *http.Request) {
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 ")
@ -293,6 +364,58 @@ func row2items(rows []map[string]any) []map[string]any {
return rows
}
// 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
}
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":
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 '%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, ",") {