Ports the core item processing: raw item JSON -> normalized columns for all 7 tables, with the exact per-table sentinel->NULL rules, material/item_set string translation, the Spells/ActiveSpells union (is_active), and compute_base_values (the spell_effects buff-reversal for base_armor_level/base_max_damage/ base_attack_bonus/etc., with the data embedded and the 167772170-vs-167772172 attack-bonus id discrepancy preserved). loadEnums now also loads MaterialType. A loopback POST /debug/process returns the normalized columns for validation. Validated against production's STORED rows (read-only, no writes): 0 mismatches across 200 items for every sampled column of items, item_enhancements (incl. translated material + set), item_combat_stats (incl. base_* values), and item_ratings. This unlocks ingestion (the processor produces the rows) and the remaining search-response enrichment (spells/weapon/mana from the same extractor). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
227 lines
7 KiB
Go
227 lines
7 KiB
Go
// Command inventory-go is a Go reimplementation of the MosswartOverlord
|
|
// inventory-service (FastAPI), deployed in parallel for comparison.
|
|
//
|
|
// Phase A: read side. Connects READ-ONLY to the existing inventory_db and
|
|
// reimplements the read endpoints, validated against the Python service on the
|
|
// same data. The heavy item-processing ingestion and the suitbuilder solver
|
|
// follow in later phases.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"strconv"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
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
|
|
log *slog.Logger
|
|
}
|
|
|
|
func main() {
|
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
|
slog.SetDefault(logger)
|
|
|
|
addr := ":" + envOr("PORT", "8772")
|
|
dsn := os.Getenv("DATABASE_URL")
|
|
enumPath := envOr("ENUM_DB_PATH", "comprehensive_enum_database_v2.json")
|
|
readOnly := envOr("READ_ONLY", "true") != "false"
|
|
|
|
logger.Info("starting inventory-go", "version", buildVersion, "addr", addr, "read_only", readOnly)
|
|
|
|
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{}}
|
|
|
|
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)
|
|
} 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))
|
|
}
|
|
|
|
if dsn == "" {
|
|
logger.Error("DATABASE_URL is required")
|
|
os.Exit(1)
|
|
}
|
|
connectCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
pool, err := newPool(connectCtx, dsn, readOnly)
|
|
cancel()
|
|
if err != nil {
|
|
logger.Error("db pool init failed", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
defer pool.Close()
|
|
srv.pool = pool
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /health", srv.handleHealth)
|
|
mux.HandleFunc("GET /sets/list", srv.handleSetsList)
|
|
mux.HandleFunc("GET /characters/list", srv.handleCharactersList)
|
|
mux.HandleFunc("GET /search/items", srv.handleSearchItems)
|
|
mux.HandleFunc("POST /debug/process", srv.handleDebugProcess)
|
|
|
|
httpSrv := &http.Server{Addr: addr, Handler: withLogging(mux), ReadHeaderTimeout: 10 * time.Second}
|
|
go func() {
|
|
if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
logger.Error("http server failed", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
}()
|
|
logger.Info("listening", "addr", addr)
|
|
|
|
<-ctx.Done()
|
|
shutdownCtx, c := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer c()
|
|
_ = httpSrv.Shutdown(shutdownCtx)
|
|
logger.Info("stopped")
|
|
}
|
|
|
|
// GET /health (main.py:2674)
|
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
|
defer cancel()
|
|
dbOK := s.pool.Ping(ctx) == nil
|
|
status := "healthy"
|
|
if !dbOK {
|
|
status = "degraded"
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"status": status,
|
|
"timestamp": pyISO(time.Now()),
|
|
"database_connected": dbOK,
|
|
"version": "1.0.0",
|
|
})
|
|
}
|
|
|
|
// GET /sets/list (main.py:2712)
|
|
func (s *Server) handleSetsList(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
|
defer cancel()
|
|
rows, err := queryRowsAsMaps(ctx, s.pool, `
|
|
SELECT enh.item_set, COUNT(*) AS item_count
|
|
FROM item_enhancements enh
|
|
WHERE enh.item_set IS NOT NULL AND enh.item_set != ''
|
|
GROUP BY enh.item_set
|
|
ORDER BY item_count DESC, enh.item_set`)
|
|
if err != nil {
|
|
s.dbErr(w, "sets/list", err)
|
|
return
|
|
}
|
|
sets := make([]map[string]any, 0, len(rows))
|
|
for _, row := range rows {
|
|
setID := toStr(row["item_set"])
|
|
name, ok := s.attributeSets[setID]
|
|
if !ok {
|
|
name = "Unknown Set " + setID
|
|
}
|
|
sets = append(sets, map[string]any{"id": setID, "name": name, "item_count": row["item_count"]})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"sets": sets})
|
|
}
|
|
|
|
// GET /characters/list (main.py:4291)
|
|
func (s *Server) handleCharactersList(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
|
defer cancel()
|
|
rows, err := queryRowsAsMaps(ctx, s.pool, `
|
|
SELECT character_name, COUNT(*) AS item_count, MAX(timestamp) AS last_updated
|
|
FROM items GROUP BY character_name ORDER BY character_name`)
|
|
if err != nil {
|
|
s.dbErr(w, "characters/list", err)
|
|
return
|
|
}
|
|
formatTimes(rows, "last_updated")
|
|
chars := make([]map[string]any, 0, len(rows))
|
|
for _, row := range rows {
|
|
chars = append(chars, map[string]any{
|
|
"character_name": row["character_name"],
|
|
"item_count": row["item_count"],
|
|
"last_updated": row["last_updated"],
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"characters": chars, "total_characters": len(chars)})
|
|
}
|
|
|
|
func (s *Server) dbErr(w http.ResponseWriter, where string, err error) {
|
|
s.log.Error("db query failed", "where", where, "err", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": "Internal server error"})
|
|
}
|
|
|
|
type enumMaps struct {
|
|
sets map[string]string
|
|
objectClasses map[int]string
|
|
materials map[int]string
|
|
}
|
|
|
|
// loadEnums reads the comprehensive enum DB and extracts AttributeSetInfo
|
|
// (set-id -> name), ObjectClass (id -> name), and MaterialType (id -> name),
|
|
// mirroring load_comprehensive_enums (dictionaries first, then enums).
|
|
func loadEnums(path string) (enumMaps, error) {
|
|
var em enumMaps
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return em, err
|
|
}
|
|
type valmap struct {
|
|
Values map[string]string `json:"values"`
|
|
}
|
|
var db struct {
|
|
Dictionaries map[string]valmap `json:"dictionaries"`
|
|
Enums map[string]valmap `json:"enums"`
|
|
ObjectClasses valmap `json:"object_classes"`
|
|
}
|
|
if err := json.Unmarshal(b, &db); err != nil {
|
|
return em, err
|
|
}
|
|
em.sets = map[string]string{}
|
|
if d, ok := db.Dictionaries["AttributeSetInfo"]; ok && len(d.Values) > 0 {
|
|
em.sets = d.Values
|
|
} else if e, ok := db.Enums["AttributeSetInfo"]; ok {
|
|
em.sets = e.Values
|
|
}
|
|
intMap := func(v valmap) map[int]string {
|
|
m := map[int]string{}
|
|
for k, val := range v.Values {
|
|
if n, err := strconv.Atoi(k); err == nil {
|
|
m[n] = val
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
em.objectClasses = intMap(db.ObjectClasses)
|
|
em.materials = intMap(db.Enums["MaterialType"])
|
|
return em, nil
|
|
}
|
|
|
|
func envOr(key, def string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
func withLogging(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
next.ServeHTTP(w, r)
|
|
slog.Info("http", "method", r.Method, "path", r.URL.Path, "dur_ms", time.Since(start).Milliseconds())
|
|
})
|
|
}
|