diff --git a/server/main.go b/server/main.go index f6af221..6f5aee3 100644 --- a/server/main.go +++ b/server/main.go @@ -6,11 +6,12 @@ import ( "encoding/base64" "encoding/json" "errors" - "fmt" + "hash/fnv" "log" "math" "net/http" "os" + "strconv" "strings" "sync" "time" @@ -20,6 +21,27 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) +const ( + worldSize = 3600.0 + spawnX = 1200.0 + spawnY = 1550.0 + playerMaxHull = 100 + playerMaxSpeed = 180.0 + playerReverseSpeed = -55.0 + playerAcceleration = 92.0 + playerBrake = 118.0 + playerTurnRate = 76.0 + playerCoastDrag = 0.992 + broadsideReload = 850 * time.Millisecond + shotSpeed = 520.0 + shotLifetime = 900 * time.Millisecond + shotDamage = 16 + shipHitRadius = 44.0 + respawnDelay = 2200 * time.Millisecond + simTick = time.Second / 30 + snapshotTick = 50 * time.Millisecond +) + type config struct { Port string DatabaseURL string @@ -53,6 +75,7 @@ type ticketStore struct { } 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 { @@ -64,6 +87,7 @@ func (s *ticketStore) issue(subject, name string) (string, error) { s.mu.Unlock() return value, nil } + func (s *ticketStore) consume(value string) (ticket, bool) { s.mu.Lock() defer s.mu.Unlock() @@ -74,6 +98,7 @@ func (s *ticketStore) consume(value string) (ticket, bool) { } return item, true } + func (s *ticketStore) prune() { s.mu.Lock() defer s.mu.Unlock() @@ -86,35 +111,67 @@ func (s *ticketStore) prune() { } 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"` + ID string `json:"id"` + Name string `json:"name"` + X float64 `json:"x"` + Y float64 `json:"y"` + Angle float64 `json:"angle"` + Speed float64 `json:"speed"` + Hull int `json:"hull"` + MaxHull int `json:"maxHull"` + Alive bool `json:"alive"` + Online bool `json:"online"` + Kills int `json:"kills"` + Deaths int `json:"deaths"` + InputSeq int64 `json:"inputSeq"` + RespawnAt int64 `json:"respawnAt,omitempty"` + LastKiller string `json:"lastKiller,omitempty"` +} + +type inputState struct { + Throttle float64 + Steer float64 + Stop bool + Docked bool + Seq int64 +} + +type projectileState struct { + ID string `json:"id"` + OwnerID string `json:"ownerId"` + X float64 `json:"x"` + Y float64 `json:"y"` + VX float64 `json:"vx"` + VY float64 `json:"vy"` + Damage int `json:"damage"` + bornAt time.Time } type clientMessage struct { - Type string `json:"type"` - X float64 `json:"x"` - Y float64 `json:"y"` - Angle float64 `json:"angle"` - Hull int `json:"hull"` + Type string `json:"type"` + Seq int64 `json:"seq"` + Throttle float64 `json:"throttle"` + Steer float64 `json:"steer"` + Stop bool `json:"stop"` + Docked bool `json:"docked"` + Side string `json:"side"` } 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 string `json:"type"` + PlayerID string `json:"playerId,omitempty"` + WorldSeed string `json:"worldSeed,omitempty"` + Players []playerState `json:"players,omitempty"` + Projectiles []projectileState `json:"projectiles,omitempty"` + ServerTime int64 `json:"serverTime,omitempty"` } type client struct { - conn *websocket.Conn - writeMu sync.Mutex - state playerState + conn *websocket.Conn + writeMu sync.Mutex + state playerState + input inputState + lastFire map[string]time.Time } func (c *client) writeJSON(v any) error { @@ -125,12 +182,39 @@ func (c *client) writeJSON(v any) error { } type hub struct { - mu sync.RWMutex - clients map[string]*client - worldSeed string + mu sync.RWMutex + clients map[string]*client + projectiles map[string]projectileState + nextProjectile uint64 + worldSeed string +} + +func newHub(seed string) *hub { + return &hub{ + clients: map[string]*client{}, + projectiles: map[string]projectileState{}, + worldSeed: seed, + } +} + +func newPlayerState(id, name string, kills, deaths int) playerState { + x, y := spawnFor(id) + return playerState{ + ID: id, Name: name, X: x, Y: y, Angle: 0, Speed: 0, + Hull: playerMaxHull, MaxHull: playerMaxHull, Alive: true, Online: true, + Kills: kills, Deaths: deaths, + } +} + +func spawnFor(id string) (float64, float64) { + h := fnv.New32a() + _, _ = h.Write([]byte(id)) + v := h.Sum32() + dx := float64(int(v%241) - 120) + dy := float64(int((v/241)%181) - 90) + return clampFloat(spawnX+dx, 90, worldSize-90), clampFloat(spawnY+dy, 90, worldSize-90) } -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 { @@ -139,6 +223,7 @@ func (h *hub) add(c *client) { 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 { @@ -146,37 +231,208 @@ func (h *hub) remove(id string, c *client) { } 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) count() int { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.clients) } -func (h *hub) snapshot() ([]*client, []playerState) { + +func (h *hub) updateInput(id string, msg clientMessage) { + h.mu.Lock() + defer h.mu.Unlock() + c := h.clients[id] + if c == nil || msg.Seq <= c.input.Seq { + return + } + c.input = inputState{ + Throttle: clampFloat(msg.Throttle, -1, 1), + Steer: clampFloat(msg.Steer, -1, 1), + Stop: msg.Stop, + Docked: msg.Docked, + Seq: msg.Seq, + } +} + +func (h *hub) fire(id, side string, now time.Time) bool { + h.mu.Lock() + defer h.mu.Unlock() + c := h.clients[id] + if c == nil || !c.state.Alive || (side != "port" && side != "starboard") { + return false + } + if c.lastFire == nil { + c.lastFire = map[string]time.Time{} + } + if last := c.lastFire[side]; !last.IsZero() && now.Sub(last) < broadsideReload { + return false + } + c.lastFire[side] = now + + sideSign := 1.0 + if side == "port" { + sideSign = -1 + } + base := headingRadians(c.state.Angle) + sideSign*math.Pi/2 + shipVX := math.Cos(headingRadians(c.state.Angle)) * c.state.Speed + shipVY := math.Sin(headingRadians(c.state.Angle)) * c.state.Speed + + for _, offset := range []float64{-7, 0, 7} { + angle := base + offset*math.Pi/180 + h.nextProjectile++ + projectileID := id + ":" + strconv.FormatUint(h.nextProjectile, 10) + h.projectiles[projectileID] = projectileState{ + ID: projectileID, OwnerID: id, + X: c.state.X + math.Cos(base)*34, + Y: c.state.Y + math.Sin(base)*34, + VX: math.Cos(angle)*shotSpeed + shipVX*0.25, + VY: math.Sin(angle)*shotSpeed + shipVY*0.25, + Damage: shotDamage, + bornAt: now, + } + } + return true +} + +func (h *hub) step(now time.Time, dt float64) { + h.mu.Lock() + defer h.mu.Unlock() + + for _, c := range h.clients { + stepPlayer(&c.state, c.input, now, dt) + } + + for id, shot := range h.projectiles { + if now.Sub(shot.bornAt) >= shotLifetime { + delete(h.projectiles, id) + continue + } + shot.X += shot.VX * dt + shot.Y += shot.VY * dt + if shot.X < -80 || shot.X > worldSize+80 || shot.Y < -80 || shot.Y > worldSize+80 { + delete(h.projectiles, id) + continue + } + + hit := false + for _, target := range h.clients { + if target.state.ID == shot.OwnerID || !target.state.Alive { + continue + } + dx := target.state.X - shot.X + dy := target.state.Y - shot.Y + if dx*dx+dy*dy > shipHitRadius*shipHitRadius { + continue + } + target.state.Hull = clampInt(target.state.Hull-shot.Damage, 0, target.state.MaxHull) + if target.state.Hull <= 0 { + target.state.Alive = false + target.state.Speed = 0 + target.state.Deaths++ + target.state.RespawnAt = now.Add(respawnDelay).UnixMilli() + if owner := h.clients[shot.OwnerID]; owner != nil { + owner.state.Kills++ + target.state.LastKiller = owner.state.Name + } + } + delete(h.projectiles, id) + hit = true + break + } + if hit { + continue + } + h.projectiles[id] = shot + } +} + +func stepPlayer(state *playerState, input inputState, now time.Time, dt float64) { + state.InputSeq = input.Seq + if !state.Alive { + if state.RespawnAt > 0 && now.UnixMilli() >= state.RespawnAt { + x, y := spawnFor(state.ID) + state.X = x + state.Y = y + state.Angle = 0 + state.Speed = 0 + state.Hull = state.MaxHull + state.Alive = true + state.RespawnAt = 0 + state.LastKiller = "" + } + return + } + if input.Docked { + state.Speed = 0 + return + } + + if input.Throttle > 0 { + state.Speed += playerAcceleration * input.Throttle * dt + } else if input.Throttle < 0 { + state.Speed += playerBrake * input.Throttle * dt + } else if !input.Stop { + state.Speed *= math.Pow(playerCoastDrag, dt*60) + } + if input.Stop { + state.Speed *= math.Pow(0.025, dt) + } + state.Speed = clampFloat(state.Speed, playerReverseSpeed, playerMaxSpeed) + + turnStrength := clampFloat(math.Abs(state.Speed)/100, 0.28, 1) + directionSign := 1.0 + if state.Speed < 0 { + directionSign = -1 + } + state.Angle = normalizeAngle(state.Angle + playerTurnRate*turnStrength*directionSign*input.Steer*dt) + + heading := headingRadians(state.Angle) + state.X = clampFloat(state.X+math.Cos(heading)*state.Speed*dt, 0, worldSize) + state.Y = clampFloat(state.Y+math.Sin(heading)*state.Speed*dt, 0, worldSize) +} + +func headingRadians(angle float64) float64 { return (angle - 90) * math.Pi / 180 } + +func (h *hub) snapshot() ([]*client, []playerState, []projectileState) { h.mu.RLock() defer h.mu.RUnlock() clients := make([]*client, 0, len(h.clients)) players := make([]playerState, 0, len(h.clients)) + projectiles := make([]projectileState, 0, len(h.projectiles)) for _, c := range h.clients { clients = append(clients, c) players = append(players, c.state) } - return clients, players + for _, shot := range h.projectiles { + projectiles = append(projectiles, shot) + } + return clients, players, projectiles } + func (h *hub) run(ctx context.Context) { - ticker := time.NewTicker(100 * time.Millisecond) - defer ticker.Stop() + simTicker := time.NewTicker(simTick) + snapshotTicker := time.NewTicker(snapshotTick) + defer simTicker.Stop() + defer snapshotTicker.Stop() + lastSim := time.Now() + for { select { case <-ctx.Done(): return - case now := <-ticker.C: - clients, players := h.snapshot() - msg := serverMessage{Type: "snapshot", Players: players, ServerTime: now.UnixMilli()} + case now := <-simTicker.C: + dt := now.Sub(lastSim).Seconds() + if dt <= 0 || dt > 0.1 { + dt = simTick.Seconds() + } + lastSim = now + h.step(now, dt) + case now := <-snapshotTicker.C: + clients, players, projectiles := h.snapshot() + msg := serverMessage{ + Type: "snapshot", Players: players, Projectiles: projectiles, + ServerTime: now.UnixMilli(), + } for _, c := range clients { if err := c.writeJSON(msg); err != nil { _ = c.conn.Close() @@ -198,6 +454,7 @@ func clampFloat(v, min, max float64) float64 { } return v } + func clampInt(v, min, max int) int { if v < min { return min @@ -207,6 +464,7 @@ func clampInt(v, min, max int) int { } return v } + func normalizeAngle(v float64) float64 { if math.IsNaN(v) || math.IsInf(v, 0) { return 0 @@ -236,6 +494,7 @@ func loadConfig() (config, error) { } return cfg, nil } + func env(key, fallback string) string { if v := os.Getenv(key); v != "" { return v @@ -251,13 +510,18 @@ func migrate(ctx context.Context, db *pgxpool.Pool) error { credits INTEGER NOT NULL DEFAULT 650, scrap INTEGER NOT NULL DEFAULT 0, progression JSONB NOT NULL DEFAULT '{}'::jsonb, + pvp_kills INTEGER NOT NULL DEFAULT 0, + pvp_deaths INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now() - ) + ); + ALTER TABLE player_profiles ADD COLUMN IF NOT EXISTS pvp_kills INTEGER NOT NULL DEFAULT 0; + ALTER TABLE player_profiles ADD COLUMN IF NOT EXISTS pvp_deaths INTEGER NOT NULL DEFAULT 0 `) 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) @@ -270,6 +534,24 @@ func ensurePlayer(ctx context.Context, db *pgxpool.Pool, sub, name string) error return err } +func loadPvPStats(ctx context.Context, db *pgxpool.Pool, sub string) (int, int, error) { + var kills, deaths int + err := db.QueryRow(ctx, `SELECT pvp_kills, pvp_deaths FROM player_profiles WHERE keycloak_sub = $1`, sub).Scan(&kills, &deaths) + return kills, deaths, err +} + +func savePvPStats(ctx context.Context, db *pgxpool.Pool, state playerState) error { + _, err := db.Exec(ctx, ` + UPDATE player_profiles SET + pvp_kills = $2, + pvp_deaths = $3, + updated_at = now(), + last_seen_at = now() + WHERE keycloak_sub = $1 + `, state.ID, state.Kills, state.Deaths) + return err +} + type app struct { cfg config db *pgxpool.Pool @@ -314,6 +596,7 @@ func (a *app) authenticate(next http.Handler) http.Handler { next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), claimsKey, c))) }) } + func audienceMatches(c claims, clientID string) bool { if c.AuthorizedParty == clientID { return true @@ -330,6 +613,7 @@ func audienceMatches(c claims, clientID string) bool { } 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") @@ -346,11 +630,13 @@ func (a *app) cors(next http.Handler) http.Handler { 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() @@ -358,12 +644,17 @@ func (a *app) health(w http.ResponseWriter, r *http.Request) { 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}) + jsonResponse(w, 200, map[string]any{ + "ok": true, "database": true, "players": a.hub.count(), + "worldSeed": a.cfg.WorldSeed, "authority": "movement+pvp", + }) } + 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) @@ -377,6 +668,7 @@ func (a *app) wsTicket(w http.ResponseWriter, r *http.Request) { } 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) @@ -388,7 +680,16 @@ func (a *app) ws(w http.ResponseWriter, r *http.Request) { 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}} + + kills, deaths, err := loadPvPStats(r.Context(), a.db, t.Subject) + if err != nil { + kills, deaths = 0, 0 + } + c := &client{ + conn: conn, + state: newPlayerState(t.Subject, t.Name, kills, deaths), + lastFire: map[string]time.Time{}, + } a.hub.add(c) defer func() { a.hub.remove(t.Subject, c) @@ -396,8 +697,13 @@ func (a *app) ws(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() _ = ensurePlayer(ctx, a.db, t.Subject, t.Name) + _ = savePvPStats(ctx, a.db, c.state) }() - _ = c.writeJSON(serverMessage{Type: "welcome", PlayerID: t.Subject, WorldSeed: a.cfg.WorldSeed, ServerTime: time.Now().UnixMilli()}) + + _ = 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)) @@ -405,8 +711,11 @@ func (a *app) ws(w http.ResponseWriter, r *http.Request) { if err := conn.ReadJSON(&msg); err != nil { return } - if msg.Type == "state" { - a.hub.update(t.Subject, msg) + switch msg.Type { + case "input": + a.hub.updateInput(t.Subject, msg) + case "fire": + a.hub.fire(t.Subject, strings.ToLower(msg.Side), time.Now()) } } } @@ -433,7 +742,10 @@ func main() { 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 := &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] @@ -446,14 +758,16 @@ func main() { 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) + 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 authority=movement+pvp", cfg.Port, cfg.WorldSeed) log.Fatal(server.ListenAndServe()) } - -func init() { _ = fmt.Sprintf }