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