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:
parent
c49b81c237
commit
2473b80519
2 changed files with 128 additions and 10 deletions
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -26,9 +27,10 @@ var buildVersion = "dev"
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
attributeSets map[string]string // AttributeSetInfo: set-id -> set name
|
attributeSets map[string]string // AttributeSetInfo: set-id -> set name
|
||||||
objectClasses map[int]string // ObjectClass: id -> name
|
objectClasses map[int]string // ObjectClass: id -> name
|
||||||
materials map[int]string // MaterialType: 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
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,15 +48,16 @@ func main() {
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
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 {
|
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 {
|
} else {
|
||||||
srv.attributeSets = e.sets
|
srv.attributeSets = e.sets
|
||||||
srv.objectClasses = e.objectClasses
|
srv.objectClasses = e.objectClasses
|
||||||
srv.materials = e.materials
|
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 == "" {
|
if dsn == "" {
|
||||||
|
|
@ -181,6 +184,7 @@ type enumMaps struct {
|
||||||
sets map[string]string
|
sets map[string]string
|
||||||
objectClasses map[int]string
|
objectClasses map[int]string
|
||||||
materials map[int]string
|
materials map[int]string
|
||||||
|
spells map[int]map[string]any
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadEnums reads the comprehensive enum DB and extracts AttributeSetInfo
|
// 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"`
|
Dictionaries map[string]valmap `json:"dictionaries"`
|
||||||
Enums map[string]valmap `json:"enums"`
|
Enums map[string]valmap `json:"enums"`
|
||||||
ObjectClasses valmap `json:"object_classes"`
|
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 {
|
if err := json.Unmarshal(b, &db); err != nil {
|
||||||
return em, err
|
return em, err
|
||||||
|
|
@ -220,9 +227,41 @@ func loadEnums(path string) (enumMaps, error) {
|
||||||
}
|
}
|
||||||
em.objectClasses = intMap(db.ObjectClasses)
|
em.objectClasses = intMap(db.ObjectClasses)
|
||||||
em.materials = intMap(db.Enums["MaterialType"])
|
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
|
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 {
|
func envOr(key, def string) string {
|
||||||
if v := os.Getenv(key); v != "" {
|
if v := os.Getenv(key); v != "" {
|
||||||
return v
|
return v
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,15 @@ SELECT DISTINCT
|
||||||
ELSE '-'
|
ELSE '-'
|
||||||
END AS computed_slot_name,
|
END AS computed_slot_name,
|
||||||
COALESCE((SELECT STRING_AGG(CAST(sp_inner.spell_id AS VARCHAR), ',' ORDER BY sp_inner.spell_id)
|
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
|
FROM items i
|
||||||
LEFT JOIN item_combat_stats cs ON i.id = cs.item_id
|
LEFT JOIN item_combat_stats cs ON i.id = cs.item_id
|
||||||
LEFT JOIN item_requirements req ON i.id = req.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)
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||||
defer cancel()
|
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 +
|
mainSQL := cte + "SELECT * FROM items_with_slots" + where + orderBy +
|
||||||
" LIMIT " + ab.add(limit) + " OFFSET " + ab.add(offset)
|
" LIMIT " + ab.add(limit) + " OFFSET " + ab.add(offset)
|
||||||
rows, err := queryRowsAsMaps(ctx, s.pool, mainSQL, ab.args...)
|
rows, err := queryRowsAsMaps(ctx, s.pool, mainSQL, ab.args...)
|
||||||
|
|
@ -316,8 +331,14 @@ func (s *Server) handleSearchItems(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// count uses the same CTE + conditions (full CTE is correct; Python trims it
|
// count uses the SAME CTE (incl. the underwear injection) + conditions, so
|
||||||
// only as an optimization). LIMIT/OFFSET args are unused here.
|
// 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
|
countSQL := cte + "SELECT COUNT(DISTINCT db_item_id) FROM items_with_slots" + where
|
||||||
var totalCount int64
|
var totalCount int64
|
||||||
if err := s.pool.QueryRow(ctx, countSQL, ab.args[:len(ab.args)-2]...).Scan(&totalCount); err != nil {
|
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")
|
delete(row, "db_item_id")
|
||||||
out = append(out, row)
|
out = append(out, row)
|
||||||
}
|
}
|
||||||
|
|
@ -424,6 +469,40 @@ func (s *Server) translateSetID(setID string) string {
|
||||||
return setID
|
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 {
|
func weaponTypeClause(wt string) string {
|
||||||
exists := func(skill int) 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)
|
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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue