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>
367 lines
9.2 KiB
Go
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
|
|
}
|