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/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))
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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: |-
|
||||
|
||||
@@ -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"].
|
||||
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() {
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user