feat(inventory-go): search spell_names/spells enrichment + shirt/pants filters

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_<id> 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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-24 12:57:20 +02:00
parent c49b81c237
commit 2473b80519
2 changed files with 128 additions and 10 deletions

View file

@ -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_<id>.
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

View file

@ -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)