diff --git a/go-services/inventory-go/spell_filter.go b/go-services/inventory-go/spell_filter.go index b85056e5..8e132082 100644 --- a/go-services/inventory-go/spell_filter.go +++ b/go-services/inventory-go/spell_filter.go @@ -1,6 +1,7 @@ package main import ( + "net/url" "sort" "strings" ) @@ -58,3 +59,48 @@ func exactSpellID(spells map[int]map[string]any, q string) (int, bool) { } return best, best != -1 } + +// spellExists renders the EXISTS clause for a set of spell IDs, binding each +// ID as a positional arg. ids must be non-empty. +func spellExists(ids []int, ab *argBuilder) string { + ph := make([]string, len(ids)) + for i, id := range ids { + ph[i] = ab.add(id) + } + return "EXISTS (SELECT 1 FROM item_spells sp WHERE sp.item_id = db_item_id AND sp.spell_id IN (" + strings.Join(ph, ", ") + "))" +} + +// spellFilterConds builds the WHERE conditions for has_spell, spell_contains, +// and legendary_cantrips. Cantrips use AND semantics — one EXISTS per checked +// cantrip (NOT the legacy COUNT>=N trick, which can false-positive when one +// label matches two spells on an item while another matches none). Any +// no-match filter yields the impossible condition, matching legacy intent. +func spellFilterConds(q url.Values, spells map[int]map[string]any, ab *argBuilder) []string { + var conds []string + impossible := func() { conds = append(conds, "1 = 0") } + + if v := q.Get("has_spell"); v != "" { + if id, ok := exactSpellID(spells, v); ok { + conds = append(conds, spellExists([]int{id}, ab)) + } else { + impossible() + } + } + if v := q.Get("spell_contains"); v != "" { + if ids := spellIDsContaining(spells, v); len(ids) > 0 { + conds = append(conds, spellExists(ids, ab)) + } else { + impossible() + } + } + if v := q.Get("legendary_cantrips"); v != "" { + for _, name := range splitNonEmpty(v) { + if ids := spellIDsForCantrip(spells, name); len(ids) > 0 { + conds = append(conds, spellExists(ids, ab)) + } else { + impossible() + } + } + } + return conds +} diff --git a/go-services/inventory-go/spell_filter_test.go b/go-services/inventory-go/spell_filter_test.go index f556dee7..4ecc4c1a 100644 --- a/go-services/inventory-go/spell_filter_test.go +++ b/go-services/inventory-go/spell_filter_test.go @@ -50,3 +50,65 @@ func TestExactSpellID(t *testing.T) { t.Error("partial name must not exact-match") } } + +func TestSpellFilterConds_Cantrips(t *testing.T) { + ab := &argBuilder{} + q := map[string][]string{"legendary_cantrips": {"Legendary Invulnerability, Legendary Summoning Prowess"}} + conds := spellFilterConds(q, testSpells, ab) + want := []string{ + "EXISTS (SELECT 1 FROM item_spells sp WHERE sp.item_id = db_item_id AND sp.spell_id IN ($1))", + "EXISTS (SELECT 1 FROM item_spells sp WHERE sp.item_id = db_item_id AND sp.spell_id IN ($2, $3))", + } + if !reflect.DeepEqual(conds, want) { + t.Errorf("conds = %v, want %v", conds, want) + } + // "Legendary Invulnerability" exact-contains only id 1; the Summoning + // label matches 3 and 5. AND semantics = one EXISTS per cantrip. + if wantArgs := []any{1, 3, 5}; !reflect.DeepEqual(ab.args, wantArgs) { + t.Errorf("args = %v, want %v", ab.args, wantArgs) + } +} + +func TestSpellFilterConds_NoMatchIsImpossible(t *testing.T) { + ab := &argBuilder{} + q := map[string][]string{"legendary_cantrips": {"Legendary Frostbite"}} + if conds := spellFilterConds(q, testSpells, ab); !reflect.DeepEqual(conds, []string{"1 = 0"}) { + t.Errorf("unknown cantrip conds = %v, want [1 = 0]", conds) + } + ab = &argBuilder{} + q = map[string][]string{"spell_contains": {"frostbite"}} + if conds := spellFilterConds(q, testSpells, ab); !reflect.DeepEqual(conds, []string{"1 = 0"}) { + t.Errorf("unknown spell_contains conds = %v, want [1 = 0]", conds) + } +} + +func TestSpellFilterConds_ContainsAndHasSpell(t *testing.T) { + ab := &argBuilder{} + q := map[string][]string{"spell_contains": {"invulnerability"}} + want := []string{"EXISTS (SELECT 1 FROM item_spells sp WHERE sp.item_id = db_item_id AND sp.spell_id IN ($1, $2))"} + if conds := spellFilterConds(q, testSpells, ab); !reflect.DeepEqual(conds, want) { + t.Errorf("spell_contains conds = %v, want %v", conds, want) + } + + ab = &argBuilder{} + q = map[string][]string{"has_spell": {"Epic Invulnerability"}} + want = []string{"EXISTS (SELECT 1 FROM item_spells sp WHERE sp.item_id = db_item_id AND sp.spell_id IN ($1))"} + if conds := spellFilterConds(q, testSpells, ab); !reflect.DeepEqual(conds, want) { + t.Errorf("has_spell conds = %v, want %v", conds, want) + } + if _, ok := exactSpellID(testSpells, "No Such Spell"); ok { + t.Fatal("precondition") + } + ab = &argBuilder{} + q = map[string][]string{"has_spell": {"No Such Spell"}} + if conds := spellFilterConds(q, testSpells, ab); !reflect.DeepEqual(conds, []string{"1 = 0"}) { + t.Errorf("unknown has_spell conds = %v, want [1 = 0]", conds) + } +} + +func TestSpellFilterConds_Empty(t *testing.T) { + ab := &argBuilder{} + if conds := spellFilterConds(map[string][]string{}, testSpells, ab); conds != nil { + t.Errorf("no params => nil conds, got %v", conds) + } +}