diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..650f013 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,67 @@ +name: Release Go Binaries + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + build: + name: Build Binary + runs-on: ubuntu-latest + strategy: + matrix: + goos: [linux, darwin, windows] + goarch: [amd64, arm64] + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: server/go.mod + + - name: Build Binary + working-directory: server + run: | + mkdir -p bin + OUT_NAME="poc-server-${{ matrix.goos }}-${{ matrix.goarch }}" + if [ "${{ matrix.goos }}" = "windows" ]; then + OUT_NAME="${OUT_NAME}.exe" + fi + echo "Building for ${{ matrix.goos }}/${{ matrix.goarch }}..." + CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} go build -ldflags "-s -w -X main.serverVersion=${{ github.ref_name }}" -o bin/${OUT_NAME} ./cmd/poc-server + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: poc-server-${{ matrix.goos }}-${{ matrix.goarch }} + path: server/bin/ + + release: + name: Create GitHub Release + needs: build + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Download Artifacts + uses: actions/download-artifact@v4 + with: + path: release-binaries + merge-multiple: true + + - name: Create Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "Creating GitHub Release for tag ${{ github.ref_name }}..." + gh release create "${{ github.ref_name }}" \ + --title "Release ${{ github.ref_name }}" \ + --generate-notes \ + release-binaries/* diff --git a/server/cmd/poc-server/main.go b/server/cmd/poc-server/main.go index fc92185..48f5def 100644 --- a/server/cmd/poc-server/main.go +++ b/server/cmd/poc-server/main.go @@ -62,7 +62,7 @@ import ( "github.com/x0gp/server/internal/transport" ) -const serverVersion = "0.1.0-poc" +var serverVersion = "0.1.0-poc" var ( upgrader = websocket.Upgrader{ @@ -95,9 +95,15 @@ func main() { devMode = flag.Bool("dev", true, "enable development mode") seedRaces = flag.Bool("seed-races", false, "seed mock race data (finished/live/plans) on startup") seedReset = flag.Bool("reset", false, "with --seed-races, wipe seed-managed data before inserting") + version = flag.Bool("version", false, "print version and exit") ) flag.Parse() + if *version { + fmt.Println(serverVersion) + return + } + if *addr != "" { _ = os.Setenv("X0GP_HTTP_ADDR", *addr) } @@ -118,7 +124,7 @@ func main() { } logger := newLogger(cfg.LogLevel) - logger.Info("starting x0gp poc-server", + logger.Info(fmt.Sprintf("starting x0gp poc-server version %s", serverVersion), "version", serverVersion, "addr", cfg.HTTPAddr, "tick_hz", cfg.TickRate, @@ -276,6 +282,7 @@ func main() { mux := http.NewServeMux() mux.HandleFunc("/health", healthHandler(hub, engine)) + mux.HandleFunc("/api/version", versionHandler()) mux.HandleFunc("/stats", statsHandler(hub, engine)) mux.HandleFunc("/api/catalog", catalogHandler(cat)) mux.HandleFunc("/api/tracks", tracksRouter(cat)) @@ -366,6 +373,23 @@ func (h *Hub) BroadcastSnapshot(snap transport.RaceSnapshot) { // HTTP handlers ------------------------------------------------------------ +// versionHandler godoc +// @Summary Get server version +// @Description Returns the current server version. +// @Tags system +// @Produce json +// @Success 200 {object} transport.VersionResponse "Success" +// @Router /api/version [get] +func versionHandler() http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(transport.VersionResponse{ + Version: serverVersion, + }) + } +} + // healthHandler godoc // @Summary Liveness probe // @Description Returns 200 OK with current engine phase, WS connection count and total snapshot drops. Intended for load-balancer health checks. diff --git a/server/cmd/poc-server/races_handlers.go b/server/cmd/poc-server/races_handlers.go index 6b7d31c..beb9033 100644 --- a/server/cmd/poc-server/races_handlers.go +++ b/server/cmd/poc-server/races_handlers.go @@ -485,6 +485,9 @@ func lobbyToWireFinished(m lobby.RaceMeta, podium []races.PodiumEntry) transport DriverID: p.DriverID, Name: p.Name, TotalTimeMs: p.TotalTimeMs, + Nickname: p.Nickname, + AvatarUrl: p.AvatarUrl, + ClanID: p.ClanID, }) } } diff --git a/server/docs/docs.go b/server/docs/docs.go index 4e08b5a..06fcd4c 100644 --- a/server/docs/docs.go +++ b/server/docs/docs.go @@ -1750,6 +1750,26 @@ const docTemplate = `{ } } }, + "/api/version": { + "get": { + "description": "Returns the current server version.", + "produces": [ + "application/json" + ], + "tags": [ + "system" + ], + "summary": "Get server version", + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/transport.VersionResponse" + } + } + } + } + }, "/api/video/control": { "post": { "description": "Sends a text command (e.g. start, stop, left, right) to the ESP32 command port (UDP 8888).", @@ -2552,6 +2572,13 @@ const docTemplate = `{ "transport.RacePodiumEntry": { "type": "object", "properties": { + "avatar_url": { + "type": "string" + }, + "clan_id": { + "type": "string", + "example": "clan-bob" + }, "driver_id": { "type": "string", "example": "driver-alice" @@ -2560,6 +2587,10 @@ const docTemplate = `{ "type": "string", "example": "driver-alice" }, + "nickname": { + "type": "string", + "example": "driver-alice" + }, "position": { "type": "integer", "example": 1 @@ -2851,6 +2882,15 @@ const docTemplate = `{ "type": "number" } } + }, + "transport.VersionResponse": { + "type": "object", + "properties": { + "version": { + "type": "string", + "example": "0.1.0-poc" + } + } } } }` diff --git a/server/docs/swagger.json b/server/docs/swagger.json index ea0b208..4630815 100644 --- a/server/docs/swagger.json +++ b/server/docs/swagger.json @@ -1748,6 +1748,26 @@ } } }, + "/api/version": { + "get": { + "description": "Returns the current server version.", + "produces": [ + "application/json" + ], + "tags": [ + "system" + ], + "summary": "Get server version", + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/transport.VersionResponse" + } + } + } + } + }, "/api/video/control": { "post": { "description": "Sends a text command (e.g. start, stop, left, right) to the ESP32 command port (UDP 8888).", @@ -2550,6 +2570,13 @@ "transport.RacePodiumEntry": { "type": "object", "properties": { + "avatar_url": { + "type": "string" + }, + "clan_id": { + "type": "string", + "example": "clan-bob" + }, "driver_id": { "type": "string", "example": "driver-alice" @@ -2558,6 +2585,10 @@ "type": "string", "example": "driver-alice" }, + "nickname": { + "type": "string", + "example": "driver-alice" + }, "position": { "type": "integer", "example": 1 @@ -2849,6 +2880,15 @@ "type": "number" } } + }, + "transport.VersionResponse": { + "type": "object", + "properties": { + "version": { + "type": "string", + "example": "0.1.0-poc" + } + } } } } \ No newline at end of file diff --git a/server/docs/swagger.yaml b/server/docs/swagger.yaml index c3848c2..32f4148 100644 --- a/server/docs/swagger.yaml +++ b/server/docs/swagger.yaml @@ -478,12 +478,20 @@ definitions: type: object transport.RacePodiumEntry: properties: + avatar_url: + type: string + clan_id: + example: clan-bob + type: string driver_id: example: driver-alice type: string name: example: driver-alice type: string + nickname: + example: driver-alice + type: string position: example: 1 type: integer @@ -677,6 +685,12 @@ definitions: width_m: type: number type: object + transport.VersionResponse: + properties: + version: + example: 0.1.0-poc + type: string + type: object host: localhost:8080 info: contact: @@ -1894,6 +1908,19 @@ paths: summary: Get / update / delete a track by id tags: - tracks + /api/version: + get: + description: Returns the current server version. + produces: + - application/json + responses: + "200": + description: Success + schema: + $ref: '#/definitions/transport.VersionResponse' + summary: Get server version + tags: + - system /api/video/control: post: description: Sends a text command (e.g. start, stop, left, right) to the ESP32 diff --git a/server/internal/races/store.go b/server/internal/races/store.go index 8149471..35c3a5b 100644 --- a/server/internal/races/store.go +++ b/server/internal/races/store.go @@ -18,10 +18,13 @@ import ( // Position (1..3). DriverID/Name are denormalised from the future // drivers table; TotalTimeMs is the driver's total race time. type PodiumEntry struct { - Position int `json:"position" example:"1"` - DriverID string `json:"driver_id" example:"driver-alice"` - Name string `json:"name" example:"driver-alice"` - TotalTimeMs int64 `json:"total_time_ms" example:"123456"` + Position int `json:"position" example:"1"` + DriverID string `json:"driver_id" example:"driver-alice"` + Name string `json:"name" example:"driver-alice"` + TotalTimeMs int64 `json:"total_time_ms" example:"123456"` + Nickname string `json:"nickname" example:"driver-alice"` + AvatarUrl string `json:"avatar_url"` + ClanID string `json:"clan_id" example:"clan-bob"` } // DriverResult is the per-driver outcome of a race. Stored in @@ -31,9 +34,13 @@ type PodiumEntry struct { // fields are derived from this row set on read. type DriverResult struct { DriverID string + Name string TotalTimeMs int64 BestLapMs int64 - Position *int // nil for DNF + Position *int // nil for DNF + Nickname string `json:"nickname" example:"driver-alice"` + AvatarUrl string `json:"avatar_url"` + ClanID string `json:"clan_id" example:"clan-bob"` } // FinishedRace is the on-disk shape of a completed race. Mirrors the @@ -88,10 +95,10 @@ type RacePlan struct { // QueueEntry is a single (driver, race) subscription. type QueueEntry struct { - DriverID string - RaceID string - PlanID string - EnqueuedMs int64 + DriverID string + RaceID string + PlanID string + EnqueuedMs int64 } // PgStore is the Postgres-backed half of the races package. @@ -587,8 +594,8 @@ func (s *PgStore) scanFinished(ctx context.Context, rows pgx.Rows) ([]FinishedRa // and derives the Winner* and Podium fields. func (s *PgStore) hydrateFinished(ctx context.Context, r *FinishedRace) error { rows, err := s.pool.Query(ctx, ` - SELECT driver_id, total_time_ms, best_lap_ms, position - FROM race_drivers + SELECT rd.driver_id, rd.total_time_ms, rd.best_lap_ms, rd.position, d.name, d.nickname, d.avatar_url, d.clan_id + FROM race_drivers rd left join drivers d on rd.driver_id = d.id WHERE race_id = $1 ORDER BY slot ASC, joined_ms ASC`, r.ID) if err != nil { @@ -602,10 +609,28 @@ func (s *PgStore) hydrateFinished(ctx context.Context, r *FinishedRace) error { for rows.Next() { var dr DriverResult var pos *int - if err := rows.Scan(&dr.DriverID, &dr.TotalTimeMs, &dr.BestLapMs, &pos); err != nil { + var name, nick, avatar, clanID *string + if err := rows.Scan( + &dr.DriverID, &dr.TotalTimeMs, &dr.BestLapMs, &pos, + &name, &nick, &avatar, &clanID, + ); err != nil { return err } dr.Position = pos + if name != nil { + dr.Name = *name + } else { + dr.Name = dr.DriverID + } + if nick != nil { + dr.Nickname = *nick + } + if avatar != nil { + dr.AvatarUrl = *avatar + } + if clanID != nil { + dr.ClanID = *clanID + } r.Results = append(r.Results, dr) r.DriverIDs = append(r.DriverIDs, dr.DriverID) } @@ -621,7 +646,7 @@ func (s *PgStore) hydrateFinished(ctx context.Context, r *FinishedRace) error { if bestTotal == -1 || dr.TotalTimeMs < bestTotal { bestTotal = dr.TotalTimeMs r.WinnerDriverID = dr.DriverID - r.WinnerName = dr.DriverID + r.WinnerName = dr.Name } if dr.BestLapMs > 0 && (r.BestLapMs == 0 || dr.BestLapMs < r.BestLapMs) { r.BestLapMs = dr.BestLapMs @@ -629,16 +654,28 @@ func (s *PgStore) hydrateFinished(ctx context.Context, r *FinishedRace) error { } // Derive podium: top-3 drivers by total_time_ms (> 0) ASC. type row struct { - id string - t int64 - best int64 + id string + t int64 + best int64 + name string + nick string + avatar string + clanID string } var sorted []row for _, dr := range r.Results { if dr.TotalTimeMs <= 0 { continue } - sorted = append(sorted, row{dr.DriverID, dr.TotalTimeMs, dr.BestLapMs}) + sorted = append(sorted, row{ + id: dr.DriverID, + t: dr.TotalTimeMs, + best: dr.BestLapMs, + name: dr.Name, + nick: dr.Nickname, + avatar: dr.AvatarUrl, + clanID: dr.ClanID, + }) } for i := 1; i < len(sorted); i++ { for j := i; j > 0 && sorted[j-1].t > sorted[j].t; j-- { @@ -649,8 +686,11 @@ func (s *PgStore) hydrateFinished(ctx context.Context, r *FinishedRace) error { r.Podium = append(r.Podium, PodiumEntry{ Position: i + 1, DriverID: sorted[i].id, - Name: sorted[i].id, + Name: sorted[i].name, TotalTimeMs: sorted[i].t, + Nickname: sorted[i].nick, + AvatarUrl: sorted[i].avatar, + ClanID: sorted[i].clanID, }) } return nil diff --git a/server/internal/transport/codec.go b/server/internal/transport/codec.go index 820a47f..d57ecda 100644 --- a/server/internal/transport/codec.go +++ b/server/internal/transport/codec.go @@ -229,6 +229,14 @@ type RacePodiumEntry struct { DriverID string `json:"driver_id" example:"driver-alice"` Name string `json:"name" example:"driver-alice"` TotalTimeMs int64 `json:"total_time_ms" example:"123456"` + Nickname string `json:"nickname" example:"driver-alice"` + AvatarUrl string `json:"avatar_url"` + ClanID string `json:"clan_id" example:"clan-bob"` +} + +// VersionResponse is the body of the version API. +type VersionResponse struct { + Version string `json:"version" example:"0.1.0-poc"` } // LobbyDriver is the wire form of lobby.DriverMeta.