mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-16 20:47:55 +00:00
Implement leaderboard service and POC HTTP handlers with Swagger documentation
This commit is contained in:
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -301,6 +301,7 @@ func main() {
|
|||||||
mux.HandleFunc("/api/races/queue/join", racesQueueJoinHandler(racesSvc))
|
mux.HandleFunc("/api/races/queue/join", racesQueueJoinHandler(racesSvc))
|
||||||
mux.HandleFunc("/api/races/plans", racePlansRouter(racesSvc))
|
mux.HandleFunc("/api/races/plans", racePlansRouter(racesSvc))
|
||||||
mux.HandleFunc("/api/races/plans/", racePlansDeleteHandler(racesSvc))
|
mux.HandleFunc("/api/races/plans/", racePlansDeleteHandler(racesSvc))
|
||||||
|
mux.HandleFunc("/api/leaderboard", leaderboardHandler(racesSvc))
|
||||||
mux.HandleFunc("/api/clans", clansRouter(clansSvc))
|
mux.HandleFunc("/api/clans", clansRouter(clansSvc))
|
||||||
mux.HandleFunc("/api/clans/", clansByIDHandler(clansSvc))
|
mux.HandleFunc("/api/clans/", clansByIDHandler(clansSvc))
|
||||||
mux.HandleFunc("/api/drivers", driversRouter(driversSvc))
|
mux.HandleFunc("/api/drivers", driversRouter(driversSvc))
|
||||||
|
|||||||
@@ -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": {
|
"/api/races": {
|
||||||
"get": {
|
"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)",
|
"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": {
|
"transport.LobbyRace": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -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": {
|
"/api/races": {
|
||||||
"get": {
|
"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)",
|
"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": {
|
"transport.LobbyRace": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -252,6 +252,58 @@ definitions:
|
|||||||
type:
|
type:
|
||||||
$ref: '#/definitions/transport.MessageType'
|
$ref: '#/definitions/transport.MessageType'
|
||||||
type: object
|
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:
|
transport.LobbyRace:
|
||||||
properties:
|
properties:
|
||||||
created_ms:
|
created_ms:
|
||||||
@@ -1408,6 +1460,51 @@ paths:
|
|||||||
summary: Get / update / delete a driver by id
|
summary: Get / update / delete a driver by id
|
||||||
tags:
|
tags:
|
||||||
- drivers
|
- 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:
|
/api/races:
|
||||||
get:
|
get:
|
||||||
description: |-
|
description: |-
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -29,8 +29,6 @@ var DefaultNow = time.Now
|
|||||||
// run time. If no tracks exist, the seeder falls back to ["default"].
|
// run time. If no tracks exist, the seeder falls back to ["default"].
|
||||||
var fallbackTracks = []string{"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 {
|
var defaultDriverSeeds = []struct {
|
||||||
nick string
|
nick string
|
||||||
name string
|
name string
|
||||||
@@ -43,6 +41,18 @@ var defaultDriverSeeds = []struct {
|
|||||||
{"FRA", "Frank"},
|
{"FRA", "Frank"},
|
||||||
{"GRA", "Grace"},
|
{"GRA", "Grace"},
|
||||||
{"HEI", "Heidi"},
|
{"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.
|
// Default clans.
|
||||||
@@ -246,7 +256,7 @@ func (r *Runner) resetAll(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := r.pg.Exec(ctx,
|
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
|
return err
|
||||||
}
|
}
|
||||||
for _, m := range r.lb.ListRaces() {
|
for _, m := range r.lb.ListRaces() {
|
||||||
|
|||||||
@@ -685,6 +685,61 @@ func (s *Service) DeletePlan(ctx context.Context, id string) error {
|
|||||||
return s.pg.DeletePlan(ctx, id)
|
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
|
// Scheduler
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -839,6 +839,29 @@ type RaceResultMsg struct {
|
|||||||
FinishedAtMs int64 `json:"finished_at_ms"`
|
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.
|
// Encode marshals an envelope to JSON bytes.
|
||||||
func Encode(env *Envelope) ([]byte, error) {
|
func Encode(env *Envelope) ([]byte, error) {
|
||||||
if env.TSMs == 0 {
|
if env.TSMs == 0 {
|
||||||
|
|||||||
Reference in New Issue
Block a user