Add multiplayer server main.go

This commit is contained in:
Admin 2026-09-21 01:30:42 +00:00
parent 3621fb1944
commit bce929bb69

459
server/main.go Normal file
View File

@ -0,0 +1,459 @@
package main
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log"
"math"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gorilla/websocket"
"github.com/jackc/pgx/v5/pgxpool"
)
type config struct {
Port string
DatabaseURL string
Issuer string
ClientID string
AllowedOrigins map[string]bool
WorldSeed string
}
type claims struct {
Subject string `json:"sub"`
PreferredUsername string `json:"preferred_username"`
Name string `json:"name"`
AuthorizedParty string `json:"azp"`
Audience interface{} `json:"aud"`
}
type contextKey string
const claimsKey contextKey = "claims"
type ticket struct {
Subject string
Name string
Expires time.Time
}
type ticketStore struct {
mu sync.Mutex
items map[string]ticket
}
func newTicketStore() *ticketStore { return &ticketStore{items: map[string]ticket{}} }
func (s *ticketStore) issue(subject, name string) (string, error) {
buf := make([]byte, 24)
if _, err := rand.Read(buf); err != nil {
return "", err
}
value := base64.RawURLEncoding.EncodeToString(buf)
s.mu.Lock()
s.items[value] = ticket{Subject: subject, Name: name, Expires: time.Now().Add(30 * time.Second)}
s.mu.Unlock()
return value, nil
}
func (s *ticketStore) consume(value string) (ticket, bool) {
s.mu.Lock()
defer s.mu.Unlock()
item, ok := s.items[value]
delete(s.items, value)
if !ok || time.Now().After(item.Expires) {
return ticket{}, false
}
return item, true
}
func (s *ticketStore) prune() {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
for key, item := range s.items {
if now.After(item.Expires) {
delete(s.items, key)
}
}
}
type playerState struct {
ID string `json:"id"`
Name string `json:"name"`
X float64 `json:"x"`
Y float64 `json:"y"`
Angle float64 `json:"angle"`
Hull int `json:"hull"`
Online bool `json:"online"`
}
type clientMessage struct {
Type string `json:"type"`
X float64 `json:"x"`
Y float64 `json:"y"`
Angle float64 `json:"angle"`
Hull int `json:"hull"`
}
type serverMessage struct {
Type string `json:"type"`
PlayerID string `json:"playerId,omitempty"`
WorldSeed string `json:"worldSeed,omitempty"`
Players []playerState `json:"players,omitempty"`
ServerTime int64 `json:"serverTime,omitempty"`
}
type client struct {
conn *websocket.Conn
writeMu sync.Mutex
state playerState
}
func (c *client) writeJSON(v any) error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
_ = c.conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
return c.conn.WriteJSON(v)
}
type hub struct {
mu sync.RWMutex
clients map[string]*client
worldSeed string
}
func newHub(seed string) *hub { return &hub{clients: map[string]*client{}, worldSeed: seed} }
func (h *hub) add(c *client) {
h.mu.Lock()
if previous := h.clients[c.state.ID]; previous != nil && previous != c {
_ = previous.conn.Close()
}
h.clients[c.state.ID] = c
h.mu.Unlock()
}
func (h *hub) remove(id string, c *client) {
h.mu.Lock()
if h.clients[id] == c {
delete(h.clients, id)
}
h.mu.Unlock()
}
func (h *hub) update(id string, msg clientMessage) {
h.mu.Lock()
if c := h.clients[id]; c != nil {
c.state.X = clampFloat(msg.X, 0, 3600)
c.state.Y = clampFloat(msg.Y, 0, 3600)
c.state.Angle = normalizeAngle(msg.Angle)
c.state.Hull = clampInt(msg.Hull, 0, 500)
}
h.mu.Unlock()
}
func (h *hub) snapshot() ([]*client, []playerState) {
h.mu.RLock()
defer h.mu.RUnlock()
clients := make([]*client, 0, len(h.clients))
players := make([]playerState, 0, len(h.clients))
for _, c := range h.clients {
clients = append(clients, c)
players = append(players, c.state)
}
return clients, players
}
func (h *hub) run(ctx context.Context) {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-ticker.C:
clients, players := h.snapshot()
msg := serverMessage{Type: "snapshot", Players: players, ServerTime: now.UnixMilli()}
for _, c := range clients {
if err := c.writeJSON(msg); err != nil {
_ = c.conn.Close()
}
}
}
}
}
func clampFloat(v, min, max float64) float64 {
if math.IsNaN(v) || math.IsInf(v, 0) {
return min
}
if v < min {
return min
}
if v > max {
return max
}
return v
}
func clampInt(v, min, max int) int {
if v < min {
return min
}
if v > max {
return max
}
return v
}
func normalizeAngle(v float64) float64 {
if math.IsNaN(v) || math.IsInf(v, 0) {
return 0
}
v = math.Mod(v, 360)
if v < 0 {
v += 360
}
return v
}
func loadConfig() (config, error) {
origins := map[string]bool{}
for _, origin := range strings.Split(env("ALLOWED_ORIGINS", "https://airship.arrowstack.dev"), ",") {
origin = strings.TrimSpace(origin)
if origin != "" {
origins[origin] = true
}
}
cfg := config{
Port: env("PORT", "8080"), DatabaseURL: os.Getenv("DATABASE_URL"),
Issuer: os.Getenv("KEYCLOAK_ISSUER"), ClientID: os.Getenv("KEYCLOAK_CLIENT_ID"),
AllowedOrigins: origins, WorldSeed: env("WORLD_SEED", "SKYWARD-001"),
}
if cfg.DatabaseURL == "" || cfg.Issuer == "" || cfg.ClientID == "" {
return cfg, errors.New("DATABASE_URL, KEYCLOAK_ISSUER and KEYCLOAK_CLIENT_ID are required")
}
return cfg, nil
}
func env(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func migrate(ctx context.Context, db *pgxpool.Pool) error {
_, err := db.Exec(ctx, `
CREATE TABLE IF NOT EXISTS player_profiles (
keycloak_sub TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
credits INTEGER NOT NULL DEFAULT 650,
scrap INTEGER NOT NULL DEFAULT 0,
progression JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`)
return err
}
func ensurePlayer(ctx context.Context, db *pgxpool.Pool, sub, name string) error {
_, err := db.Exec(ctx, `
INSERT INTO player_profiles (keycloak_sub, display_name)
VALUES ($1, $2)
ON CONFLICT (keycloak_sub) DO UPDATE SET
display_name = EXCLUDED.display_name,
updated_at = now(),
last_seen_at = now()
`, sub, name)
return err
}
type app struct {
cfg config
db *pgxpool.Pool
verifier *oidc.IDTokenVerifier
tickets *ticketStore
hub *hub
upgrader websocket.Upgrader
}
func (a *app) authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
raw := strings.TrimSpace(strings.TrimPrefix(auth, "Bearer "))
token, err := a.verifier.Verify(r.Context(), raw)
if err != nil {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
var c claims
if err := token.Claims(&c); err != nil || c.Subject == "" {
http.Error(w, "invalid claims", http.StatusUnauthorized)
return
}
if !audienceMatches(c, a.cfg.ClientID) {
http.Error(w, "wrong client", http.StatusForbidden)
return
}
if c.PreferredUsername == "" {
c.PreferredUsername = c.Name
}
if c.PreferredUsername == "" {
c.PreferredUsername = "Captain"
}
if err := ensurePlayer(r.Context(), a.db, c.Subject, c.PreferredUsername); err != nil {
http.Error(w, "database unavailable", http.StatusServiceUnavailable)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), claimsKey, c)))
})
}
func audienceMatches(c claims, clientID string) bool {
if c.AuthorizedParty == clientID {
return true
}
switch aud := c.Audience.(type) {
case string:
return aud == clientID
case []interface{}:
for _, item := range aud {
if s, ok := item.(string); ok && s == clientID {
return true
}
}
}
return false
}
func (a *app) cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && a.cfg.AllowedOrigins[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func jsonResponse(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func (a *app) health(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), time.Second)
defer cancel()
if err := a.db.Ping(ctx); err != nil {
jsonResponse(w, 503, map[string]any{"ok": false, "database": false})
return
}
jsonResponse(w, 200, map[string]any{"ok": true, "database": true, "players": len(a.hub.clients), "worldSeed": a.cfg.WorldSeed})
}
func (a *app) me(w http.ResponseWriter, r *http.Request) {
c := r.Context().Value(claimsKey).(claims)
jsonResponse(w, 200, map[string]any{"id": c.Subject, "name": c.PreferredUsername, "worldSeed": a.cfg.WorldSeed})
}
func (a *app) wsTicket(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
c := r.Context().Value(claimsKey).(claims)
value, err := a.tickets.issue(c.Subject, c.PreferredUsername)
if err != nil {
http.Error(w, "ticket unavailable", http.StatusInternalServerError)
return
}
jsonResponse(w, 201, map[string]any{"ticket": value, "expiresIn": 30})
}
func (a *app) ws(w http.ResponseWriter, r *http.Request) {
value := r.URL.Query().Get("ticket")
t, ok := a.tickets.consume(value)
if !ok {
http.Error(w, "invalid websocket ticket", http.StatusUnauthorized)
return
}
conn, err := a.upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
c := &client{conn: conn, state: playerState{ID: t.Subject, Name: t.Name, X: 1200, Y: 1550, Angle: 0, Hull: 100, Online: true}}
a.hub.add(c)
defer func() {
a.hub.remove(t.Subject, c)
_ = conn.Close()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = ensurePlayer(ctx, a.db, t.Subject, t.Name)
}()
_ = c.writeJSON(serverMessage{Type: "welcome", PlayerID: t.Subject, WorldSeed: a.cfg.WorldSeed, ServerTime: time.Now().UnixMilli()})
conn.SetReadLimit(4096)
for {
_ = conn.SetReadDeadline(time.Now().Add(45 * time.Second))
var msg clientMessage
if err := conn.ReadJSON(&msg); err != nil {
return
}
if msg.Type == "state" {
a.hub.update(t.Subject, msg)
}
}
}
func main() {
cfg, err := loadConfig()
if err != nil {
log.Fatal(err)
}
ctx := context.Background()
db, err := pgxpool.New(ctx, cfg.DatabaseURL)
if err != nil {
log.Fatal(err)
}
defer db.Close()
if err := db.Ping(ctx); err != nil {
log.Fatalf("database ping: %v", err)
}
if err := migrate(ctx, db); err != nil {
log.Fatalf("migration: %v", err)
}
provider, err := oidc.NewProvider(ctx, cfg.Issuer)
if err != nil {
log.Fatalf("oidc provider: %v", err)
}
verifier := provider.Verifier(&oidc.Config{SkipClientIDCheck: true})
application := &app{cfg: cfg, db: db, verifier: verifier, tickets: newTicketStore(), hub: newHub(cfg.WorldSeed)}
application.upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
return origin == "" || cfg.AllowedOrigins[origin]
}}
go application.hub.run(ctx)
go func() {
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for range ticker.C {
application.tickets.prune()
}
}()
mux := http.NewServeMux()
mux.HandleFunc("/health", application.health)
mux.Handle("/api/me", application.cors(application.authenticate(http.HandlerFunc(application.me))))
mux.Handle("/api/ws-ticket", application.cors(application.authenticate(http.HandlerFunc(application.wsTicket))))
mux.HandleFunc("/ws", application.ws)
server := &http.Server{Addr: ":" + cfg.Port, Handler: mux, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second}
log.Printf("airship game server listening on :%s world=%s", cfg.Port, cfg.WorldSeed)
log.Fatal(server.ListenAndServe())
}
func init() { _ = fmt.Sprintf }