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>
This commit is contained in:
Erik 2026-06-24 09:38:10 +02:00
parent 1af47520c0
commit c4e8190656
9 changed files with 908 additions and 10 deletions

View file

@ -0,0 +1,87 @@
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 ""
}

View file

@ -18,6 +18,7 @@ import (
"errors"
"log/slog"
"net/http"
"net/http/httputil"
"os"
"os/signal"
"syscall"
@ -32,9 +33,12 @@ var buildVersion = "dev"
// Server holds the shared dependencies for HTTP handlers.
type Server struct {
pool *pgxpool.Pool
cache *liveCache
log *slog.Logger
pool *pgxpool.Pool
cache *liveCache
totals *totalsCache
invProxy *httputil.ReverseProxy
staticDir string
log *slog.Logger
}
func main() {
@ -47,12 +51,23 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
srv := &Server{cache: newLiveCache(), log: logger}
srv := &Server{
cache: newLiveCache(),
totals: newTotalsCache(),
staticDir: cfg.StaticDir,
log: logger,
}
// Inventory-service reverse proxy (independent of the DB).
if err := srv.initInvProxy(cfg.InventoryURL); err != nil {
logger.Error("inventory proxy init failed", "err", err, "target", cfg.InventoryURL)
os.Exit(1)
}
// Connect to the dereth DB (read-only). If DATABASE_URL is unset we still
// serve health/version (Phase-0 mode) so the container is observable.
if cfg.DatabaseURL == "" {
logger.Warn("DATABASE_URL unset — running without DB; /live and /trails will be empty")
logger.Warn("DATABASE_URL unset — running without DB; DB-backed endpoints will be empty")
} else {
connectCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
pool, err := newPool(connectCtx, cfg.DatabaseURL)
@ -64,7 +79,8 @@ func main() {
defer pool.Close()
srv.pool = pool
go srv.runCacheLoop(ctx)
logger.Info("db connected; live cache loop started", "interval", cacheInterval.String())
go srv.runTotalsLoop(ctx)
logger.Info("db connected; cache loops started", "live_interval", cacheInterval.String(), "totals_interval", totalsInterval.String())
}
mux := http.NewServeMux()
@ -97,14 +113,18 @@ func main() {
// config holds runtime configuration sourced from environment variables,
// matching the Python service's env var names where they overlap.
type config struct {
Addr string // listen address, e.g. ":8770"
DatabaseURL string // dereth TimescaleDB DSN (read-only use)
Addr string // listen address, e.g. ":8770"
DatabaseURL string // dereth TimescaleDB DSN (read-only use)
InventoryURL string // inventory-service base URL
StaticDir string // directory for static assets / openissues.json
}
func loadConfig() config {
return config{
Addr: ":" + envOr("PORT", "8770"),
DatabaseURL: os.Getenv("DATABASE_URL"),
Addr: ":" + envOr("PORT", "8770"),
DatabaseURL: os.Getenv("DATABASE_URL"),
InventoryURL: envOr("INVENTORY_SERVICE_URL", "http://inventory-service:8000"),
StaticDir: envOr("STATIC_DIR", "static"),
}
}
@ -119,11 +139,39 @@ func (s *Server) registerRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /health", s.handleHealth)
// Mirrors Python's GET /api-version (hyphenated so nginx never strips it).
mux.HandleFunc("GET /api-version", s.handleVersion)
// Phase 1 read-side: the 5s caches.
mux.HandleFunc("GET /live", s.handleLive)
mux.HandleFunc("GET /live/", s.handleLive)
mux.HandleFunc("GET /trails", s.handleTrails)
mux.HandleFunc("GET /trails/", s.handleTrails)
// Totals (5-minute caches).
mux.HandleFunc("GET /total-rares", s.handleTotalRares)
mux.HandleFunc("GET /total-rares/", s.handleTotalRares)
mux.HandleFunc("GET /total-kills", s.handleTotalKills)
mux.HandleFunc("GET /total-kills/", s.handleTotalKills)
// Per-character & aggregate DB reads.
mux.HandleFunc("GET /stats/{character_name}", s.handleStats)
mux.HandleFunc("GET /portals", s.handlePortals)
mux.HandleFunc("GET /spawns/heatmap", s.handleSpawnHeatmap)
mux.HandleFunc("GET /server-health", s.handleServerHealth)
mux.HandleFunc("GET /character-stats/{name}", s.handleCharacterStats)
mux.HandleFunc("GET /combat-stats", s.handleCombatStatsAll)
mux.HandleFunc("GET /combat-stats/{character_name}", s.handleCombatStatsOne)
mux.HandleFunc("GET /inventories", s.handleInventories)
mux.HandleFunc("GET /inventory/{character_name}/search", s.handleInventorySearch)
// Ingest-only state (empty/default in Phase 1).
mux.HandleFunc("GET /quest-status", s.handleQuestStatus)
mux.HandleFunc("GET /vital-sharing/peers", s.handleVitalSharingPeers)
mux.HandleFunc("GET /equipment-cantrip-state/{name}", s.handleEquipmentCantrip)
mux.HandleFunc("GET /issues", s.handleIssues)
mux.HandleFunc("GET /me", s.handleMe)
// Inventory-service reverse proxies.
s.registerProxyRoutes(mux)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {

View file

@ -0,0 +1,70 @@
package main
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
)
// These endpoints are backed by ingest-only in-memory state in the Python
// service (populated from /ws/position events). Phase 1 has no ingest, so they
// return the same empty/default shapes the Python service emits when no data is
// present — preserving the API contract for the frontend.
// GET /quest-status (main.py:1940)
func (s *Server) handleQuestStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"quest_data": map[string]any{},
"tracked_quests": []string{
"Stipend Collection Timer",
"Blank Augmentation Gem Pickup Timer",
"Insatiable Eater Jaw",
},
"player_count": 0,
})
}
// GET /vital-sharing/peers (main.py:1800)
func (s *Server) handleVitalSharingPeers(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"peers": []any{},
"subscriber_count": 0,
})
}
// GET /equipment-cantrip-state/{name} (main.py:4167)
func (s *Server) handleEquipmentCantrip(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
writeJSON(w, http.StatusOK, map[string]any{
"type": "equipment_cantrip_state",
"character_name": name,
"items": []any{},
})
}
// GET /issues — flat-file issue board. (main.py:1709)
func (s *Server) handleIssues(w http.ResponseWriter, r *http.Request) {
issues := s.loadIssues()
writeJSON(w, http.StatusOK, map[string]any{"issues": issues})
}
func (s *Server) loadIssues() []any {
empty := []any{}
b, err := os.ReadFile(filepath.Join(s.staticDir, "openissues.json"))
if err != nil {
return empty
}
var v []any
if json.Unmarshal(b, &v) != nil {
return empty
}
return v
}
// GET /me — current user. Phase 1 has no session-cookie verification yet, so
// (like the Python service for an unauthenticated request) this is 401. The
// loopback internal-trust path carries no user identity. (main.py:1455)
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusUnauthorized, map[string]any{"detail": "Not authenticated"})
}

View file

@ -0,0 +1,74 @@
package main
import (
"net/http"
"net/http/httputil"
"net/url"
)
// initInvProxy builds a streaming reverse proxy to the inventory-service.
// FlushInterval=-1 flushes writes immediately so SSE endpoints (the suitbuilder
// search stream) work. Connection errors map to 503, mirroring the Python
// service's "Inventory service unavailable".
func (s *Server) initInvProxy(target string) error {
u, err := url.Parse(target)
if err != nil {
return err
}
rp := httputil.NewSingleHostReverseProxy(u)
rp.FlushInterval = -1
rp.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
s.log.Error("inventory proxy error", "err", err, "path", r.URL.Path)
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"detail": "Inventory service unavailable"})
}
s.invProxy = rp
return nil
}
// proxyInv returns a handler that rewrites the request path (via rewrite) and
// forwards it to the inventory-service, preserving method, query, headers, and
// body. The original /inv/* prefix etc. is mapped to the upstream path.
func (s *Server) proxyInv(rewrite func(r *http.Request) string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if s.invProxy == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"detail": "Inventory service unavailable"})
return
}
r.URL.Path = rewrite(r)
r.URL.RawPath = "" // force re-encode from the (decoded) Path
s.invProxy.ServeHTTP(w, r)
}
}
func (s *Server) registerProxyRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /inventory/{character_name}", s.proxyInv(func(r *http.Request) string {
return "/inventory/" + r.PathValue("character_name")
}))
mux.HandleFunc("GET /inventory-characters", s.proxyInv(func(r *http.Request) string {
return "/characters/list"
}))
mux.HandleFunc("GET /search/items", s.proxyInv(func(r *http.Request) string {
return "/search/items"
}))
mux.HandleFunc("GET /search/equipped/{character_name}", s.proxyInv(func(r *http.Request) string {
return "/search/equipped/" + r.PathValue("character_name")
}))
mux.HandleFunc("GET /search/upgrades/{character_name}/{slot}", s.proxyInv(func(r *http.Request) string {
return "/search/upgrades/" + r.PathValue("character_name") + "/" + r.PathValue("slot")
}))
mux.HandleFunc("GET /sets/list", s.proxyInv(func(r *http.Request) string {
return "/sets/list"
}))
// /inv/test is a static liveness probe in the Python service.
mux.HandleFunc("GET /inv/test", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"message": "Inventory proxy route is working"})
})
// Generic catch-all proxy: /inv/{path...} -> {SVC}/{path}. Covers GET and
// POST (incl. the SSE suitbuilder search). Registered for both methods.
invAll := s.proxyInv(func(r *http.Request) string {
return "/" + r.PathValue("path")
})
mux.HandleFunc("GET /inv/{path...}", invAll)
mux.HandleFunc("POST /inv/{path...}", invAll)
}

View file

@ -0,0 +1,367 @@
package main
import (
"context"
"fmt"
"net/http"
"strconv"
"time"
)
// reqCtx returns a child of the request context with a query timeout.
func reqCtx(r *http.Request) (context.Context, context.CancelFunc) {
return context.WithTimeout(r.Context(), 15*time.Second)
}
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"})
}
// GET /stats/{character_name} — latest telemetry snapshot + lifetime totals. (main.py:3927)
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
cn := r.PathValue("character_name")
ctx, cancel := reqCtx(r)
defer cancel()
const sql = `
WITH latest AS (
SELECT * FROM telemetry_events
WHERE character_name = $1
ORDER BY timestamp DESC LIMIT 1
)
SELECT l.*,
COALESCE(cs.total_kills, 0) AS total_kills,
COALESCE(rs.total_rares, 0) AS total_rares
FROM latest l
LEFT JOIN char_stats cs ON l.character_name = cs.character_name
LEFT JOIN rare_stats rs ON l.character_name = rs.character_name`
row, err := queryRowAsMap(ctx, s.pool, sql, cn)
if err != nil {
s.dbErr(w, "stats", err)
return
}
if row == nil {
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "Character not found"})
return
}
totalKills := row["total_kills"]
totalRares := row["total_rares"]
delete(row, "total_kills")
delete(row, "total_rares")
formatTimes([]map[string]any{row}, "timestamp", "received_at")
writeJSON(w, http.StatusOK, map[string]any{
"character_name": cn,
"latest_snapshot": row,
"total_kills": totalKills,
"total_rares": totalRares,
})
}
// GET /portals — all active portals (cleanup job handles 1h expiry). (main.py:1959)
func (s *Server) handlePortals(w http.ResponseWriter, r *http.Request) {
ctx, cancel := reqCtx(r)
defer cancel()
rows, err := queryRowsAsMaps(ctx, s.pool,
`SELECT portal_name, ns, ew, z, discovered_at, discovered_by FROM portals ORDER BY discovered_at DESC`)
if err != nil {
s.dbErr(w, "portals", err)
return
}
portals := make([]map[string]any, 0, len(rows))
for _, row := range rows {
da := ""
if t, ok := row["discovered_at"].(time.Time); ok {
da = pyISO(t)
}
portals = append(portals, map[string]any{
"portal_name": row["portal_name"],
"coordinates": map[string]any{"ns": row["ns"], "ew": row["ew"], "z": row["z"]},
"discovered_at": da,
"discovered_by": row["discovered_by"],
})
}
writeJSON(w, http.StatusOK, map[string]any{"portals": portals, "portal_count": len(portals)})
}
// GET /spawns/heatmap?hours=&limit= — aggregated spawn density. (main.py:2037)
func (s *Server) handleSpawnHeatmap(w http.ResponseWriter, r *http.Request) {
hours := clampInt(queryInt(r, "hours", 24), 1, 168)
limit := clampInt(queryInt(r, "limit", 10000), 100, 50000)
ctx, cancel := reqCtx(r)
defer cancel()
cutoff := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
rows, err := queryRowsAsMaps(ctx, s.pool,
`SELECT ew, ns, COUNT(*) AS spawn_count FROM spawn_events
WHERE timestamp >= $1 GROUP BY ew, ns ORDER BY spawn_count DESC LIMIT $2`,
cutoff, limit)
if err != nil {
s.dbErr(w, "spawns/heatmap", err)
return
}
points := make([]map[string]any, 0, len(rows))
for _, row := range rows {
points = append(points, map[string]any{
"ew": toFloat(row["ew"]),
"ns": toFloat(row["ns"]),
"intensity": toInt(row["spawn_count"]),
})
}
writeJSON(w, http.StatusOK, map[string]any{
"spawn_points": points,
"total_points": len(points),
"timestamp": pyISO(time.Now().UTC()),
"hours_window": hours,
})
}
// GET /server-health — current Coldeve status + computed uptime. (main.py:1881)
func (s *Server) handleServerHealth(w http.ResponseWriter, r *http.Request) {
ctx, cancel := reqCtx(r)
defer cancel()
row, err := queryRowAsMap(ctx, s.pool, `SELECT * FROM server_status WHERE server_name = $1`, "Coldeve")
if err != nil {
s.dbErr(w, "server-health", err)
return
}
status := "unknown"
var latency, playerCount, lastRestart, lastCheck any
var uptimeSeconds int64
if row != nil {
if v, ok := row["current_status"].(string); ok && v != "" {
status = v
}
latency = row["last_latency_ms"]
playerCount = row["last_player_count"]
uptimeSeconds = toInt64(row["total_uptime_seconds"])
if t, ok := row["last_restart"].(time.Time); ok {
lastRestart = pyISO(t)
}
if t, ok := row["last_check"].(time.Time); ok {
lastCheck = pyISO(t)
}
}
days := uptimeSeconds / 86400
hours := (uptimeSeconds % 86400) / 3600
minutes := (uptimeSeconds % 3600) / 60
uptime := fmt.Sprintf("%dh %dm", hours, minutes)
if days > 0 {
uptime = fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
}
writeJSON(w, http.StatusOK, map[string]any{
"server_name": "Coldeve",
"status": status,
"latency_ms": latency,
"player_count": playerCount,
"uptime": uptime,
"uptime_seconds": uptimeSeconds,
"last_restart": lastRestart,
"last_check": lastCheck,
})
}
// GET /inventories — characters with stored inventories. (main.py:2212)
func (s *Server) handleInventories(w http.ResponseWriter, r *http.Request) {
ctx, cancel := reqCtx(r)
defer cancel()
rows, err := queryRowsAsMaps(ctx, s.pool,
`SELECT character_name, COUNT(*) AS item_count, MAX(timestamp) AS last_updated
FROM character_inventories GROUP BY character_name ORDER BY last_updated DESC`)
if err != nil {
s.dbErr(w, "inventories", 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)})
}
// GET /inventory/{character_name}/search — filtered local inventory rows. (main.py:2135)
func (s *Server) handleInventorySearch(w http.ResponseWriter, r *http.Request) {
cn := r.PathValue("character_name")
name := optStr(r, "name")
objectClass := optInt(r, "object_class")
minValue := optInt(r, "min_value")
maxValue := optInt(r, "max_value")
minBurden := optInt(r, "min_burden")
maxBurden := optInt(r, "max_burden")
conds := []string{"character_name = $1"}
args := []any{cn}
add := func(tmpl string, val any) {
args = append(args, val)
conds = append(conds, fmt.Sprintf(tmpl, len(args)))
}
if name != nil && *name != "" {
add("name ILIKE $%d", "%"+*name+"%")
}
if objectClass != nil {
add("object_class = $%d", *objectClass)
}
if minValue != nil {
add("value >= $%d", *minValue)
}
if maxValue != nil {
add("value <= $%d", *maxValue)
}
if minBurden != nil {
add("burden >= $%d", *minBurden)
}
if maxBurden != nil {
add("burden <= $%d", *maxBurden)
}
sql := `SELECT name, icon, object_class, value, burden, has_id_data, item_data, timestamp
FROM character_inventories WHERE ` + join(conds, " AND ") + ` ORDER BY value DESC, name`
ctx, cancel := reqCtx(r)
defer cancel()
rows, err := queryRowsAsMaps(ctx, s.pool, sql, args...)
if err != nil {
s.dbErr(w, "inventory-search", err)
return
}
formatTimes(rows, "timestamp")
for _, row := range rows {
if v, ok := row["item_data"]; ok {
row["item_data"] = decodeJSONValue(v)
}
}
writeJSON(w, http.StatusOK, map[string]any{
"character_name": cn,
"item_count": len(rows),
"search_criteria": map[string]any{
"name": derefStr(name),
"object_class": derefInt(objectClass),
"min_value": derefInt(minValue),
"max_value": derefInt(maxValue),
"min_burden": derefInt(minBurden),
"max_burden": derefInt(maxBurden),
},
"items": rows,
})
}
// ---- small param/number helpers ----
func queryInt(r *http.Request, key string, def int) int {
if v := r.URL.Query().Get(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
func optInt(r *http.Request, key string) *int {
v := r.URL.Query().Get(key)
if v == "" {
return nil
}
n, err := strconv.Atoi(v)
if err != nil {
return nil
}
return &n
}
func optStr(r *http.Request, key string) *string {
vs := r.URL.Query()
if !vs.Has(key) {
return nil
}
v := vs.Get(key)
return &v
}
func derefStr(p *string) any {
if p == nil {
return nil
}
return *p
}
func derefInt(p *int) any {
if p == nil {
return nil
}
return *p
}
func clampInt(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
func join(parts []string, sep string) string {
out := ""
for i, p := range parts {
if i > 0 {
out += sep
}
out += p
}
return out
}
func toFloat(v any) float64 {
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int64:
return float64(x)
case int32:
return float64(x)
case int:
return float64(x)
}
return 0
}
func toInt(v any) int {
switch x := v.(type) {
case int64:
return int(x)
case int32:
return int(x)
case int:
return x
case float64:
return int(x)
}
return 0
}
func toInt64(v any) int64 {
switch x := v.(type) {
case int64:
return x
case int32:
return int64(x)
case int:
return int64(x)
case float64:
return int64(x)
}
return 0
}

View file

@ -2,6 +2,8 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
@ -52,6 +54,65 @@ func queryRowsAsMaps(ctx context.Context, pool *pgxpool.Pool, sql string, args .
return out, nil
}
// queryRowAsMap runs a query expected to return at most one row. It returns
// (nil, nil) when there are no rows, so callers can map that to a 404.
func queryRowAsMap(ctx context.Context, pool *pgxpool.Pool, sql string, args ...any) (map[string]any, error) {
rows, err := pool.Query(ctx, sql, args...)
if err != nil {
return nil, err
}
m, err := pgx.CollectExactlyOneRow(rows, pgx.RowToMap)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return m, nil
}
// asJSONMap coerces a value that may be JSON bytes, a JSON string, or an
// already-decoded map into a map[string]any. Used for JSONB columns where pgx's
// decoding can vary. Returns nil if the value can't be interpreted as an object.
func asJSONMap(v any) map[string]any {
switch x := v.(type) {
case nil:
return nil
case map[string]any:
return x
case []byte:
var m map[string]any
if json.Unmarshal(x, &m) == nil {
return m
}
case string:
var m map[string]any
if json.Unmarshal([]byte(x), &m) == nil {
return m
}
}
return nil
}
// decodeJSONValue coerces a JSON/JSONB column value into its natural Go value
// (map, slice, scalar). Bytes/strings are unmarshaled; anything else is
// returned unchanged.
func decodeJSONValue(v any) any {
switch x := v.(type) {
case []byte:
var out any
if json.Unmarshal(x, &out) == nil {
return out
}
case string:
var out any
if json.Unmarshal([]byte(x), &out) == nil {
return out
}
}
return v
}
// pyISO formats a timestamp the way Python's datetime.isoformat() does for a
// UTC tz-aware value, so output matches FastAPI's jsonable_encoder:
// - no fractional part when microseconds are zero

View file

@ -0,0 +1,80 @@
package main
import (
"context"
"encoding/json"
"net/http"
"sync"
"time"
)
const totalsInterval = 300 * time.Second // _refresh_total_rares_cache cadence
// totalsCache holds the pre-marshaled bodies for /total-rares and /total-kills,
// refreshed every totalsInterval, mirroring main.py:924.
type totalsCache struct {
mu sync.RWMutex
raresJSON []byte
killsJSON []byte
}
func newTotalsCache() *totalsCache {
return &totalsCache{
raresJSON: []byte(`{"all_time":0,"today":0,"last_updated":null}`),
killsJSON: []byte(`{"total":0,"last_updated":null}`),
}
}
func (c *totalsCache) getRares() []byte { c.mu.RLock(); defer c.mu.RUnlock(); return c.raresJSON }
func (c *totalsCache) getKills() []byte { c.mu.RLock(); defer c.mu.RUnlock(); return c.killsJSON }
func (c *totalsCache) set(rares, kills []byte) {
c.mu.Lock()
defer c.mu.Unlock()
c.raresJSON = rares
c.killsJSON = kills
}
func (s *Server) refreshTotals(ctx context.Context) error {
qctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
var allTime, today, totalKills int64
// Each query degrades to 0 on error, mirroring the Python try/except blocks.
_ = s.pool.QueryRow(qctx, "SELECT COALESCE(SUM(total_rares), 0) FROM rare_stats").Scan(&allTime)
_ = s.pool.QueryRow(qctx, "SELECT COUNT(*) FROM rare_events WHERE timestamp >= CURRENT_DATE").Scan(&today)
_ = s.pool.QueryRow(qctx, "SELECT COALESCE(SUM(total_kills), 0) FROM char_stats").Scan(&totalKills)
lastUpdated := pyISO(time.Now().UTC())
raresJSON, err := json.Marshal(map[string]any{"all_time": allTime, "today": today, "last_updated": lastUpdated})
if err != nil {
return err
}
killsJSON, err := json.Marshal(map[string]any{"total": totalKills, "last_updated": lastUpdated})
if err != nil {
return err
}
s.totals.set(raresJSON, killsJSON)
return nil
}
func (s *Server) runTotalsLoop(ctx context.Context) {
for {
if err := s.refreshTotals(ctx); err != nil {
s.log.Error("totals cache refresh failed", "err", err)
}
select {
case <-ctx.Done():
return
case <-time.After(totalsInterval):
}
}
}
func (s *Server) handleTotalRares(w http.ResponseWriter, r *http.Request) {
writeRawJSON(w, s.totals.getRares())
}
func (s *Server) handleTotalKills(w http.ResponseWriter, r *http.Request) {
writeRawJSON(w, s.totals.getKills())
}