From 2473b80519fc56cb8ab2a730868be8b68de48038 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 24 Jun 2026 12:57:20 +0200 Subject: [PATCH] feat(inventory-go): search spell_names/spells enrichment + shirt/pants filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the remaining search-result enrichment that the suitbuilder solver (and the item-detail UI) depend on, validated byte-exact against the Python service on production data: - Load the SpellTable (spells.values, 6266 entries) from the enum DB and port translate_spell (id -> {id,name,description,school,difficulty,duration,mana, family}, Unknown_Spell_ fallback, "" defaults). - Emit `spells` (full dicts) and `spell_names` from the ordered passive Spells array (original_json->'Spells', array order + duplicates preserved), exactly as enrich_db_item/extract_item_properties do — NOT from item_spells. Only set when the item has spells. A jsonb_typeof guard keeps non-array Spells safe. - Add the shirt_only / pants_only / underwear_only filters as CTE-body WHERE injections (coverage-bit logic on key 218103821), mirroring main.py. Validation (char Plant Enjoyer, all chars): spell_names 0 mismatches (8 spell items), spells[].name 0 mismatches, shirt_only/pants_only item sets identical (0 only-py / 0 only-go). Normal-search total_count still matches Python. Note: for shirt/pants/slot filters Python's total_count is inconsistent with its own items (separate count CTE lacks the injection); Go uses one CTE so the count is self-consistent. Deliberately not replicating that Python bug. Co-Authored-By: Claude Opus 4.8 --- go-services/inventory-go/main.go | 51 +++++++++++++++--- go-services/inventory-go/search.go | 87 ++++++++++++++++++++++++++++-- 2 files changed, 128 insertions(+), 10 deletions(-) diff --git a/go-services/inventory-go/main.go b/go-services/inventory-go/main.go index 68576db7..cd2d5b00 100644 --- a/go-services/inventory-go/main.go +++ b/go-services/inventory-go/main.go @@ -11,6 +11,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "log/slog" "net/http" "os" @@ -26,9 +27,10 @@ var buildVersion = "dev" type Server struct { pool *pgxpool.Pool - attributeSets map[string]string // AttributeSetInfo: set-id -> set name - objectClasses map[int]string // ObjectClass: id -> name - materials map[int]string // MaterialType: id -> name + attributeSets map[string]string // AttributeSetInfo: set-id -> set name + objectClasses map[int]string // ObjectClass: id -> name + materials map[int]string // MaterialType: id -> name + spells map[int]map[string]any // SpellTable: spell-id -> raw spell value object log *slog.Logger } @@ -46,15 +48,16 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() - srv := &Server{log: logger, attributeSets: map[string]string{}, objectClasses: map[int]string{}, materials: map[int]string{}} + srv := &Server{log: logger, attributeSets: map[string]string{}, objectClasses: map[int]string{}, materials: map[int]string{}, spells: map[int]map[string]any{}} if e, err := loadEnums(enumPath); err != nil { - logger.Warn("could not load enum DB (set/class/material names will be unknown)", "err", err, "path", enumPath) + logger.Warn("could not load enum DB (set/class/material/spell names will be unknown)", "err", err, "path", enumPath) } else { srv.attributeSets = e.sets srv.objectClasses = e.objectClasses srv.materials = e.materials - logger.Info("loaded enum DB", "sets", len(e.sets), "object_classes", len(e.objectClasses), "materials", len(e.materials)) + srv.spells = e.spells + logger.Info("loaded enum DB", "sets", len(e.sets), "object_classes", len(e.objectClasses), "materials", len(e.materials), "spells", len(e.spells)) } if dsn == "" { @@ -181,6 +184,7 @@ type enumMaps struct { sets map[string]string objectClasses map[int]string materials map[int]string + spells map[int]map[string]any } // loadEnums reads the comprehensive enum DB and extracts AttributeSetInfo @@ -199,6 +203,9 @@ func loadEnums(path string) (enumMaps, error) { Dictionaries map[string]valmap `json:"dictionaries"` Enums map[string]valmap `json:"enums"` ObjectClasses valmap `json:"object_classes"` + Spells struct { + Values map[string]map[string]any `json:"values"` + } `json:"spells"` } if err := json.Unmarshal(b, &db); err != nil { return em, err @@ -220,9 +227,41 @@ func loadEnums(path string) (enumMaps, error) { } em.objectClasses = intMap(db.ObjectClasses) em.materials = intMap(db.Enums["MaterialType"]) + // SpellTable: spell-id -> raw value object (translate_spell reads .name etc.). + em.spells = map[int]map[string]any{} + for k, v := range db.Spells.Values { + if n, err := strconv.Atoi(k); err == nil { + em.spells[n] = v + } + } return em, nil } +// translateSpell mirrors main.py translate_spell: returns the spell dict +// (id + name/description/school/difficulty/duration/mana/family), defaulting +// missing fields to "" and the name to Unknown_Spell_. +func (s *Server) translateSpell(id int) map[string]any { + raw := s.spells[id] + get := func(k string, def any) any { + if raw != nil { + if v, ok := raw[k]; ok { + return v + } + } + return def + } + return map[string]any{ + "id": id, + "name": get("name", fmt.Sprintf("Unknown_Spell_%d", id)), + "description": get("description", ""), + "school": get("school", ""), + "difficulty": get("difficulty", ""), + "duration": get("duration", ""), + "mana": get("mana", ""), + "family": get("family", ""), + } +} + func envOr(key, def string) string { if v := os.Getenv(key); v != "" { return v diff --git a/go-services/inventory-go/search.go b/go-services/inventory-go/search.go index 847ff782..6168aaea 100644 --- a/go-services/inventory-go/search.go +++ b/go-services/inventory-go/search.go @@ -106,7 +106,15 @@ SELECT DISTINCT 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 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 @@ -307,7 +315,14 @@ func (s *Server) handleSearchItems(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() - cte := "WITH items_with_slots AS (" + cteSelect + ")\n" + // 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...) @@ -316,8 +331,14 @@ func (s *Server) handleSearchItems(w http.ResponseWriter, r *http.Request) { return } - // count uses the same CTE + conditions (full CTE is correct; Python trims it - // only as an optimization). LIMIT/OFFSET args are unused here. + // 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 { @@ -383,6 +404,30 @@ func (s *Server) enrichRows(rows []map[string]any) []map[string]any { } } + // 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") + delete(row, "db_item_id") out = append(out, row) } @@ -424,6 +469,40 @@ func (s *Server) translateSetID(setID string) string { 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)