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"
|
||||
"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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue