From b4bee24773ef5f6d5c8141c744b2ea0613355335 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 15 Jul 2026 08:41:43 +0200 Subject: [PATCH] feat(inventory-go): weapon_types multi-select filter by skill (melee/missile/war/void) Co-Authored-By: Claude Sonnet 5 --- go-services/inventory-go/search.go | 58 +++++++++++++++++++---- go-services/inventory-go/search_test.go | 62 +++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 9 deletions(-) create mode 100644 go-services/inventory-go/search_test.go diff --git a/go-services/inventory-go/search.go b/go-services/inventory-go/search.go index ffa55a3d..b35a870c 100644 --- a/go-services/inventory-go/search.go +++ b/go-services/inventory-go/search.go @@ -191,7 +191,7 @@ func (s *Server) runSearch(ctx context.Context, q url.Values) (map[string]any, e case qBool(q, "jewelry_only"): conds = append(conds, "object_class = 4") case qBool(q, "weapon_only"): - conds = append(conds, weaponTypeClause(q.Get("weapon_type"))) + conds = append(conds, weaponOnlyClause(q)) 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%'))") } @@ -543,24 +543,36 @@ func underwearCTEWhere(q map[string][]string) string { } func weaponTypeClause(wt string) string { - exists := func(skill int) string { + meleeSkill := 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) } + missileMastery := func(mastery int) string { + return fmt.Sprintf("(object_class = 9 AND EXISTS (SELECT 1 FROM item_raw_data wrd WHERE wrd.item_id = db_item_id AND (wrd.int_values->>'218103840')::int = 47 AND (wrd.int_values->>'353')::int = %d))", mastery) + } + casterSkill := func(skill int) string { + return fmt.Sprintf("(object_class = 31 AND EXISTS (SELECT 1 FROM item_raw_data wrd WHERE wrd.item_id = db_item_id AND (wrd.int_values->>'159')::int = %d))", skill) + } switch strings.ToLower(wt) { case "heavy": - return exists(44) + return meleeSkill(44) case "light": - return exists(45) + return meleeSkill(45) case "finesse": - return exists(46) + return meleeSkill(46) case "two_handed": - return exists(41) + return meleeSkill(41) + case "missile": + return "(object_class = 9 AND EXISTS (SELECT 1 FROM item_raw_data wrd WHERE wrd.item_id = db_item_id AND (wrd.int_values->>'218103840')::int = 47))" case "bow": - return "(object_class = 9 AND name ILIKE '%bow%' AND name NOT ILIKE '%crossbow%')" + return missileMastery(8) case "crossbow": - return "(object_class = 9 AND name ILIKE '%crossbow%')" + return missileMastery(9) 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%'))" + return missileMastery(10) + case "war": + return casterSkill(34) + case "void": + return casterSkill(43) case "caster": return "object_class = 31" default: @@ -568,6 +580,34 @@ func weaponTypeClause(wt string) string { } } +// weaponOnlyClause builds the WHERE fragment for weapon_only searches: an OR of +// the per-type clauses named in weapon_types (CSV) plus the legacy single +// weapon_type. Empty selection = all weapons. Types are deduped. +func weaponOnlyClause(q url.Values) string { + var types []string + types = append(types, splitNonEmpty(q.Get("weapon_types"))...) + if wt := strings.TrimSpace(q.Get("weapon_type")); wt != "" { + types = append(types, wt) + } + seen := map[string]bool{} + var clauses []string + for _, t := range types { + key := strings.ToLower(strings.TrimSpace(t)) + if key == "" || seen[key] { + continue + } + seen[key] = true + clauses = append(clauses, weaponTypeClause(key)) + } + if len(clauses) == 0 { + return "object_class IN (1, 9, 31)" + } + if len(clauses) == 1 { + return clauses[0] + } + return "(" + strings.Join(clauses, " OR ") + ")" +} + func slotNameClause(name string, ab *argBuilder) string { switch strings.ToLower(name) { case "ring": diff --git a/go-services/inventory-go/search_test.go b/go-services/inventory-go/search_test.go new file mode 100644 index 00000000..9f7be9c7 --- /dev/null +++ b/go-services/inventory-go/search_test.go @@ -0,0 +1,62 @@ +package main + +import ( + "net/url" + "strings" + "testing" +) + +func TestWeaponTypeClause(t *testing.T) { + cases := map[string][]string{ + "heavy": {"object_class = 1", "'218103840')::int = 44"}, + "light": {"object_class = 1", "= 45"}, + "finesse": {"object_class = 1", "= 46"}, + "two_handed": {"object_class = 1", "= 41"}, + "missile": {"object_class = 9", "'218103840')::int = 47"}, + "bow": {"object_class = 9", "'218103840')::int = 47", "'353')::int = 8"}, + "crossbow": {"object_class = 9", "'353')::int = 9"}, + "thrown": {"object_class = 9", "'353')::int = 10"}, + "war": {"object_class = 31", "'159')::int = 34"}, + "void": {"object_class = 31", "'159')::int = 43"}, + "caster": {"object_class = 31"}, + } + for wt, subs := range cases { + got := weaponTypeClause(wt) + for _, s := range subs { + if !strings.Contains(got, s) { + t.Errorf("weaponTypeClause(%q) = %q, missing %q", wt, got, s) + } + } + } + if got := weaponTypeClause("nonsense"); !strings.Contains(got, "object_class IN (1, 9, 31)") { + t.Errorf("unknown type = %q, want all-weapons fallback", got) + } +} + +func TestWeaponOnlyClause(t *testing.T) { + // No types → all weapons. + if got := weaponOnlyClause(url.Values{}); !strings.Contains(got, "object_class IN (1, 9, 31)") { + t.Errorf("empty = %q, want all weapons", got) + } + // Single type via weapon_types. + got := weaponOnlyClause(url.Values{"weapon_types": {"heavy"}}) + if !strings.Contains(got, "= 44") || strings.Contains(got, " OR ") { + t.Errorf("single = %q, want just heavy, no OR", got) + } + // Multiple → parenthesized OR, one clause per type. + got = weaponOnlyClause(url.Values{"weapon_types": {"heavy,two_handed,war"}}) + if !strings.HasPrefix(got, "(") || !strings.HasSuffix(got, ")") || + strings.Count(got, " OR ") != 2 || + !strings.Contains(got, "= 44") || !strings.Contains(got, "= 41") || !strings.Contains(got, "= 34") { + t.Errorf("multi = %q, want (heavy OR two_handed OR war)", got) + } + // Legacy weapon_type still honored, deduped against weapon_types. + got = weaponOnlyClause(url.Values{"weapon_type": {"missile"}}) + if !strings.Contains(got, "object_class = 9") { + t.Errorf("legacy weapon_type = %q, want missile", got) + } + got = weaponOnlyClause(url.Values{"weapon_types": {"heavy"}, "weapon_type": {"heavy"}}) + if strings.Contains(got, " OR ") { + t.Errorf("dedup = %q, want single heavy clause", got) + } +}