// Package api wires the games-service HTTP endpoints (coverage game).
package api
import (
"crypto/sha256"
"crypto/subtle"
"encoding/json"
"log"
"math"
"math/rand"
"net/http"
"net/url"
"time"
"github.com/ontrack-by/games-service/internal/catalog"
"github.com/ontrack-by/games-service/internal/challenge"
"github.com/ontrack-by/games-service/internal/config"
"github.com/ontrack-by/games-service/internal/geo"
"github.com/ontrack-by/games-service/internal/store"
)
const silhouetteW, silhouetteH, silhouettePad = 320.0, 200.0, 18.0
type Server struct {
cfg config.Config
store *store.Store
catalog *catalog.Client
signer challenge.Signer
}
func New(cfg config.Config, st *store.Store, cat *catalog.Client) *Server {
return &Server{cfg: cfg, store: st, catalog: cat, signer: challenge.New(cfg.ChallengeSecret)}
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", s.handleHealth)
mux.Handle("GET /v1/games/coverage/challenge", s.guard(s.handleChallenge))
mux.Handle("POST /v1/games/coverage/guess", s.guard(s.handleGuess))
mux.Handle("GET /v1/games/coverage/me", s.guard(s.handleMe))
mux.Handle("GET /v1/games/coverage/leaderboard", s.guard(s.handleLeaderboard))
mux.Handle("GET /v1/games/reaction/round", s.guard(s.handleReactionRound))
mux.Handle("POST /v1/games/reaction/submit", s.guard(s.handleReactionSubmit))
mux.Handle("GET /v1/games/reaction/me", s.guard(s.handleReactionMe))
mux.Handle("GET /v1/games/reaction/leaderboard", s.guard(s.handleReactionLeaderboard))
mux.Handle("GET /v1/games/arcade/{game}/progress", s.guard(s.handleArcadeProgress))
mux.Handle("POST /v1/games/arcade/{game}/complete", s.guard(s.handleArcadeComplete))
mux.Handle("GET /v1/games/arcade/{game}/leaderboard", s.guard(s.handleArcadeLeaderboard))
return mux
}
// guard enforces the shared X-Service-Token (constant-time) on internal routes.
func (s *Server) guard(h http.HandlerFunc) http.Handler {
want := sha256.Sum256([]byte(s.cfg.ServiceToken))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got := sha256.Sum256([]byte(r.Header.Get("X-Service-Token")))
if subtle.ConstantTimeCompare(got[:], want[:]) != 1 {
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "missing or invalid service token")
return
}
h(w, r)
})
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "service": "games-service"})
}
type challengeResponse struct {
ChallengeID string `json:"challengeId"`
Silhouette [][2]float64 `json:"silhouette"`
DistanceKm float64 `json:"distanceKm"`
TotalTracks int `json:"totalTracks"`
Discovered int `json:"discovered"`
}
func (s *Server) handleChallenge(w http.ResponseWriter, r *http.Request) {
uid := r.Header.Get("X-User-Uid")
if uid == "" {
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "missing user")
return
}
tracks, err := s.catalog.Tracks(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, "CATALOG_UNAVAILABLE", "catalog unavailable")
return
}
if len(tracks) == 0 {
writeError(w, http.StatusServiceUnavailable, "NO_TRACKS", "no tracks to play yet")
return
}
discovered, err := s.store.DiscoveredTrackIDs(r.Context(), uid)
if err != nil {
writeError(w, http.StatusInternalServerError, "DB_ERROR", "could not load progress")
return
}
pick := pickTrack(tracks, discovered)
writeJSON(w, http.StatusOK, challengeResponse{
ChallengeID: s.signer.Sign(pick.ID),
Silhouette: geo.NormalizeSilhouette(pick.Simplified, silhouetteW, silhouetteH, silhouettePad),
DistanceKm: pick.DistanceKm,
TotalTracks: len(tracks),
Discovered: len(discovered),
})
}
type guessRequest struct {
ChallengeID string `json:"challengeId"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
type guessResponse struct {
Correct bool `json:"correct"`
DistanceKm float64 `json:"distanceKm"`
ActualLat float64 `json:"actualLat"`
ActualLon float64 `json:"actualLon"`
TrackName string `json:"trackName"`
Locality string `json:"locality"`
NewlyDiscovered bool `json:"newlyDiscovered"`
DiscoveredCount int `json:"discoveredCount"`
TotalTracks int `json:"totalTracks"`
}
func (s *Server) handleGuess(w http.ResponseWriter, r *http.Request) {
uid := r.Header.Get("X-User-Uid")
if uid == "" {
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "missing user")
return
}
var req guessRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "invalid body")
return
}
trackID, err := s.signer.Verify(req.ChallengeID)
if err != nil {
writeError(w, http.StatusBadRequest, "BAD_CHALLENGE", "invalid challenge")
return
}
if req.Lat < -90 || req.Lat > 90 || req.Lon < -180 || req.Lon > 180 {
writeError(w, http.StatusBadRequest, "BAD_COORDS", "coordinates out of range")
return
}
tracks, err := s.catalog.Tracks(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, "CATALOG_UNAVAILABLE", "catalog unavailable")
return
}
track, ok := findTrack(tracks, trackID)
if !ok {
writeError(w, http.StatusNotFound, "TRACK_NOT_FOUND", "track no longer available")
return
}
start := track.Simplified[0] // [lon, lat]
dist := geo.HaversineKm(req.Lat, req.Lon, start[1], start[0])
correct := dist <= s.cfg.GuessRadiusKm
newly := false
if correct {
newly, err = s.store.AddDiscovery(r.Context(), uid, trackID, dist, decodeUserName(r.Header.Get("X-User-Name")))
if err != nil {
writeError(w, http.StatusInternalServerError, "DB_ERROR", "could not save progress")
return
}
}
discovered, err := s.store.DiscoveredTrackIDs(r.Context(), uid)
if err != nil {
writeError(w, http.StatusInternalServerError, "DB_ERROR", "could not load progress")
return
}
writeJSON(w, http.StatusOK, guessResponse{
Correct: correct,
DistanceKm: round1(dist),
ActualLat: start[1],
ActualLon: start[0],
TrackName: track.Name,
Locality: track.Locality,
NewlyDiscovered: newly,
DiscoveredCount: len(discovered),
TotalTracks: len(tracks),
})
}
type discoveredTrack struct {
ID int `json:"id"`
Name string `json:"name"`
Locality string `json:"locality"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
type meResponse struct {
Discovered []discoveredTrack `json:"discovered"`
DiscoveredCount int `json:"discoveredCount"`
TotalTracks int `json:"totalTracks"`
CoveragePct int `json:"coveragePct"`
}
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
uid := r.Header.Get("X-User-Uid")
if uid == "" {
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "missing user")
return
}
tracks, err := s.catalog.Tracks(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, "CATALOG_UNAVAILABLE", "catalog unavailable")
return
}
discovered, err := s.store.DiscoveredTrackIDs(r.Context(), uid)
if err != nil {
writeError(w, http.StatusInternalServerError, "DB_ERROR", "could not load progress")
return
}
found := make([]discoveredTrack, 0, len(discovered))
for _, t := range tracks {
if _, ok := discovered[t.ID]; ok {
found = append(found, discoveredTrack{
ID: t.ID, Name: t.Name, Locality: t.Locality,
Lat: t.Simplified[0][1], Lon: t.Simplified[0][0],
})
}
}
pct := 0
if len(tracks) > 0 {
pct = int(math.Round(100 * float64(len(found)) / float64(len(tracks))))
}
writeJSON(w, http.StatusOK, meResponse{
Discovered: found,
DiscoveredCount: len(found),
TotalTracks: len(tracks),
CoveragePct: pct,
})
}
type leaderEntry struct {
Name string `json:"name"`
Discovered int `json:"discovered"`
IsMe bool `json:"isMe"`
}
func (s *Server) handleLeaderboard(w http.ResponseWriter, r *http.Request) {
entries, err := s.store.Leaderboard(r.Context(), 20)
if err != nil {
writeError(w, http.StatusInternalServerError, "DB_ERROR", "could not load leaderboard")
return
}
me := r.Header.Get("X-User-Uid")
uids := make([]string, len(entries))
for i, e := range entries {
uids[i] = e.UID
}
nicks, _ := s.store.Nicknames(r.Context(), uids)
out := make([]leaderEntry, 0, len(entries))
for _, e := range entries {
out = append(out, leaderEntry{
Name: pickName(nicks[e.UID], e.Name),
Discovered: e.Discovered,
IsMe: e.UID != "" && e.UID == me,
})
}
writeJSON(w, http.StatusOK, map[string]any{"entries": out})
}
// ── reaction game ("Идеальный старт") ──────────────────────────────────────
const (
minReactionMs = 80
maxReactionMs = 60000
maxRoundAgeMs = 60000
)
// Arcade games (levels + stars). Server-side allowlist of valid game/variant/level.
type arcadeConfig struct {
variants map[string]bool
levels int
}
var arcadeGames = map[string]arcadeConfig{
"slalom": {
variants: map[string]bool{"city": true, "forest": true, "mountains": true},
levels: 8,
},
}
type arcadeProgressItem struct {
Variant string `json:"variant"`
Level int `json:"level"`
Stars int `json:"stars"`
}
type arcadeCompleteRequest struct {
Variant string `json:"variant"`
Level int `json:"level"`
Stars int `json:"stars"`
}
type arcadeLeaderEntry struct {
Name string `json:"name"`
Stars int `json:"stars"`
IsMe bool `json:"isMe"`
}
func (s *Server) handleArcadeProgress(w http.ResponseWriter, r *http.Request) {
uid := r.Header.Get("X-User-Uid")
if uid == "" {
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "missing user")
return
}
game := r.PathValue("game")
if _, ok := arcadeGames[game]; !ok {
writeError(w, http.StatusNotFound, "UNKNOWN_GAME", "unknown game")
return
}
entries, err := s.store.ArcadeProgress(r.Context(), game, uid)
if err != nil {
writeError(w, http.StatusInternalServerError, "DB_ERROR", "could not load progress")
return
}
items := make([]arcadeProgressItem, 0, len(entries))
for _, e := range entries {
items = append(items, arcadeProgressItem{Variant: e.Variant, Level: e.Level, Stars: e.Stars})
}
writeJSON(w, http.StatusOK, map[string]any{"progress": items})
}
func (s *Server) handleArcadeComplete(w http.ResponseWriter, r *http.Request) {
uid := r.Header.Get("X-User-Uid")
if uid == "" {
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "missing user")
return
}
game := r.PathValue("game")
cfg, ok := arcadeGames[game]
if !ok {
writeError(w, http.StatusNotFound, "UNKNOWN_GAME", "unknown game")
return
}
var req arcadeCompleteRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "invalid body")
return
}
if !cfg.variants[req.Variant] {
writeError(w, http.StatusBadRequest, "BAD_VARIANT", "unknown variant")
return
}
if req.Level < 1 || req.Level > cfg.levels {
writeError(w, http.StatusBadRequest, "BAD_LEVEL", "level out of range")
return
}
if req.Stars < 1 || req.Stars > 3 {
writeError(w, http.StatusBadRequest, "BAD_STARS", "stars out of range")
return
}
if err := s.store.ArcadeComplete(r.Context(), game, uid, decodeUserName(r.Header.Get("X-User-Name")), req.Variant, req.Level, req.Stars); err != nil {
writeError(w, http.StatusInternalServerError, "DB_ERROR", "could not save progress")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleArcadeLeaderboard(w http.ResponseWriter, r *http.Request) {
game := r.PathValue("game")
if _, ok := arcadeGames[game]; !ok {
writeError(w, http.StatusNotFound, "UNKNOWN_GAME", "unknown game")
return
}
entries, err := s.store.ArcadeLeaderboard(r.Context(), game, 20)
if err != nil {
writeError(w, http.StatusInternalServerError, "DB_ERROR", "could not load leaderboard")
return
}
me := r.Header.Get("X-User-Uid")
uids := make([]string, len(entries))
for i, e := range entries {
uids[i] = e.UID
}
nicks, _ := s.store.Nicknames(r.Context(), uids)
out := make([]arcadeLeaderEntry, 0, len(entries))
for _, e := range entries {
out = append(out, arcadeLeaderEntry{
Name: pickName(nicks[e.UID], e.Name),
Stars: e.Stars,
IsMe: e.UID != "" && e.UID == me,
})
}
writeJSON(w, http.StatusOK, map[string]any{"entries": out})
}
func (s *Server) handleReactionRound(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-User-Uid") == "" {
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "missing user")
return
}
// Signed server timestamp; submit must reference a recent round.
roundID := s.signer.Sign(int(time.Now().UnixMilli()))
writeJSON(w, http.StatusOK, map[string]string{"roundId": roundID})
}
type reactionSubmitRequest struct {
RoundID string `json:"roundId"`
Ms int `json:"ms"`
}
type reactionSubmitResponse struct {
Ms int `json:"ms"`
BestMs int `json:"bestMs"`
Attempts int `json:"attempts"`
IsRecord bool `json:"isRecord"`
}
func (s *Server) handleReactionSubmit(w http.ResponseWriter, r *http.Request) {
uid := r.Header.Get("X-User-Uid")
if uid == "" {
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "missing user")
return
}
var req reactionSubmitRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "invalid body")
return
}
if req.Ms < minReactionMs || req.Ms > maxReactionMs {
writeError(w, http.StatusBadRequest, "BAD_TIME", "implausible reaction time")
return
}
issueMs, err := s.signer.Verify(req.RoundID)
if err != nil {
writeError(w, http.StatusBadRequest, "BAD_ROUND", "invalid round")
return
}
// Basic integrity: the round must predate the reaction and not be stale.
elapsed := int(time.Now().UnixMilli() - int64(issueMs))
if elapsed < req.Ms || elapsed > maxRoundAgeMs {
writeError(w, http.StatusBadRequest, "BAD_ROUND", "stale or inconsistent round")
return
}
res, err := s.store.SubmitReaction(r.Context(), uid, decodeUserName(r.Header.Get("X-User-Name")), req.Ms)
if err != nil {
writeError(w, http.StatusInternalServerError, "DB_ERROR", "could not save score")
return
}
writeJSON(w, http.StatusOK, reactionSubmitResponse{
Ms: req.Ms, BestMs: res.BestMs, Attempts: res.Attempts, IsRecord: res.IsRecord,
})
}
type reactionMeResponse struct {
Played bool `json:"played"`
BestMs int `json:"bestMs"`
Attempts int `json:"attempts"`
Rank int `json:"rank"`
}
func (s *Server) handleReactionMe(w http.ResponseWriter, r *http.Request) {
uid := r.Header.Get("X-User-Uid")
if uid == "" {
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "missing user")
return
}
best, attempts, rank, found, err := s.store.ReactionMe(r.Context(), uid)
if err != nil {
writeError(w, http.StatusInternalServerError, "DB_ERROR", "could not load progress")
return
}
writeJSON(w, http.StatusOK, reactionMeResponse{Played: found, BestMs: best, Attempts: attempts, Rank: rank})
}
type reactionLeaderEntry struct {
Name string `json:"name"`
BestMs int `json:"bestMs"`
Attempts int `json:"attempts"`
IsMe bool `json:"isMe"`
}
func (s *Server) handleReactionLeaderboard(w http.ResponseWriter, r *http.Request) {
entries, err := s.store.ReactionLeaderboard(r.Context(), 20)
if err != nil {
writeError(w, http.StatusInternalServerError, "DB_ERROR", "could not load leaderboard")
return
}
me := r.Header.Get("X-User-Uid")
uids := make([]string, len(entries))
for i, e := range entries {
uids[i] = e.UID
}
nicks, _ := s.store.Nicknames(r.Context(), uids)
out := make([]reactionLeaderEntry, 0, len(entries))
for _, e := range entries {
out = append(out, reactionLeaderEntry{
Name: pickName(nicks[e.UID], e.Name),
BestMs: e.BestMs,
Attempts: e.Attempts,
IsMe: e.UID != "" && e.UID == me,
})
}
writeJSON(w, http.StatusOK, map[string]any{"entries": out})
}
// ── helpers ────────────────────────────────────────────────────────────────
func pickTrack(tracks []catalog.Track, discovered map[int]struct{}) catalog.Track {
undiscovered := make([]catalog.Track, 0, len(tracks))
for _, t := range tracks {
if _, ok := discovered[t.ID]; !ok {
undiscovered = append(undiscovered, t)
}
}
pool := undiscovered
if len(pool) == 0 {
pool = tracks
}
return pool[rand.Intn(len(pool))]
}
func findTrack(tracks []catalog.Track, id int) (catalog.Track, bool) {
for _, t := range tracks {
if t.ID == id {
return t, true
}
}
return catalog.Track{}, false
}
func round1(v float64) float64 { return math.Round(v*10) / 10 }
// pickName prefers the profile nickname, falls back to the token display name,
// then to a generic label.
func pickName(nickname, fallback string) string {
if nickname != "" {
return nickname
}
if fallback != "" {
return fallback
}
return "Игрок"
}
// decodeUserName undoes the URL-encoding the gateway applies to non-ASCII names.
func decodeUserName(raw string) string {
if decoded, err := url.QueryUnescape(raw); err == nil {
return decoded
}
return raw
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("write json: %v", err)
}
}
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, map[string]any{"error": map[string]string{"code": code, "message": message}})
}
// Package catalog is a small cached client for the internal gpx-catalog-service.
package catalog
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
)
// Track is the subset of a catalog track games-service needs. Simplified is the
// [lon, lat] polyline; Simplified[0] is the start used as the "answer" location.
type Track struct {
ID int `json:"id"`
Name string `json:"name"`
Locality string `json:"locality"`
DistanceKm float64 `json:"distanceKm"`
Simplified [][2]float64 `json:"simplifiedGeojson"`
}
type Client struct {
baseURL string
token string
http *http.Client
ttl time.Duration
mu sync.Mutex
cache []Track
fetchedAt time.Time
}
func New(baseURL, token string) *Client {
return &Client{
baseURL: baseURL,
token: token,
http: &http.Client{Timeout: 5 * time.Second},
ttl: 2 * time.Minute,
}
}
// Tracks returns catalog tracks that have a usable silhouette, cached for ttl.
func (c *Client) Tracks(ctx context.Context) ([]Track, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.cache != nil && time.Since(c.fetchedAt) < c.ttl {
return c.cache, nil
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/tracks", nil)
if err != nil {
return nil, err
}
req.Header.Set("x-service-token", c.token)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("catalog /tracks: status %d", resp.StatusCode)
}
var body struct {
Tracks []Track `json:"tracks"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return nil, err
}
playable := make([]Track, 0, len(body.Tracks))
for _, t := range body.Tracks {
if len(t.Simplified) >= 2 {
playable = append(playable, t)
}
}
c.cache = playable
c.fetchedAt = time.Now()
return playable, nil
}
// Package challenge issues and verifies tamper-proof challenge tokens so the
// server stays authoritative: the client gets an opaque token, not the answer.
package challenge
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"strconv"
"strings"
)
type Signer struct {
secret []byte
}
func New(secret string) Signer {
return Signer{secret: []byte(secret)}
}
// Sign returns "<base64(trackID)>.<hmac>" — the trackID is signed so the client
// cannot forge or swap it. (The catalog is public, so this guards integrity, not
// secrecy of the answer.)
func (s Signer) Sign(trackID int) string {
payload := strconv.Itoa(trackID)
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + s.mac(payload)
}
func (s Signer) Verify(token string) (int, error) {
parts := strings.SplitN(token, ".", 2)
if len(parts) != 2 {
return 0, errors.New("malformed challenge")
}
raw, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return 0, errors.New("malformed challenge")
}
payload := string(raw)
if !hmac.Equal([]byte(s.mac(payload)), []byte(parts[1])) {
return 0, errors.New("invalid challenge signature")
}
return strconv.Atoi(payload)
}
func (s Signer) mac(payload string) string {
m := hmac.New(sha256.New, s.secret)
m.Write([]byte(payload))
return hex.EncodeToString(m.Sum(nil))
}
// Package config loads games-service settings from the environment.
package config
import (
"fmt"
"os"
"strconv"
"strings"
)
type Config struct {
Port string
ServiceToken string // shared gateway↔games token; also used to call catalog
DatabaseURL string
CatalogURL string
ChallengeSecret string // HMAC key for challenge tokens (defaults to ServiceToken)
GuessRadiusKm float64 // a guess within this distance "discovers" the track
}
func Load() (Config, error) {
c := Config{
Port: getenv("PORT", "3004"),
ServiceToken: os.Getenv("SERVICE_TOKEN"),
DatabaseURL: os.Getenv("DATABASE_URL"),
CatalogURL: strings.TrimRight(getenv("CATALOG_SERVICE_URL", "http://localhost:3001"), "/"),
ChallengeSecret: os.Getenv("CHALLENGE_SECRET"),
GuessRadiusKm: 30,
}
if c.ServiceToken == "" {
return Config{}, fmt.Errorf("SERVICE_TOKEN is required")
}
if c.DatabaseURL == "" {
return Config{}, fmt.Errorf("DATABASE_URL is required")
}
if c.ChallengeSecret == "" {
c.ChallengeSecret = c.ServiceToken
}
if v := os.Getenv("GUESS_RADIUS_KM"); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 {
c.GuessRadiusKm = f
}
}
return c, nil
}
func getenv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// Package geo holds the small geometry helpers games-service needs:
// great-circle distance and silhouette normalisation.
package geo
import "math"
const earthRadiusKm = 6371.0
func rad(deg float64) float64 { return deg * math.Pi / 180 }
// HaversineKm returns the great-circle distance between two lat/lon points in km.
func HaversineKm(lat1, lon1, lat2, lon2 float64) float64 {
dLat := rad(lat2 - lat1)
dLon := rad(lon2 - lon1)
h := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(rad(lat1))*math.Cos(rad(lat2))*math.Sin(dLon/2)*math.Sin(dLon/2)
return 2 * earthRadiusKm * math.Asin(math.Sqrt(h))
}
// NormalizeSilhouette projects a [lon, lat] polyline into [x, y] coordinates that
// fit a w×h box (aspect preserved, Y flipped so north is up, centered). The geo
// location is intentionally stripped — the client only gets the shape, never the
// real coordinates of the track being guessed.
func NormalizeSilhouette(lonLat [][2]float64, w, h, pad float64) [][2]float64 {
if len(lonLat) == 0 {
return nil
}
minLon, maxLon := math.Inf(1), math.Inf(-1)
minLat, maxLat := math.Inf(1), math.Inf(-1)
for _, p := range lonLat {
minLon, maxLon = math.Min(minLon, p[0]), math.Max(maxLon, p[0])
minLat, maxLat = math.Min(minLat, p[1]), math.Max(maxLat, p[1])
}
midLat := (minLat + maxLat) / 2
lonScale := math.Cos(rad(midLat))
if lonScale == 0 {
lonScale = 1
}
spanX := math.Max((maxLon-minLon)*lonScale, 1e-9)
spanY := math.Max(maxLat-minLat, 1e-9)
innerW := math.Max(w-pad*2, 1)
innerH := math.Max(h-pad*2, 1)
scale := math.Min(innerW/spanX, innerH/spanY)
offX := pad + (innerW-spanX*scale)/2
offY := pad + (innerH-spanY*scale)/2
out := make([][2]float64, len(lonLat))
for i, p := range lonLat {
out[i] = [2]float64{
round1(offX + (p[0]-minLon)*lonScale*scale),
round1(offY + (maxLat-p[1])*scale),
}
}
return out
}
func round1(v float64) float64 { return math.Round(v*10) / 10 }
// Package store is the Postgres persistence for games-service (schema "games").
package store
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Store struct {
pool *pgxpool.Pool
}
type LeaderEntry struct {
UID string
Name string
Discovered int
}
// Open connects a pool and ensures the schema exists (idempotent DDL).
func Open(ctx context.Context, dbURL string) (*Store, error) {
pool, err := pgxpool.New(ctx, dbURL)
if err != nil {
return nil, err
}
s := &Store{pool: pool}
if err := s.ensureSchema(ctx); err != nil {
pool.Close()
return nil, err
}
return s, nil
}
func (s *Store) Close() { s.pool.Close() }
func (s *Store) ensureSchema(ctx context.Context) error {
stmts := []string{
`CREATE SCHEMA IF NOT EXISTS games`,
`CREATE TABLE IF NOT EXISTS games.discoveries (
id BIGSERIAL PRIMARY KEY,
uid TEXT NOT NULL,
track_id INTEGER NOT NULL,
distance_km DOUBLE PRECISION NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (uid, track_id)
)`,
`CREATE INDEX IF NOT EXISTS discoveries_uid_idx ON games.discoveries (uid)`,
`CREATE TABLE IF NOT EXISTS games.players (
uid TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
discovered INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
`CREATE TABLE IF NOT EXISTS games.reaction_players (
uid TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
best_ms INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
`CREATE TABLE IF NOT EXISTS games.arcade_progress (
game TEXT NOT NULL,
uid TEXT NOT NULL,
variant TEXT NOT NULL,
level INTEGER NOT NULL,
stars INTEGER NOT NULL,
name TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (game, uid, variant, level)
)`,
`CREATE INDEX IF NOT EXISTS arcade_progress_game_uid_idx ON games.arcade_progress (game, uid)`,
}
for _, q := range stmts {
if _, err := s.pool.Exec(ctx, q); err != nil {
return fmt.Errorf("ensure schema: %w", err)
}
}
return nil
}
// DiscoveredTrackIDs returns the set of track ids the user has discovered.
func (s *Store) DiscoveredTrackIDs(ctx context.Context, uid string) (map[int]struct{}, error) {
rows, err := s.pool.Query(ctx, `SELECT track_id FROM games.discoveries WHERE uid = $1`, uid)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[int]struct{})
for rows.Next() {
var id int
if err := rows.Scan(&id); err != nil {
return nil, err
}
out[id] = struct{}{}
}
return out, rows.Err()
}
// AddDiscovery records a discovery (idempotent per uid+track) and refreshes the
// player's cached name + count. Returns true if this was a new discovery.
func (s *Store) AddDiscovery(ctx context.Context, uid string, trackID int, distanceKm float64, name string) (bool, error) {
tag, err := s.pool.Exec(ctx,
`INSERT INTO games.discoveries (uid, track_id, distance_km)
VALUES ($1, $2, $3)
ON CONFLICT (uid, track_id) DO NOTHING`,
uid, trackID, distanceKm)
if err != nil {
return false, err
}
added := tag.RowsAffected() > 0
_, err = s.pool.Exec(ctx,
`INSERT INTO games.players (uid, name, discovered, updated_at)
VALUES ($1, $2, (SELECT count(*) FROM games.discoveries WHERE uid = $1), now())
ON CONFLICT (uid) DO UPDATE SET
name = CASE WHEN EXCLUDED.name <> '' THEN EXCLUDED.name ELSE games.players.name END,
discovered = EXCLUDED.discovered,
updated_at = now()`,
uid, name)
if err != nil {
return added, err
}
return added, nil
}
func (s *Store) Leaderboard(ctx context.Context, limit int) ([]LeaderEntry, error) {
rows, err := s.pool.Query(ctx,
`SELECT uid, name, discovered FROM games.players
ORDER BY discovered DESC, updated_at ASC LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []LeaderEntry
for rows.Next() {
var e LeaderEntry
if err := rows.Scan(&e.UID, &e.Name, &e.Discovered); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// Nicknames resolves uid → profile nickname (catalog's public.user_profiles,
// same database). Only uids with a non-empty nickname are included. Used so all
// leaderboards show the player's chosen nickname, always current.
func (s *Store) Nicknames(ctx context.Context, uids []string) (map[string]string, error) {
out := make(map[string]string, len(uids))
if len(uids) == 0 {
return out, nil
}
rows, err := s.pool.Query(ctx,
`SELECT firebase_uid, nickname FROM public.user_profiles
WHERE firebase_uid = ANY($1) AND nickname IS NOT NULL AND nickname <> ''`, uids)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var uid, nick string
if err := rows.Scan(&uid, &nick); err != nil {
return nil, err
}
out[uid] = nick
}
return out, rows.Err()
}
// ── reaction game ("Идеальный старт") ──────────────────────────────────────
type ReactionResult struct {
BestMs int
Attempts int
IsRecord bool
}
type ReactionEntry struct {
UID string
Name string
BestMs int
Attempts int
}
// SubmitReaction records an attempt, keeping the player's best (lowest) ms.
// Returns the resulting best/attempts and whether this attempt set a new best.
func (s *Store) SubmitReaction(ctx context.Context, uid, name string, ms int) (ReactionResult, error) {
var r ReactionResult
err := s.pool.QueryRow(ctx,
`INSERT INTO games.reaction_players (uid, name, best_ms, attempts, updated_at)
VALUES ($1, $2, $3, 1, now())
ON CONFLICT (uid) DO UPDATE SET
best_ms = LEAST(games.reaction_players.best_ms, EXCLUDED.best_ms),
name = CASE WHEN EXCLUDED.name <> '' THEN EXCLUDED.name ELSE games.reaction_players.name END,
attempts = games.reaction_players.attempts + 1,
updated_at = now()
RETURNING best_ms, attempts, (best_ms = $3)`,
uid, name, ms).Scan(&r.BestMs, &r.Attempts, &r.IsRecord)
return r, err
}
func (s *Store) ReactionLeaderboard(ctx context.Context, limit int) ([]ReactionEntry, error) {
rows, err := s.pool.Query(ctx,
`SELECT uid, name, best_ms, attempts FROM games.reaction_players
ORDER BY best_ms ASC, updated_at ASC LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ReactionEntry
for rows.Next() {
var e ReactionEntry
if err := rows.Scan(&e.UID, &e.Name, &e.BestMs, &e.Attempts); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// ── arcade games (levels + stars), e.g. "slalom" ───────────────────────────
type ProgressEntry struct {
Variant string
Level int
Stars int
}
type ArcadeEntry struct {
UID string
Name string
Stars int
}
// ArcadeComplete records a level result, keeping the best (max) stars per level.
func (s *Store) ArcadeComplete(ctx context.Context, game, uid, name, variant string, level, stars int) error {
_, err := s.pool.Exec(ctx,
`INSERT INTO games.arcade_progress (game, uid, variant, level, stars, name, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, now())
ON CONFLICT (game, uid, variant, level) DO UPDATE SET
stars = GREATEST(games.arcade_progress.stars, EXCLUDED.stars),
name = CASE WHEN EXCLUDED.name <> '' THEN EXCLUDED.name ELSE games.arcade_progress.name END,
updated_at = now()`,
game, uid, variant, level, stars, name)
return err
}
// ArcadeProgress returns the user's completed levels (best stars) for a game.
func (s *Store) ArcadeProgress(ctx context.Context, game, uid string) ([]ProgressEntry, error) {
rows, err := s.pool.Query(ctx,
`SELECT variant, level, stars FROM games.arcade_progress WHERE game = $1 AND uid = $2`,
game, uid)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ProgressEntry
for rows.Next() {
var e ProgressEntry
if err := rows.Scan(&e.Variant, &e.Level, &e.Stars); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// ArcadeLeaderboard ranks players by total stars across all levels of a game.
func (s *Store) ArcadeLeaderboard(ctx context.Context, game string, limit int) ([]ArcadeEntry, error) {
rows, err := s.pool.Query(ctx,
`SELECT uid, max(name) AS name, sum(stars) AS total
FROM games.arcade_progress WHERE game = $1
GROUP BY uid ORDER BY total DESC, max(updated_at) ASC LIMIT $2`,
game, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ArcadeEntry
for rows.Next() {
var e ArcadeEntry
if err := rows.Scan(&e.UID, &e.Name, &e.Stars); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// ReactionMe returns the player's best/attempts/rank; found=false if never played.
func (s *Store) ReactionMe(ctx context.Context, uid string) (best, attempts, rank int, found bool, err error) {
err = s.pool.QueryRow(ctx,
`SELECT best_ms, attempts,
(SELECT count(*) + 1 FROM games.reaction_players p2 WHERE p2.best_ms < p.best_ms) AS rank
FROM games.reaction_players p WHERE uid = $1`, uid).Scan(&best, &attempts, &rank)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, 0, 0, false, nil
}
return 0, 0, 0, false, err
}
return best, attempts, rank, true, nil
}
package main
import (
"context"
"log"
"net/http"
"time"
"github.com/ontrack-by/games-service/internal/api"
"github.com/ontrack-by/games-service/internal/catalog"
"github.com/ontrack-by/games-service/internal/config"
"github.com/ontrack-by/games-service/internal/store"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("config: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
st, err := store.Open(ctx, cfg.DatabaseURL)
cancel()
if err != nil {
log.Fatalf("store: %v", err)
}
defer st.Close()
srv := api.New(cfg, st, catalog.New(cfg.CatalogURL, cfg.ServiceToken))
httpServer := &http.Server{
Addr: ":" + cfg.Port,
Handler: srv.Handler(),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Printf("games-service listening on :%s", cfg.Port)
if err := httpServer.ListenAndServe(); err != nil {
log.Fatalf("server stopped: %v", err)
}
}