From 4a894a4399b5cc2bc4617d636d971a5e5daaa618 Mon Sep 17 00:00:00 2001 From: IS1DI Date: Wed, 15 Jul 2026 11:29:19 +0400 Subject: [PATCH] Implement leaderboard service and POC HTTP handlers with Swagger documentation --- server/cmd/poc-server/leaderboard_handlers.go | 120 +++++ server/cmd/poc-server/main.go | 1 + server/docs/docs.go | 138 ++++++ server/docs/swagger.json | 138 ++++++ server/docs/swagger.yaml | 97 ++++ server/internal/races/leaderboard.go | 453 ++++++++++++++++++ server/internal/races/leaderboard_test.go | 262 ++++++++++ server/internal/races/seed/seed.go | 16 +- server/internal/races/service.go | 55 +++ server/internal/transport/codec.go | 23 + 10 files changed, 1300 insertions(+), 3 deletions(-) create mode 100644 server/cmd/poc-server/leaderboard_handlers.go create mode 100644 server/internal/races/leaderboard.go create mode 100644 server/internal/races/leaderboard_test.go diff --git a/server/cmd/poc-server/leaderboard_handlers.go b/server/cmd/poc-server/leaderboard_handlers.go new file mode 100644 index 0000000..d2a02fa --- /dev/null +++ b/server/cmd/poc-server/leaderboard_handlers.go @@ -0,0 +1,120 @@ +package main + +import ( + "net/http" + "strconv" + + "github.com/x0gp/server/internal/races" + "github.com/x0gp/server/internal/transport" +) + +// leaderboardHandler godoc +// @Summary Leaderboard +// @Description Returns paginated leaderboard list. Supports overall leaderboard or track-specific leaderboard for the current year. +// @Tags leaderboard +// @Produce json +// @Param track_id query string false "Track ID. If empty, returns overall leaderboard." +// @Param year query int false "Year. Defaults to current year." +// @Param limit query int false "Limit (1..200, default 50)" +// @Param offset query int false "Offset (default 0)" +// @Param X-Driver-Id header string false "Current driver ID. Defaults to driver-seed-001 (mock Alice)." +// @Success 200 {object} transport.LeaderboardResponse "Leaderboard page and current driver position" +// @Failure 400 {object} map[string]interface{} "Invalid input" +// @Failure 500 {object} map[string]interface{} "Internal server error" +// @Router /api/leaderboard [get] +func leaderboardHandler(svc *races.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + trackID := r.URL.Query().Get("track_id") + + yearVal := r.URL.Query().Get("year") + var year int + if yearVal != "" { + var err error + year, err = strconv.Atoi(yearVal) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_input", "invalid year") + return + } + } + + limit := 50 + limitVal := r.URL.Query().Get("limit") + if limitVal != "" { + var err error + limit, err = strconv.Atoi(limitVal) + if err != nil || limit <= 0 { + writeError(w, http.StatusBadRequest, "invalid_input", "invalid limit") + return + } + if limit > 200 { + limit = 200 + } + } + + offset := 0 + offsetVal := r.URL.Query().Get("offset") + if offsetVal != "" { + var err error + offset, err = strconv.Atoi(offsetVal) + if err != nil || offset < 0 { + writeError(w, http.StatusBadRequest, "invalid_input", "invalid offset") + return + } + } + + driverID := r.Header.Get("X-Driver-Id") + if driverID == "" { + driverID = "driver-seed-001" // mock current driver + } + + res, err := svc.GetLeaderboard(r.Context(), trackID, year, limit, offset, driverID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + + items := make([]transport.LeaderboardEntry, 0, len(res.Items)) + for _, item := range res.Items { + items = append(items, leaderboardEntryToWire(item)) + } + + var curDriver *transport.LeaderboardEntry + if res.CurrentDriver != nil { + wire := leaderboardEntryToWire(*res.CurrentDriver) + curDriver = &wire + } + + writeJSON(w, http.StatusOK, transport.LeaderboardResponse{ + Items: items, + Total: res.Total, + Limit: limit, + Offset: offset, + CurrentDriver: curDriver, + }) + } +} + +func leaderboardEntryToWire(e races.LeaderboardEntry) transport.LeaderboardEntry { + var bestPos *int + if e.BestPos != nil { + pos := *e.BestPos + bestPos = &pos + } + var bestTime *int64 + if e.BestTimeMs != nil { + val := *e.BestTimeMs + bestTime = &val + } + return transport.LeaderboardEntry{ + DriverID: e.DriverID, + Nickname: e.Nickname, + Name: e.Name, + AvatarURL: e.AvatarURL, + ClanID: e.ClanID, + ClanTag: e.ClanTag, + Points: e.Points, + BestPos: bestPos, + BestTimeMs: bestTime, + Rank: e.Rank, + } +} diff --git a/server/cmd/poc-server/main.go b/server/cmd/poc-server/main.go index dc6c491..208a310 100644 --- a/server/cmd/poc-server/main.go +++ b/server/cmd/poc-server/main.go @@ -301,6 +301,7 @@ func main() { mux.HandleFunc("/api/races/queue/join", racesQueueJoinHandler(racesSvc)) mux.HandleFunc("/api/races/plans", racePlansRouter(racesSvc)) mux.HandleFunc("/api/races/plans/", racePlansDeleteHandler(racesSvc)) + mux.HandleFunc("/api/leaderboard", leaderboardHandler(racesSvc)) mux.HandleFunc("/api/clans", clansRouter(clansSvc)) mux.HandleFunc("/api/clans/", clansByIDHandler(clansSvc)) mux.HandleFunc("/api/drivers", driversRouter(driversSvc)) diff --git a/server/docs/docs.go b/server/docs/docs.go index e8cdcbc..6b7adca 100644 --- a/server/docs/docs.go +++ b/server/docs/docs.go @@ -1012,6 +1012,72 @@ const docTemplate = `{ } } }, + "/api/leaderboard": { + "get": { + "description": "Returns paginated leaderboard list. Supports overall leaderboard or track-specific leaderboard for the current year.", + "produces": [ + "application/json" + ], + "tags": [ + "leaderboard" + ], + "summary": "Leaderboard", + "parameters": [ + { + "type": "string", + "description": "Track ID. If empty, returns overall leaderboard.", + "name": "track_id", + "in": "query" + }, + { + "type": "integer", + "description": "Year. Defaults to current year.", + "name": "year", + "in": "query" + }, + { + "type": "integer", + "description": "Limit (1..200, default 50)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset (default 0)", + "name": "offset", + "in": "query" + }, + { + "type": "string", + "description": "Current driver ID. Defaults to driver-seed-001 (mock Alice).", + "name": "X-Driver-Id", + "in": "header" + } + ], + "responses": { + "200": { + "description": "Leaderboard page and current driver position", + "schema": { + "$ref": "#/definitions/transport.LeaderboardResponse" + } + }, + "400": { + "description": "Invalid input", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/api/races": { "get": { "description": "Returns one keyset page of races. Live races (status lobby|racing) come from in-memory lobby.Service; finished races come from Postgres. The two sources are merged and ordered by (started_ms DESC, id ASC). The cursor is opaque: pass back the next_cursor verbatim from a previous page.\n## Filters\n- ` + "`" + `status` + "`" + ` (comma-separated, e.g. \"racing,finished\"). Recognised values: all, lobby, racing, finished.\n- ` + "`" + `track_id` + "`" + ` (exact match against a physical track id; custom lobbies are not supported in this build).\n- ` + "`" + `limit` + "`" + ` (1..200, default 50)", @@ -2315,6 +2381,78 @@ const docTemplate = `{ } } }, + "transport.LeaderboardEntry": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string", + "example": "https://cdn.example.com/u/ace.png" + }, + "best_pos": { + "description": "Only populated for track-specific leaderboard", + "type": "integer", + "example": 1 + }, + "best_time_ms": { + "type": "integer", + "example": 15000 + }, + "clan_id": { + "type": "string", + "example": "clan-seed-001" + }, + "clan_tag": { + "type": "string", + "example": "ACE" + }, + "driver_id": { + "type": "string", + "example": "driver-seed-001" + }, + "name": { + "type": "string", + "example": "Alice" + }, + "nickname": { + "type": "string", + "example": "ACE" + }, + "points": { + "type": "integer", + "example": 25 + }, + "rank": { + "type": "integer", + "example": 1 + } + } + }, + "transport.LeaderboardResponse": { + "type": "object", + "properties": { + "current_driver": { + "$ref": "#/definitions/transport.LeaderboardEntry" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/transport.LeaderboardEntry" + } + }, + "limit": { + "type": "integer", + "example": 50 + }, + "offset": { + "type": "integer", + "example": 0 + }, + "total": { + "type": "integer", + "example": 8 + } + } + }, "transport.LobbyRace": { "type": "object", "properties": { diff --git a/server/docs/swagger.json b/server/docs/swagger.json index f95f2f8..c043751 100644 --- a/server/docs/swagger.json +++ b/server/docs/swagger.json @@ -1010,6 +1010,72 @@ } } }, + "/api/leaderboard": { + "get": { + "description": "Returns paginated leaderboard list. Supports overall leaderboard or track-specific leaderboard for the current year.", + "produces": [ + "application/json" + ], + "tags": [ + "leaderboard" + ], + "summary": "Leaderboard", + "parameters": [ + { + "type": "string", + "description": "Track ID. If empty, returns overall leaderboard.", + "name": "track_id", + "in": "query" + }, + { + "type": "integer", + "description": "Year. Defaults to current year.", + "name": "year", + "in": "query" + }, + { + "type": "integer", + "description": "Limit (1..200, default 50)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Offset (default 0)", + "name": "offset", + "in": "query" + }, + { + "type": "string", + "description": "Current driver ID. Defaults to driver-seed-001 (mock Alice).", + "name": "X-Driver-Id", + "in": "header" + } + ], + "responses": { + "200": { + "description": "Leaderboard page and current driver position", + "schema": { + "$ref": "#/definitions/transport.LeaderboardResponse" + } + }, + "400": { + "description": "Invalid input", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/api/races": { "get": { "description": "Returns one keyset page of races. Live races (status lobby|racing) come from in-memory lobby.Service; finished races come from Postgres. The two sources are merged and ordered by (started_ms DESC, id ASC). The cursor is opaque: pass back the next_cursor verbatim from a previous page.\n## Filters\n- `status` (comma-separated, e.g. \"racing,finished\"). Recognised values: all, lobby, racing, finished.\n- `track_id` (exact match against a physical track id; custom lobbies are not supported in this build).\n- `limit` (1..200, default 50)", @@ -2313,6 +2379,78 @@ } } }, + "transport.LeaderboardEntry": { + "type": "object", + "properties": { + "avatar_url": { + "type": "string", + "example": "https://cdn.example.com/u/ace.png" + }, + "best_pos": { + "description": "Only populated for track-specific leaderboard", + "type": "integer", + "example": 1 + }, + "best_time_ms": { + "type": "integer", + "example": 15000 + }, + "clan_id": { + "type": "string", + "example": "clan-seed-001" + }, + "clan_tag": { + "type": "string", + "example": "ACE" + }, + "driver_id": { + "type": "string", + "example": "driver-seed-001" + }, + "name": { + "type": "string", + "example": "Alice" + }, + "nickname": { + "type": "string", + "example": "ACE" + }, + "points": { + "type": "integer", + "example": 25 + }, + "rank": { + "type": "integer", + "example": 1 + } + } + }, + "transport.LeaderboardResponse": { + "type": "object", + "properties": { + "current_driver": { + "$ref": "#/definitions/transport.LeaderboardEntry" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/transport.LeaderboardEntry" + } + }, + "limit": { + "type": "integer", + "example": 50 + }, + "offset": { + "type": "integer", + "example": 0 + }, + "total": { + "type": "integer", + "example": 8 + } + } + }, "transport.LobbyRace": { "type": "object", "properties": { diff --git a/server/docs/swagger.yaml b/server/docs/swagger.yaml index 149a74e..65a1660 100644 --- a/server/docs/swagger.yaml +++ b/server/docs/swagger.yaml @@ -252,6 +252,58 @@ definitions: type: $ref: '#/definitions/transport.MessageType' type: object + transport.LeaderboardEntry: + properties: + avatar_url: + example: https://cdn.example.com/u/ace.png + type: string + best_pos: + description: Only populated for track-specific leaderboard + example: 1 + type: integer + best_time_ms: + example: 15000 + type: integer + clan_id: + example: clan-seed-001 + type: string + clan_tag: + example: ACE + type: string + driver_id: + example: driver-seed-001 + type: string + name: + example: Alice + type: string + nickname: + example: ACE + type: string + points: + example: 25 + type: integer + rank: + example: 1 + type: integer + type: object + transport.LeaderboardResponse: + properties: + current_driver: + $ref: '#/definitions/transport.LeaderboardEntry' + items: + items: + $ref: '#/definitions/transport.LeaderboardEntry' + type: array + limit: + example: 50 + type: integer + offset: + example: 0 + type: integer + total: + example: 8 + type: integer + type: object transport.LobbyRace: properties: created_ms: @@ -1408,6 +1460,51 @@ paths: summary: Get / update / delete a driver by id tags: - drivers + /api/leaderboard: + get: + description: Returns paginated leaderboard list. Supports overall leaderboard + or track-specific leaderboard for the current year. + parameters: + - description: Track ID. If empty, returns overall leaderboard. + in: query + name: track_id + type: string + - description: Year. Defaults to current year. + in: query + name: year + type: integer + - description: Limit (1..200, default 50) + in: query + name: limit + type: integer + - description: Offset (default 0) + in: query + name: offset + type: integer + - description: Current driver ID. Defaults to driver-seed-001 (mock Alice). + in: header + name: X-Driver-Id + type: string + produces: + - application/json + responses: + "200": + description: Leaderboard page and current driver position + schema: + $ref: '#/definitions/transport.LeaderboardResponse' + "400": + description: Invalid input + schema: + additionalProperties: true + type: object + "500": + description: Internal server error + schema: + additionalProperties: true + type: object + summary: Leaderboard + tags: + - leaderboard /api/races: get: description: |- diff --git a/server/internal/races/leaderboard.go b/server/internal/races/leaderboard.go new file mode 100644 index 0000000..101f611 --- /dev/null +++ b/server/internal/races/leaderboard.go @@ -0,0 +1,453 @@ +package races + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// LeaderboardEntry is a single row in the leaderboard. +type LeaderboardEntry struct { + DriverID string + Name string + Nickname string + AvatarURL string + ClanID string + ClanTag string + Points int + BestPos *int // deprecated/optional + BestTimeMs *int64 + Rank int +} + +// LeaderboardResult groups the paginated items, total count, and current driver. +type LeaderboardResult struct { + Items []LeaderboardEntry + Total int + CurrentDriver *LeaderboardEntry +} + +func getYearTimestamps(year int) (int64, int64) { + start := time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(year+1, time.January, 1, 0, 0, 0, 0, time.UTC) + return start.UnixMilli(), end.UnixMilli() +} + +// GetTrackLeaderboard returns track-specific leaderboard entries for the given track and year. +func (s *PgStore) GetTrackLeaderboard(ctx context.Context, trackID string, year int, limit, offset int) ([]LeaderboardEntry, int, error) { + startMs, endMs := getYearTimestamps(year) + if limit <= 0 { + limit = 50 + } + + countQuery := ` + WITH best_results AS ( + SELECT + rd.driver_id + FROM race_drivers rd + JOIN races r ON rd.race_id = r.id + WHERE r.status = 'finished' + AND r.track_id = $1 + AND r.finished_ms >= $2 + AND r.finished_ms < $3 + GROUP BY rd.driver_id + ) + SELECT COUNT(*) FROM best_results` + + var total int + err := s.pool.QueryRow(ctx, countQuery, trackID, startMs, endMs).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("count track leaderboard: %w", err) + } + + if total == 0 { + return nil, 0, nil + } + + query := ` + WITH best_results AS ( + SELECT + r.track_id, + rd.driver_id, + MIN(CASE WHEN rd.best_lap_ms > 0 THEN rd.best_lap_ms ELSE NULL END) as best_time + FROM race_drivers rd + JOIN races r ON rd.race_id = r.id + WHERE r.status = 'finished' + AND r.finished_ms >= $1 + AND r.finished_ms < $2 + GROUP BY r.track_id, rd.driver_id + ), + ranked_times AS ( + SELECT + track_id, + driver_id, + best_time, + ROW_NUMBER() OVER (PARTITION BY track_id ORDER BY best_time ASC NULLS LAST, driver_id ASC) as time_rank + FROM best_results + ), + track_points AS ( + SELECT + track_id, + driver_id, + best_time, + CASE + WHEN best_time IS NULL THEN 1 + WHEN time_rank <= 3 THEN 25 + WHEN time_rank <= 10 THEN 18 + WHEN time_rank <= 20 THEN 15 + WHEN time_rank <= 30 THEN 12 + WHEN time_rank <= 40 THEN 10 + WHEN time_rank <= 50 THEN 8 + WHEN time_rank <= 60 THEN 6 + WHEN time_rank <= 70 THEN 4 + WHEN time_rank <= 80 THEN 2 + WHEN time_rank <= 90 THEN 1 + ELSE 1 + END as points + FROM ranked_times + ), + ranked_leaderboard AS ( + SELECT + tp.driver_id, + d.name, + d.nickname, + d.avatar_url, + COALESCE(d.clan_id, '') as clan_id, + COALESCE(c.tag, '') as clan_tag, + tp.points, + tp.best_time, + ROW_NUMBER() OVER (ORDER BY tp.points DESC, tp.best_time ASC NULLS LAST, tp.driver_id ASC) as rank + FROM track_points tp + JOIN drivers d ON tp.driver_id = d.id + LEFT JOIN clans c ON d.clan_id = c.id + WHERE tp.track_id = $3 + ) + SELECT rank, driver_id, name, nickname, avatar_url, clan_id, clan_tag, points, best_time + FROM ranked_leaderboard + ORDER BY rank ASC + LIMIT $4 OFFSET $5` + + rows, err := s.pool.Query(ctx, query, startMs, endMs, trackID, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("query track leaderboard: %w", err) + } + defer rows.Close() + + var entries []LeaderboardEntry + for rows.Next() { + var entry LeaderboardEntry + if err := rows.Scan( + &entry.Rank, &entry.DriverID, &entry.Name, &entry.Nickname, + &entry.AvatarURL, &entry.ClanID, &entry.ClanTag, &entry.Points, &entry.BestTimeMs, + ); err != nil { + return nil, 0, err + } + entries = append(entries, entry) + } + return entries, total, nil +} + +// GetTrackLeaderboardDriver returns a single driver's rank on a track. +func (s *PgStore) GetTrackLeaderboardDriver(ctx context.Context, trackID string, year int, driverID string) (*LeaderboardEntry, error) { + startMs, endMs := getYearTimestamps(year) + query := ` + WITH best_results AS ( + SELECT + r.track_id, + rd.driver_id, + MIN(CASE WHEN rd.best_lap_ms > 0 THEN rd.best_lap_ms ELSE NULL END) as best_time + FROM race_drivers rd + JOIN races r ON rd.race_id = r.id + WHERE r.status = 'finished' + AND r.finished_ms >= $1 + AND r.finished_ms < $2 + GROUP BY r.track_id, rd.driver_id + ), + ranked_times AS ( + SELECT + track_id, + driver_id, + best_time, + ROW_NUMBER() OVER (PARTITION BY track_id ORDER BY best_time ASC NULLS LAST, driver_id ASC) as time_rank + FROM best_results + ), + track_points AS ( + SELECT + track_id, + driver_id, + best_time, + CASE + WHEN best_time IS NULL THEN 1 + WHEN time_rank <= 3 THEN 25 + WHEN time_rank <= 10 THEN 18 + WHEN time_rank <= 20 THEN 15 + WHEN time_rank <= 30 THEN 12 + WHEN time_rank <= 40 THEN 10 + WHEN time_rank <= 50 THEN 8 + WHEN time_rank <= 60 THEN 6 + WHEN time_rank <= 70 THEN 4 + WHEN time_rank <= 80 THEN 2 + WHEN time_rank <= 90 THEN 1 + ELSE 1 + END as points + FROM ranked_times + ), + ranked_leaderboard AS ( + SELECT + tp.driver_id, + d.name, + d.nickname, + d.avatar_url, + COALESCE(d.clan_id, '') as clan_id, + COALESCE(c.tag, '') as clan_tag, + tp.points, + tp.best_time, + ROW_NUMBER() OVER (ORDER BY tp.points DESC, tp.best_time ASC NULLS LAST, tp.driver_id ASC) as rank + FROM track_points tp + JOIN drivers d ON tp.driver_id = d.id + LEFT JOIN clans c ON d.clan_id = c.id + WHERE tp.track_id = $3 + ) + SELECT rank, driver_id, name, nickname, avatar_url, clan_id, clan_tag, points, best_time + FROM ranked_leaderboard + WHERE driver_id = $4` + + row := s.pool.QueryRow(ctx, query, startMs, endMs, trackID, driverID) + var entry LeaderboardEntry + if err := row.Scan( + &entry.Rank, &entry.DriverID, &entry.Name, &entry.Nickname, + &entry.AvatarURL, &entry.ClanID, &entry.ClanTag, &entry.Points, &entry.BestTimeMs, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &entry, nil +} + +// GetOverallLeaderboard returns overall leaderboard entries for the given year. +func (s *PgStore) GetOverallLeaderboard(ctx context.Context, year int, limit, offset int) ([]LeaderboardEntry, int, error) { + startMs, endMs := getYearTimestamps(year) + if limit <= 0 { + limit = 50 + } + + countQuery := ` + WITH best_results AS ( + SELECT + rd.driver_id + FROM race_drivers rd + JOIN races r ON rd.race_id = r.id + WHERE r.status = 'finished' + AND r.finished_ms >= $1 + AND r.finished_ms < $2 + GROUP BY rd.driver_id + ) + SELECT COUNT(*) FROM best_results` + + var total int + err := s.pool.QueryRow(ctx, countQuery, startMs, endMs).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("count overall leaderboard: %w", err) + } + + if total == 0 { + return nil, 0, nil + } + + query := ` + WITH best_results AS ( + SELECT + r.track_id, + rd.driver_id, + MIN(CASE WHEN rd.best_lap_ms > 0 THEN rd.best_lap_ms ELSE NULL END) as best_time + FROM race_drivers rd + JOIN races r ON rd.race_id = r.id + WHERE r.status = 'finished' + AND r.finished_ms >= $1 + AND r.finished_ms < $2 + GROUP BY r.track_id, rd.driver_id + ), + ranked_times AS ( + SELECT + track_id, + driver_id, + best_time, + ROW_NUMBER() OVER (PARTITION BY track_id ORDER BY best_time ASC NULLS LAST, driver_id ASC) as time_rank + FROM best_results + ), + track_points AS ( + SELECT + track_id, + driver_id, + best_time, + CASE + WHEN best_time IS NULL THEN 1 + WHEN time_rank <= 3 THEN 25 + WHEN time_rank <= 10 THEN 18 + WHEN time_rank <= 20 THEN 15 + WHEN time_rank <= 30 THEN 12 + WHEN time_rank <= 40 THEN 10 + WHEN time_rank <= 50 THEN 8 + WHEN time_rank <= 60 THEN 6 + WHEN time_rank <= 70 THEN 4 + WHEN time_rank <= 80 THEN 2 + WHEN time_rank <= 90 THEN 1 + ELSE 1 + END as points + FROM ranked_times + ), + overall_points AS ( + SELECT + driver_id, + SUM(points) as points + FROM track_points + GROUP BY driver_id + ), + ranked_overall AS ( + SELECT + op.driver_id, + d.name, + d.nickname, + d.avatar_url, + COALESCE(d.clan_id, '') as clan_id, + COALESCE(c.tag, '') as clan_tag, + op.points, + ROW_NUMBER() OVER (ORDER BY op.points DESC, op.driver_id ASC) as rank + FROM overall_points op + JOIN drivers d ON op.driver_id = d.id + LEFT JOIN clans c ON d.clan_id = c.id + ) + SELECT rank, driver_id, name, nickname, avatar_url, clan_id, clan_tag, points + FROM ranked_overall + ORDER BY rank ASC + LIMIT $3 OFFSET $4` + + rows, err := s.pool.Query(ctx, query, startMs, endMs, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("query overall leaderboard: %w", err) + } + defer rows.Close() + + var entries []LeaderboardEntry + for rows.Next() { + var entry LeaderboardEntry + if err := rows.Scan( + &entry.Rank, &entry.DriverID, &entry.Name, &entry.Nickname, + &entry.AvatarURL, &entry.ClanID, &entry.ClanTag, &entry.Points, + ); err != nil { + return nil, 0, err + } + entries = append(entries, entry) + } + return entries, total, nil +} + +// GetOverallLeaderboardDriver returns a single driver's overall rank. +func (s *PgStore) GetOverallLeaderboardDriver(ctx context.Context, year int, driverID string) (*LeaderboardEntry, error) { + startMs, endMs := getYearTimestamps(year) + query := ` + WITH best_results AS ( + SELECT + r.track_id, + rd.driver_id, + MIN(CASE WHEN rd.best_lap_ms > 0 THEN rd.best_lap_ms ELSE NULL END) as best_time + FROM race_drivers rd + JOIN races r ON rd.race_id = r.id + WHERE r.status = 'finished' + AND r.finished_ms >= $1 + AND r.finished_ms < $2 + GROUP BY r.track_id, rd.driver_id + ), + ranked_times AS ( + SELECT + track_id, + driver_id, + best_time, + ROW_NUMBER() OVER (PARTITION BY track_id ORDER BY best_time ASC NULLS LAST, driver_id ASC) as time_rank + FROM best_results + ), + track_points AS ( + SELECT + track_id, + driver_id, + best_time, + CASE + WHEN best_time IS NULL THEN 1 + WHEN time_rank <= 3 THEN 25 + WHEN time_rank <= 10 THEN 18 + WHEN time_rank <= 20 THEN 15 + WHEN time_rank <= 30 THEN 12 + WHEN time_rank <= 40 THEN 10 + WHEN time_rank <= 50 THEN 8 + WHEN time_rank <= 60 THEN 6 + WHEN time_rank <= 70 THEN 4 + WHEN time_rank <= 80 THEN 2 + WHEN time_rank <= 90 THEN 1 + ELSE 1 + END as points + FROM ranked_times + ), + overall_points AS ( + SELECT + driver_id, + SUM(points) as points + FROM track_points + GROUP BY driver_id + ), + ranked_overall AS ( + SELECT + op.driver_id, + d.name, + d.nickname, + d.avatar_url, + COALESCE(d.clan_id, '') as clan_id, + COALESCE(c.tag, '') as clan_tag, + op.points, + ROW_NUMBER() OVER (ORDER BY op.points DESC, op.driver_id ASC) as rank + FROM overall_points op + JOIN drivers d ON op.driver_id = d.id + LEFT JOIN clans c ON d.clan_id = c.id + ) + SELECT rank, driver_id, name, nickname, avatar_url, clan_id, clan_tag, points + FROM ranked_overall + WHERE driver_id = $3` + + row := s.pool.QueryRow(ctx, query, startMs, endMs, driverID) + var entry LeaderboardEntry + if err := row.Scan( + &entry.Rank, &entry.DriverID, &entry.Name, &entry.Nickname, + &entry.AvatarURL, &entry.ClanID, &entry.ClanTag, &entry.Points, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &entry, nil +} + +// GetDriverLeaderboardProfile returns driver profile information. +func (s *PgStore) GetDriverLeaderboardProfile(ctx context.Context, driverID string) (*LeaderboardEntry, error) { + query := ` + SELECT d.id, d.name, d.nickname, d.avatar_url, COALESCE(d.clan_id, ''), COALESCE(c.tag, '') + FROM drivers d + LEFT JOIN clans c ON d.clan_id = c.id + WHERE d.id = $1` + + row := s.pool.QueryRow(ctx, query, driverID) + var entry LeaderboardEntry + if err := row.Scan( + &entry.DriverID, &entry.Name, &entry.Nickname, &entry.AvatarURL, &entry.ClanID, &entry.ClanTag, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &entry, nil +} diff --git a/server/internal/races/leaderboard_test.go b/server/internal/races/leaderboard_test.go new file mode 100644 index 0000000..5f3fd96 --- /dev/null +++ b/server/internal/races/leaderboard_test.go @@ -0,0 +1,262 @@ +package races + +import ( + "context" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestLeaderboard(t *testing.T) { + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + t.Skip("DATABASE_URL is not set, skipping database integration tests") + } + + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("failed to connect to test database: %v", err) + } + defer pool.Close() + + store := NewPgStore(pool) + + // Clean up any left-overs with our test prefix + cleanup := func() { + _, _ = pool.Exec(ctx, "DELETE FROM race_drivers WHERE race_id LIKE 'test-race-%'") + _, _ = pool.Exec(ctx, "DELETE FROM races WHERE id LIKE 'test-race-%'") + _, _ = pool.Exec(ctx, "DELETE FROM drivers WHERE id LIKE 'test-driver-%'") + _, _ = pool.Exec(ctx, "DELETE FROM clans WHERE id LIKE 'test-clan-%'") + } + cleanup() + defer cleanup() + + // 1. Create a test clan and test drivers + nowMs := time.Now().UnixMilli() + _, err = pool.Exec(ctx, ` + INSERT INTO clans (id, tag, name, avatar_url, created_ms, updated_ms) + VALUES ('test-clan-1', 'TST', 'Test Clan', 'http://avatar.clan', $1, $1)`, nowMs) + if err != nil { + t.Fatalf("failed to insert test clan: %v", err) + } + + driversData := []struct { + id string + nick string + name string + }{ + {"test-driver-1", "DRV", "Driver One"}, + {"test-driver-2", "TWO", "Driver Two"}, + {"test-driver-3", "THR", "Driver Three"}, + {"test-driver-4", "FOR", "Driver Four"}, + } + + for _, d := range driversData { + _, err = pool.Exec(ctx, ` + INSERT INTO drivers (id, nickname, name, avatar_url, clan_id, created_ms, updated_ms) + VALUES ($1, $2, $3, 'http://avatar', 'test-clan-1', $4, $4)`, + d.id, d.nick, d.name, nowMs) + if err != nil { + t.Fatalf("failed to insert test driver %s: %v", d.id, err) + } + } + + // 2. Create test races on different tracks for current year + currentYear := time.Now().Year() + testYearMs := time.Date(currentYear, time.June, 1, 12, 0, 0, 0, time.UTC).UnixMilli() + + racesData := []struct { + id string + trackID string + }{ + {"test-race-1", "test-track-A"}, + {"test-race-2", "test-track-A"}, + {"test-race-3", "test-track-B"}, + } + + for _, r := range racesData { + _, err = pool.Exec(ctx, ` + INSERT INTO races (id, name, track_id, max_cars, laps, time_limit_s, status, created_ms, started_ms, finished_ms, updated_ms) + VALUES ($1, 'Test Race', $2, 4, 10, 60, 'finished', $3, $3, $3, $3)`, + r.id, r.trackID, testYearMs) + if err != nil { + t.Fatalf("failed to insert test race %s: %v", r.id, err) + } + } + + // 3. Insert race drivers results + // Race 1 (Track A): + // - test-driver-1: best_lap 100 (1st fastest -> 25 points) + // - test-driver-2: best_lap 400 (currently 3rd fastest -> 25 points) + // - test-driver-3: best_lap 300 (currently 2nd fastest -> 25 points) + // - test-driver-4: best_lap 0 (DNF, completed 0 laps -> 1 point) + resultsRace1 := []struct { + driverID string + bestLapMs int64 + pos any + }{ + {"test-driver-1", 100, 1}, + {"test-driver-2", 400, 2}, + {"test-driver-3", 300, 3}, + {"test-driver-4", 0, nil}, + } + for _, res := range resultsRace1 { + _, err = pool.Exec(ctx, ` + INSERT INTO race_drivers (race_id, driver_id, slot, joined_ms, total_time_ms, best_lap_ms, position) + VALUES ('test-race-1', $1, 0, $2, 1000, $3, $4)`, + res.driverID, testYearMs, res.bestLapMs, res.pos) + if err != nil { + t.Fatalf("failed to insert race driver result: %v", err) + } + } + + // Race 2 (Track A - testing best results): + // - test-driver-2: best_lap 150 (2nd fastest -> 25 points, overrides 400ms lap time) + // This makes the times: + // - test-driver-1: 100ms (rank 1 -> 25 points) + // - test-driver-2: 150ms (rank 2 -> 25 points) + // - test-driver-3: 300ms (rank 3 -> 25 points) + // - test-driver-4: NULL (rank 4 -> 1 point) + // Exactly 3 drivers have 25 points. + _, err = pool.Exec(ctx, ` + INSERT INTO race_drivers (race_id, driver_id, slot, joined_ms, total_time_ms, best_lap_ms, position) + VALUES ('test-race-2', 'test-driver-2', 0, $1, 1000, 150, 1)`, testYearMs) + if err != nil { + t.Fatalf("failed to insert race 2 result: %v", err) + } + + // Race 3 (Track B): + // - test-driver-1: best_lap 500 (rank 2 on Track B -> 25 points) + // - test-driver-3: best_lap 200 (rank 1 on Track B -> 25 points) + resultsRace3 := []struct { + driverID string + bestLapMs int64 + pos any + }{ + {"test-driver-1", 500, 2}, + {"test-driver-3", 200, 1}, + } + for _, res := range resultsRace3 { + _, err = pool.Exec(ctx, ` + INSERT INTO race_drivers (race_id, driver_id, slot, joined_ms, total_time_ms, best_lap_ms, position) + VALUES ('test-race-3', $1, 0, $2, 1000, $3, $4)`, + res.driverID, testYearMs, res.bestLapMs, res.pos) + if err != nil { + t.Fatalf("failed to insert race 3 driver result: %v", err) + } + } + + // Test Track A Leaderboard + t.Run("Track A Leaderboard", func(t *testing.T) { + entries, total, err := store.GetTrackLeaderboard(ctx, "test-track-A", currentYear, 10, 0) + if err != nil { + t.Fatalf("failed to get track leaderboard: %v", err) + } + if total != 4 { + t.Errorf("expected total 4, got %d", total) + } + if len(entries) != 4 { + t.Fatalf("expected 4 entries, got %d", len(entries)) + } + + // Rank 1: test-driver-1 (25 pts, best_time 100ms) + // Rank 2: test-driver-2 (25 pts, best_time 150ms) + // Rank 3: test-driver-3 (25 pts, best_time 300ms) + // Rank 4: test-driver-4 (1 pt, best_time NULL) + drv1 := entries[0] + if drv1.DriverID != "test-driver-1" || drv1.Points != 25 || *drv1.BestTimeMs != 100 || drv1.Rank != 1 { + t.Errorf("invalid rank 1: %+v", drv1) + } + + drv2 := entries[1] + if drv2.DriverID != "test-driver-2" || drv2.Points != 25 || *drv2.BestTimeMs != 150 || drv2.Rank != 2 { + t.Errorf("invalid rank 2: %+v", drv2) + } + + // Check entries by looping to find specific drivers + for _, e := range entries { + if e.DriverID == "test-driver-3" { + if e.Points != 25 || *e.BestTimeMs != 300 || e.Rank != 3 { + t.Errorf("invalid test-driver-3: %+v", e) + } + } + if e.DriverID == "test-driver-4" { + if e.Points != 1 || e.BestTimeMs != nil || e.Rank != 4 { + t.Errorf("invalid test-driver-4: %+v", e) + } + } + } + }) + + // Test Track A Driver Rank + t.Run("Track A Driver Rank", func(t *testing.T) { + entry, err := store.GetTrackLeaderboardDriver(ctx, "test-track-A", currentYear, "test-driver-2") + if err != nil { + t.Fatalf("failed to get driver rank: %v", err) + } + if entry == nil || entry.Rank != 2 || entry.Points != 25 || *entry.BestTimeMs != 150 { + t.Errorf("expected rank 2, points 25, best_time 150, got %+v", entry) + } + }) + + // Test Overall Leaderboard + t.Run("Overall Leaderboard", func(t *testing.T) { + // Overall points calculation: + // - test-driver-1: Track A (25) + Track B (25) = 50 points + // - test-driver-3: Track A (25) + Track B (25) = 50 points + // - test-driver-2: Track A (25) + Track B (0) = 25 points + // - test-driver-4: Track A (1) + Track B (0) = 1 point + entries, _, err := store.GetOverallLeaderboard(ctx, currentYear, 200, 0) + if err != nil { + t.Fatalf("failed to get overall leaderboard: %v", err) + } + + // Filter to only our test drivers + var testEntries []LeaderboardEntry + for _, entry := range entries { + if len(entry.DriverID) >= 11 && entry.DriverID[:12] == "test-driver-" { + testEntries = append(testEntries, entry) + } + } + + if len(testEntries) != 4 { + t.Fatalf("expected 4 test entries, got %d", len(testEntries)) + } + + // test-driver-1 and test-driver-3 should both have 50 points + if testEntries[0].Points != 50 || testEntries[1].Points != 50 { + t.Errorf("expected top 2 test drivers to have 50 points, got: %+v and %+v", testEntries[0], testEntries[1]) + } + if testEntries[2].DriverID != "test-driver-2" || testEntries[2].Points != 25 { + t.Errorf("invalid overall rank 3: %+v", testEntries[2]) + } + if testEntries[3].DriverID != "test-driver-4" || testEntries[3].Points != 1 { + t.Errorf("invalid overall rank 4: %+v", testEntries[3]) + } + }) + + // Test Overall Driver Rank + t.Run("Overall Driver Rank", func(t *testing.T) { + entry, err := store.GetOverallLeaderboardDriver(ctx, currentYear, "test-driver-3") + if err != nil { + t.Fatalf("failed to get overall driver rank: %v", err) + } + if entry == nil || entry.Points != 50 || entry.Rank <= 0 { + t.Errorf("expected rank > 0, points 50, got %+v", entry) + } + }) + + // Test Fallback Profile + t.Run("Fallback Profile", func(t *testing.T) { + entry, err := store.GetDriverLeaderboardProfile(ctx, "test-driver-1") + if err != nil { + t.Fatalf("failed to get profile: %v", err) + } + if entry == nil || entry.Name != "Driver One" || entry.Nickname != "DRV" || entry.ClanTag != "TST" { + t.Errorf("expected Driver One, DRV, TST, got %+v", entry) + } + }) +} diff --git a/server/internal/races/seed/seed.go b/server/internal/races/seed/seed.go index 0678494..3e5a682 100644 --- a/server/internal/races/seed/seed.go +++ b/server/internal/races/seed/seed.go @@ -29,8 +29,6 @@ var DefaultNow = time.Now // run time. If no tracks exist, the seeder falls back to ["default"]. var fallbackTracks = []string{"default"} -// Default driver nicknames used when the drivers table is empty. Three -// uppercase letters each (per the drivers table constraint). var defaultDriverSeeds = []struct { nick string name string @@ -43,6 +41,18 @@ var defaultDriverSeeds = []struct { {"FRA", "Frank"}, {"GRA", "Grace"}, {"HEI", "Heidi"}, + {"IVY", "Ivy"}, + {"JAC", "Jack"}, + {"KEN", "Ken"}, + {"LEO", "Leo"}, + {"MAX", "Max"}, + {"NED", "Ned"}, + {"OLI", "Oliver"}, + {"PEP", "Peggy"}, + {"QIN", "Quincy"}, + {"ROY", "Roy"}, + {"SAM", "Sam"}, + {"TED", "Ted"}, } // Default clans. @@ -246,7 +256,7 @@ func (r *Runner) resetAll(ctx context.Context) error { return err } if _, err := r.pg.Exec(ctx, - `TRUNCATE TABLE race_plans, race_queue`); err != nil { + `TRUNCATE TABLE race_plans, race_queue, drivers, clans CASCADE`); err != nil { return err } for _, m := range r.lb.ListRaces() { diff --git a/server/internal/races/service.go b/server/internal/races/service.go index a058755..e2e75d9 100644 --- a/server/internal/races/service.go +++ b/server/internal/races/service.go @@ -685,6 +685,61 @@ func (s *Service) DeletePlan(ctx context.Context, id string) error { return s.pg.DeletePlan(ctx, id) } +// GetLeaderboard retrieves the leaderboard page and current driver position. +func (s *Service) GetLeaderboard(ctx context.Context, trackID string, year int, limit, offset int, currentDriverID string) (*LeaderboardResult, error) { + if year == 0 { + year = s.now().Year() + } + + var items []LeaderboardEntry + var total int + var err error + + if trackID != "" { + items, total, err = s.pg.GetTrackLeaderboard(ctx, trackID, year, limit, offset) + } else { + items, total, err = s.pg.GetOverallLeaderboard(ctx, year, limit, offset) + } + if err != nil { + return nil, err + } + + var currentDriver *LeaderboardEntry + if currentDriverID != "" { + if trackID != "" { + currentDriver, err = s.pg.GetTrackLeaderboardDriver(ctx, trackID, year, currentDriverID) + } else { + currentDriver, err = s.pg.GetOverallLeaderboardDriver(ctx, year, currentDriverID) + } + if err != nil { + return nil, err + } + + if currentDriver == nil { + // fallback: get profile from drivers table + currentDriver, err = s.pg.GetDriverLeaderboardProfile(ctx, currentDriverID) + if err != nil { + return nil, err + } + // if driver exists but has no points, set points/rank to 0 + if currentDriver != nil { + currentDriver.Points = 0 + currentDriver.Rank = 0 + } + } + } + + if items == nil { + items = []LeaderboardEntry{} + } + + return &LeaderboardResult{ + Items: items, + Total: total, + CurrentDriver: currentDriver, + }, nil +} + // --------------------------------------------------------------------------- // Scheduler // --------------------------------------------------------------------------- diff --git a/server/internal/transport/codec.go b/server/internal/transport/codec.go index c089cee..a9e2e29 100644 --- a/server/internal/transport/codec.go +++ b/server/internal/transport/codec.go @@ -839,6 +839,29 @@ type RaceResultMsg struct { FinishedAtMs int64 `json:"finished_at_ms"` } +// LeaderboardEntry is a single row in the paginated leaderboard response. +type LeaderboardEntry struct { + DriverID string `json:"driver_id" example:"driver-seed-001"` + Nickname string `json:"nickname" example:"ACE"` + Name string `json:"name" example:"Alice"` + AvatarURL string `json:"avatar_url" example:"https://cdn.example.com/u/ace.png"` + ClanID string `json:"clan_id,omitempty" example:"clan-seed-001"` + ClanTag string `json:"clan_tag,omitempty" example:"ACE"` + Points int `json:"points" example:"25"` + BestPos *int `json:"best_pos,omitempty" example:"1"` // Only populated for track-specific leaderboard + BestTimeMs *int64 `json:"best_time_ms,omitempty" example:"15000"` + Rank int `json:"rank" example:"1"` +} + +// LeaderboardResponse is the paginated response for GET /api/leaderboard. +type LeaderboardResponse struct { + Items []LeaderboardEntry `json:"items"` + Total int `json:"total" example:"8"` + Limit int `json:"limit" example:"50"` + Offset int `json:"offset" example:"0"` + CurrentDriver *LeaderboardEntry `json:"current_driver,omitempty"` +} + // Encode marshals an envelope to JSON bytes. func Encode(env *Envelope) ([]byte, error) { if env.TSMs == 0 {