mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-16 20:47:55 +00:00
feat(server): initial implementation
This commit captures the full server code accumulated across
several development sessions. It includes the following layers,
applied in roughly this order during development:
1. Base infrastructure
- cmd/poc-server HTTP+WebSocket server, /health, /stats
- internal/transport wire types (Envelope + all message types)
- internal/catalog, internal/realtime, internal/stats
- internal/storage/postgres with migrations 001-004
- .env, .env.example, docker-compose, Dockerfile, scripts/
2. Swagger documentation
- go get github.com/swaggo/http-swagger
- swag init produces docs/docs.go
- /swagger/index.html UI, /swagger/doc.json spec
- annotations on health/stats/ws and catalog handlers
3. Races API
- internal/races/{store,service,keyset,types}.go
- migration 005_races.sql: finished_races, race_plans, race_queue
- GET /api/races with keyset pagination, status filter
- GET /api/races/upcoming
- POST /api/races/queue/join, GET, DELETE
- POST/GET /api/races/plans, DELETE /{id}
- lobby.RaceMeta simplification (no host_id/host_name)
4. Races seeder
- internal/races/seed with deterministic generator
- --seed-races / --reset CLI flags in main.go
- 30 finished + 5 live + 5 plans + 4 queue entries
5. Drivers and clans
- internal/drivers, internal/clans (CRUD, validation)
- migration 008_drivers_clans.sql
- /api/clans and /api/drivers endpoints
- 3-letter uppercase nickname/tag validation
- lobby.DriverMeta: Nickname, AvatarURL, ClanID, ClanTag
6. Podium
- internal/transport.RacePodiumEntry
- lobby.SetDriverProfile for in-memory metadata sync
- migration 007_podium.sql (podium JSONB column)
- seeder populates top-3 per finished race
7. Live races persistence
- migration 009_live_persistence.sql: live_races,
live_race_drivers, lobby_drivers
- internal/races/live_store.go: LiveStore with write-side
mirror for lobby.Service mutations
- Service.collectLive and Upcoming read from Postgres
- main.go RestoreFromDB rehydrates lobby on startup
8. Unify live + finished into one races table
- migration 010_unify_races.sql: rename finished_races to
races, expand status CHECK, merge live rows
- PgStore now hosts both write paths; LiveStore is a
thin facade implementing lobby.Persistence
- seeder resetAll drops only finished/cancelled rows and
race_plans / race_queue / lobby_drivers
Each layer is consistent on its own; cross-layer changes are
visible in the file history. A future refactor may split this
commit into the per-stage boundaries listed above.
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
// Package config loads runtime configuration from environment variables.
|
||||
//
|
||||
// On startup Load() reads a .env file (if present) from the current
|
||||
// working directory and merges its values into the process environment.
|
||||
// Existing process env vars take precedence over .env so deployments
|
||||
// can override .env without modifying the file.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// Config holds all runtime parameters.
|
||||
type Config struct {
|
||||
HTTPAddr string // Address to listen on, e.g. ":8080"
|
||||
TickRate int // Race tick rate (Hz)
|
||||
TickInterval time.Duration // Derived from TickRate
|
||||
LogLevel string // "debug" | "info" | "warn" | "error"
|
||||
MaxClients int // Maximum concurrent WS clients
|
||||
SnapshotRate int // Snapshot fan-out rate (Hz)
|
||||
DevMode bool // If true, allow multiple clients without auth
|
||||
|
||||
DatabaseURL string // Postgres connection string; required at runtime
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables, applying defaults.
|
||||
//
|
||||
// A .env file in the current working directory (or any path listed in
|
||||
// X0GP_DOTENV) is loaded before reading the environment. Missing .env
|
||||
// is not an error — production deployments set vars directly.
|
||||
func Load() (*Config, error) {
|
||||
loadDotEnv()
|
||||
|
||||
c := &Config{
|
||||
HTTPAddr: getEnv("X0GP_HTTP_ADDR", ":8080"),
|
||||
TickRate: getEnvInt("X0GP_TICK_RATE", 60),
|
||||
LogLevel: getEnv("X0GP_LOG_LEVEL", "info"),
|
||||
MaxClients: getEnvInt("X0GP_MAX_CLIENTS", 100),
|
||||
SnapshotRate: getEnvInt("X0GP_SNAPSHOT_RATE", 30),
|
||||
DevMode: getEnvBool("X0GP_DEV_MODE", true),
|
||||
DatabaseURL: getEnv("DATABASE_URL", ""),
|
||||
}
|
||||
|
||||
if c.TickRate <= 0 || c.TickRate > 240 {
|
||||
return nil, fmt.Errorf("X0GP_TICK_RATE must be in (0, 240], got %d", c.TickRate)
|
||||
}
|
||||
c.TickInterval = time.Second / time.Duration(c.TickRate)
|
||||
|
||||
if c.SnapshotRate <= 0 || c.SnapshotRate > c.TickRate {
|
||||
return nil, fmt.Errorf("X0GP_SNAPSHOT_RATE must be in (0, %d], got %d",
|
||||
c.TickRate, c.SnapshotRate)
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// loadDotEnv merges a .env file into the process environment. Files
|
||||
// that do not exist are silently ignored. Paths can be overridden with
|
||||
// X0GP_DOTENV (colon-separated). Existing process env vars take
|
||||
// precedence over .env values so deploys can override without
|
||||
// editing the file (godotenv.Load, not Overload).
|
||||
func loadDotEnv() {
|
||||
if paths := os.Getenv("X0GP_DOTENV"); paths != "" {
|
||||
for _, p := range splitPaths(paths) {
|
||||
_ = godotenv.Load(p)
|
||||
}
|
||||
return
|
||||
}
|
||||
_ = godotenv.Load(".env")
|
||||
}
|
||||
|
||||
func splitPaths(s string) []string {
|
||||
out := make([]string, 0, 4)
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == ':' || s[i] == ';' {
|
||||
if i > start {
|
||||
out = append(out, s[start:i])
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(s) {
|
||||
out = append(out, s[start:])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func getEnvInt(key string, fallback int) int {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvBool(key string, fallback bool) bool {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
if b, err := strconv.ParseBool(v); err == nil {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
Reference in New Issue
Block a user