package main import ( "context" "fmt" "net/http" "strconv" "strings" "time" ) // coerceNum converts a JSON value to a float64, parsing string-encoded numbers. // The plugin sends several telemetry fields as strings (kills_per_hour, deaths, // total_deaths, prismatic_taper_count via .ToString()); Python's pydantic // coerced them, so Go must too or it writes null/0 (causing the live counters // to flap 0<->value between the WS broadcast and the DB-derived /live poll). func coerceNum(v any) (float64, bool) { switch x := v.(type) { case float64: return x, true case float32: return float64(x), true case int: return float64(x), true case int32: return float64(x), true case int64: return float64(x), true case string: s := strings.TrimSpace(x) if s == "" { return 0, false } f, err := strconv.ParseFloat(s, 64) return f, err == nil } return 0, false } // 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 { f, _ := coerceNum(v) return f } func toInt(v any) int { f, _ := coerceNum(v) return int(f) } func toInt64(v any) int64 { f, _ := coerceNum(v) return int64(f) }