MosswartOverlord/go-services/tracker-go/reads.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

367 lines
9.2 KiB
Go

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
}