// 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" "fmt" "log/slog" "net/http" "os" "os/signal" "sort" "strconv" "strings" "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 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() { 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{}, spells: map[int]map[string]any{}} if e, err := loadEnums(enumPath); err != nil { 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 srv.spells = e.spells srv.equipMaskMap = e.equipMaskMap srv.equipMaskOrdered = e.equipMaskOrdered logger.Info("loaded enum DB", "sets", len(e.sets), "object_classes", len(e.objectClasses), "materials", len(e.materials), "spells", len(e.spells), "equip_masks", len(e.equipMaskOrdered)) } 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 // Ingest mode owns its DB: create the schema on first run. In cutover // (reusing the production inventory_db) SKIP_SCHEMA_INIT runs no DDL. if !readOnly && envOr("SKIP_SCHEMA_INIT", "false") != "true" { sctx, c := context.WithTimeout(ctx, 60*time.Second) initSchema(sctx, pool, logger) c() } 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) // Ingestion (works in read-write mode; on the read-only prod instance these // fail the read-only transaction, which is the intended guard). mux.HandleFunc("POST /process-inventory", srv.handleProcessInventory) mux.HandleFunc("POST /inventory/{character_name}/item", srv.handleUpsertItem) mux.HandleFunc("DELETE /inventory/{character_name}/item/{item_id}", srv.handleDeleteItem) // Suitbuilder (port of suitbuilder.py router, mounted at /suitbuilder). mux.HandleFunc("POST /suitbuilder/search", srv.handleSuitSearch) mux.HandleFunc("GET /suitbuilder/characters", srv.handleSuitCharacters) mux.HandleFunc("GET /suitbuilder/sets", srv.handleSuitSets) 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 spells map[int]map[string]any equipMaskMap map[int]string equipMaskOrdered []equipMaskEntry } // 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"` Spells struct { Values map[string]map[string]any `json:"values"` } `json:"spells"` } 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"]) // 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 } } // EquipMask: mask -> technical name. Skip EXPR: keys; order by ascending mask // (the JSON order) so multi-bit bit-flag decode joins parts deterministically. em.equipMaskMap = map[int]string{} for k, v := range db.Enums["EquipMask"].Values { if strings.HasPrefix(k, "EXPR:") { continue } if n, err := strconv.Atoi(k); err == nil { em.equipMaskMap[n] = v em.equipMaskOrdered = append(em.equipMaskOrdered, equipMaskEntry{Mask: n, Name: v}) } } sort.Slice(em.equipMaskOrdered, func(i, j int) bool { return em.equipMaskOrdered[i].Mask < em.equipMaskOrdered[j].Mask }) 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_. 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 } 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()) }) }