MosswartOverlord/go-services/tracker-go/charstats.go
Erik c4e8190656 feat(go-services): tracker-go — complete the Phase 1 read API
Adds the rest of the read-side endpoints to the Go tracker, all parity-checked
against the live Python service:

- DB reads: /stats/{c}, /portals, /spawns/heatmap, /server-health,
  /character-stats/{c} (stats_data JSONB merged to top level),
  /combat-stats[/{c}], /inventories, /inventory/{c}/search.
- 5-minute totals cache + /total-rares, /total-kills.
- Ingest-only state returned as Python's empty/default shapes (/quest-status,
  /vital-sharing/peers, /equipment-cantrip-state/{c}); /issues (flat file),
  /me (401 until cookie verification lands).
- Streaming reverse proxy to inventory-service (/inventory/{c},
  /inventory-characters, /search/*, /sets/list, /inv/{path...} incl. the SSE
  suitbuilder stream).
- compare/compare_endpoints.py: structural parity for all read endpoints +
  exact-match check for /character-stats and /combat-stats on OFFLINE chars
  (online chars legitimately differ — Python serves a richer live overlay that
  Phase-1 Go lacks until ingest).

Verified live: 14/14 endpoints structural-match, 8/8 rich offline chars
exact-match on /character-stats.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 09:38:10 +02:00

87 lines
2.6 KiB
Go

package main
import (
"net/http"
"sort"
)
// GET /character-stats/{name} — latest full stats. Phase 1 reads the DB
// (character_stats is authoritative); the live_character_stats overlay is an
// ingest-only freshness layer we don't have yet. (main.py:4137)
func (s *Server) handleCharacterStats(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
ctx, cancel := reqCtx(r)
defer cancel()
row, err := queryRowAsMap(ctx, s.pool, `SELECT * FROM character_stats WHERE character_name = $1`, name)
if err != nil {
s.dbErr(w, "character-stats", err)
return
}
if row == nil {
writeJSON(w, http.StatusNotFound, map[string]any{"error": "No stats available for this character"})
return
}
// Merge stats_data JSONB up to the top level, matching the frontend contract.
sd := asJSONMap(row["stats_data"])
delete(row, "stats_data")
formatTimes([]map[string]any{row}, "timestamp")
for k, v := range sd {
row[k] = v
}
writeJSON(w, http.StatusOK, row)
}
// GET /combat-stats/{character_name} — lifetime combat blob. Phase 1: DB only,
// so session is always null. (main.py:1819)
func (s *Server) handleCombatStatsOne(w http.ResponseWriter, r *http.Request) {
cn := r.PathValue("character_name")
ctx, cancel := reqCtx(r)
defer cancel()
row, err := queryRowAsMap(ctx, s.pool, `SELECT stats_data FROM combat_stats WHERE character_name = $1`, cn)
if err != nil {
s.dbErr(w, "combat-stats/one", err)
return
}
if row == nil {
writeJSON(w, http.StatusOK, map[string]any{"character_name": cn, "session": nil, "lifetime": nil})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"character_name": cn,
"session": nil,
"lifetime": decodeJSONValue(row["stats_data"]),
})
}
// GET /combat-stats — all characters' lifetime combat blobs. Phase 1: DB only. (main.py:1850)
func (s *Server) handleCombatStatsAll(w http.ResponseWriter, r *http.Request) {
ctx, cancel := reqCtx(r)
defer cancel()
rows, err := queryRowsAsMaps(ctx, s.pool, `SELECT character_name, stats_data FROM combat_stats`)
if err != nil {
s.dbErr(w, "combat-stats/all", err)
return
}
results := make([]map[string]any, 0, len(rows))
for _, row := range rows {
results = append(results, map[string]any{
"character_name": row["character_name"],
"session": nil,
"lifetime": decodeJSONValue(row["stats_data"]),
})
}
sort.Slice(results, func(i, j int) bool {
return toStr(results[i]["character_name"]) < toStr(results[j]["character_name"])
})
writeJSON(w, http.StatusOK, map[string]any{"stats": results})
}
func toStr(v any) string {
if s, ok := v.(string); ok {
return s
}
return ""
}