From c04cfaf2c619bd46fde21cf724468a893a4132ce Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 24 Jun 2026 11:56:10 +0200 Subject: [PATCH] =?UTF-8?q?feat(go-services):=20inventory-go=20search=20?= =?UTF-8?q?=E2=80=94=20computed=5Fslot=5Fname=20+=20slot/weapon/set=20filt?= =?UTF-8?q?ers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- go-services/inventory-go/search.go | 125 ++++++++++++++++++++++++++++- 1 file changed, 124 insertions(+), 1 deletion(-) diff --git a/go-services/inventory-go/search.go b/go-services/inventory-go/search.go index f7311107..b0db7b37 100644 --- a/go-services/inventory-go/search.go +++ b/go-services/inventory-go/search.go @@ -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, ",") {