MosswartOverlord/go-services/inventory-go/main.go
Erik 57f53ff36b feat(inventory-go): port the suitbuilder solver (/suitbuilder/search) — validated
Full Go port of suitbuilder.py's ConstraintSatisfactionSolver (the LIVE solver
behind the suitbuilder UI; main.py's /optimize/suits is legacy/unused):

- suit_model.go: CoverageMask + reductions, SuitItem/ItemBucket/SuitState,
  SpellBitmapIndex, ScoringWeights, SearchConstraints, CompletedSuit.to_dict,
  ItemPreFilter, set name<->id maps. Every sort carries (character_name, name)
  tiebreakers for deterministic results.
- suit_solver.go: the 5-phase pipeline — load_items (fed in-process by the Go
  /search/items), create_buckets (+multi-slot/generic-jewelry expansion),
  apply_reduction_options, sort_buckets, and the depth-first recursive_search
  with both Mag-SuitBuilder pruning rules, can_add_item constraints (set limits,
  jewelry spell contribution, strict spell mode), scoring, and finalize.
- suit_http.go: POST /suitbuilder/search (SSE: phase/log/suit/progress/complete),
  GET /suitbuilder/characters, GET /suitbuilder/sets.
- search.go: refactor handleSearchItems -> shared runSearch (the solver reuses
  it so both see identical rows); emit slot_name (get_sophisticated_slot_options
  + translate_equipment_slot); fix the trinket slot_names clause to exclude
  %bracelet% (matches Python).
- slotname.go: the EquipMask-based slot translation, loaded from the enum DB.

Validation: 9/9 scenarios stream byte-identical suits vs the Python service on
production data (no-spell, multi-character, locked slots with/without spells,
spell constraints, alternate set pairs, primary-only). ~45x faster than Python.

Three subtle bugs found and fixed during validation:
- slot_name is load-bearing, not display: jewelry's computed_slot_name is empty,
  so load_items falls back to slot_name to bucket rings/neck/wrists/trinket.
- Python scoring uses floor division (total_armor // 100); total_armor goes
  negative (non-armor items carry armor_level -1) so Go's truncation was +1 off.
- the trinket fetch must exclude bracelets or they duplicate the Wrist buckets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 14:03:59 +02:00

303 lines
10 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"
"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.
if !readOnly {
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_<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
}
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())
})
}