Implement leaderboard service and POC HTTP handlers with Swagger documentation

This commit is contained in:
2026-07-15 11:29:19 +04:00
parent c25e0bc581
commit 4a894a4399
10 changed files with 1300 additions and 3 deletions
@@ -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,
}
}
+1
View File
@@ -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))
+138
View File
@@ -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": {
+138
View File
@@ -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": {
+97
View File
@@ -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: |-
+453
View File
@@ -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
}
+262
View File
@@ -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)
}
})
}
+13 -3
View File
@@ -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() {
+55
View File
@@ -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
// ---------------------------------------------------------------------------
+23
View File
@@ -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 {