fix(inventory-go): restore GET /inventory/{name} (live Inv window was empty)

The Go cutover omitted get_character_inventory; the React InventoryWindow
fetches GET /inventory/{name} and got 404 -> empty. Port the endpoint:
per-character items with placement (current_wielded_location/container_id/
items_capacity), mana (current_mana/max_mana from original_json IntValues),
icon overlays, and join-table combat/req/enh/rating stats; material-prefixed
name. Returns {character_name,item_count,items}.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-25 21:44:16 +02:00
parent 4bc51a1f48
commit db534ea389
3 changed files with 136 additions and 8 deletions

View file

@ -0,0 +1,92 @@
package main
import (
"net/http"
"strings"
)
// GET /inventory/{character_name} — full per-character inventory for the React
// Inventory window. Port of inventory-service get_character_inventory +
// enrich_db_item (main.py:2622 / 2338). The Go cutover omitted this endpoint
// (it was assumed unused), but the React InventoryWindow fetches it, so its
// absence (404) made the live inventory render empty.
//
// Returns {character_name, item_count, items:[...]} with the snake_case fields
// the frontend normalizeItem consumes: placement via current_wielded_location /
// container_id / items_capacity, the mana panel via current_mana / max_mana,
// icon overlays, plus tooltip combat/requirement/enhancement/rating stats. Mana
// and icon overlays are pulled straight from original_json IntValues (same keys
// the plugin/search path use); the rest come from the normalized join tables.
func (s *Server) handleCharacterInventory(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("character_name")
limit := clampInt(qIntDefault(r.URL.Query(), "limit", 1000), 1, 5000)
offset := qIntDefault(r.URL.Query(), "offset", 0)
if offset < 0 {
offset = 0
}
const q = `
SELECT
i.item_id, i.name, i.icon, i.object_class, i.value, i.burden,
i.current_wielded_location, i.container_id, i.items_capacity, i.stack_size,
cs.max_damage, cs.armor_level, cs.damage_bonus, cs.attack_bonus,
cs.melee_defense_bonus, cs.magic_defense_bonus,
r.wield_level, r.skill_level, r.equip_skill, r.lore_requirement,
e.material, e.imbue, e.item_set, e.tinks, e.workmanship,
rt.damage_rating, rt.crit_rating, rt.crit_damage_rating, rt.heal_boost_rating,
NULLIF((rd.original_json->'IntValues'->>'218103815')::int, 0) AS current_mana,
NULLIF((rd.original_json->'IntValues'->>'218103814')::int, 0) AS max_mana,
NULLIF((rd.original_json->'IntValues'->>'218103849')::int, 0) AS icon_overlay_id,
NULLIF((rd.original_json->'IntValues'->>'218103850')::int, 0) AS icon_underlay_id
FROM items i
LEFT JOIN item_combat_stats cs ON i.id = cs.item_id
LEFT JOIN item_requirements r ON i.id = r.item_id
LEFT JOIN item_enhancements e ON i.id = e.item_id
LEFT JOIN item_ratings rt ON i.id = rt.item_id
LEFT JOIN item_raw_data rd ON i.id = rd.item_id
WHERE i.character_name = $1
ORDER BY i.name
LIMIT $2 OFFSET $3`
rows, err := queryRowsAsMaps(r.Context(), s.pool, q, name, limit, offset)
if err != nil {
s.dbErr(w, "inventory/"+name, err)
return
}
items := make([]map[string]any, 0, len(rows))
for _, row := range rows {
items = append(items, enrichInventoryRow(row))
}
// Unlike the Python endpoint (404 on no rows), always return 200 with a
// possibly-empty list — the window treats both as empty, and 200 avoids the
// frontend's catch-all error path.
writeJSON(w, http.StatusOK, map[string]any{
"character_name": name,
"item_count": len(items),
"items": items,
})
}
// enrichInventoryRow flattens a joined inventory row into the frontend item
// shape: drops NULL columns and applies the material-name prefix to the item
// name (enrich_db_item parity, e.g. "Pyreal" + "Chiran Helm" -> "Pyreal Chiran
// Helm"), preserving the un-prefixed name in original_name.
func enrichInventoryRow(row map[string]any) map[string]any {
out := make(map[string]any, len(row)+2)
for k, v := range row {
if v != nil {
out[k] = v
}
}
if mat, ok := out["material"].(string); ok && mat != "" {
out["material_name"] = mat
if name, ok := out["name"].(string); ok && name != "" &&
!strings.HasPrefix(strings.ToLower(name), strings.ToLower(mat)) {
out["name"] = mat + " " + name
out["original_name"] = name
}
}
return out
}

View file

@ -0,0 +1,35 @@
package main
import "testing"
func TestEnrichInventoryRow(t *testing.T) {
// NULL columns are dropped.
out := enrichInventoryRow(map[string]any{"name": "Helm", "armor_level": nil, "value": 100})
if _, ok := out["armor_level"]; ok {
t.Errorf("nil column armor_level should be dropped, got %v", out["armor_level"])
}
if out["value"] != 100 {
t.Errorf("value = %v, want 100", out["value"])
}
// Material prefix is applied and original_name preserved.
out = enrichInventoryRow(map[string]any{"name": "Chiran Helm", "material": "Pyreal"})
if out["name"] != "Pyreal Chiran Helm" {
t.Errorf("name = %v, want %q", out["name"], "Pyreal Chiran Helm")
}
if out["original_name"] != "Chiran Helm" {
t.Errorf("original_name = %v, want %q", out["original_name"], "Chiran Helm")
}
if out["material_name"] != "Pyreal" {
t.Errorf("material_name = %v, want Pyreal", out["material_name"])
}
// Already-prefixed name is not doubled.
out = enrichInventoryRow(map[string]any{"name": "Pyreal Helm", "material": "Pyreal"})
if out["name"] != "Pyreal Helm" {
t.Errorf("name = %v, want no double prefix", out["name"])
}
if _, ok := out["original_name"]; ok {
t.Errorf("original_name should be unset when no prefix applied")
}
}

View file

@ -28,14 +28,14 @@ import (
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
spells map[int]map[string]any // SpellTable: spell-id -> raw spell value object
equipMaskMap map[int]string // EquipMask: mask -> technical name (exact lookup)
equipMaskOrdered []equipMaskEntry // EquipMask in ascending-mask order (bit-flag decode)
log *slog.Logger
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
spells map[int]map[string]any // SpellTable: spell-id -> raw spell value object
equipMaskMap map[int]string // EquipMask: mask -> technical name (exact lookup)
equipMaskOrdered []equipMaskEntry // EquipMask in ascending-mask order (bit-flag decode)
log *slog.Logger
}
func main() {
@ -93,6 +93,7 @@ func main() {
mux.HandleFunc("GET /sets/list", srv.handleSetsList)
mux.HandleFunc("GET /characters/list", srv.handleCharactersList)
mux.HandleFunc("GET /search/items", srv.handleSearchItems)
mux.HandleFunc("GET /inventory/{character_name}", srv.handleCharacterInventory)
mux.HandleFunc("POST /debug/process", srv.handleDebugProcess)
// Ingestion (works in read-write mode; on the read-only prod instance these
// fail the read-only transaction, which is the intended guard).