774 lines
20 KiB
Go
774 lines
20 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"hash/fnv"
|
|
"log"
|
|
"math"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/coreos/go-oidc/v3/oidc"
|
|
"github.com/gorilla/websocket"
|
|
"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
|
|
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"`
|
|
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"`
|
|
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"`
|
|
Projectiles []projectileState `json:"projectiles,omitempty"`
|
|
ServerTime int64 `json:"serverTime,omitempty"`
|
|
}
|
|
|
|
type client struct {
|
|
conn *websocket.Conn
|
|
writeMu sync.Mutex
|
|
state playerState
|
|
input inputState
|
|
lastFire map[string]time.Time
|
|
}
|
|
|
|
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
|
|
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 (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) count() int {
|
|
h.mu.RLock()
|
|
defer h.mu.RUnlock()
|
|
return len(h.clients)
|
|
}
|
|
|
|
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)
|
|
}
|
|
for _, shot := range h.projectiles {
|
|
projectiles = append(projectiles, shot)
|
|
}
|
|
return clients, players, projectiles
|
|
}
|
|
|
|
func (h *hub) run(ctx context.Context) {
|
|
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 := <-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()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
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)
|
|
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
|
|
}
|
|
|
|
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
|
|
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": 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)
|
|
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
|
|
}
|
|
|
|
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)
|
|
_ = conn.Close()
|
|
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(),
|
|
})
|
|
conn.SetReadLimit(4096)
|
|
for {
|
|
_ = conn.SetReadDeadline(time.Now().Add(45 * time.Second))
|
|
var msg clientMessage
|
|
if err := conn.ReadJSON(&msg); err != nil {
|
|
return
|
|
}
|
|
switch msg.Type {
|
|
case "input":
|
|
a.hub.updateInput(t.Subject, msg)
|
|
case "fire":
|
|
a.hub.fire(t.Subject, strings.ToLower(msg.Side), time.Now())
|
|
}
|
|
}
|
|
}
|
|
|
|
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 authority=movement+pvp", cfg.Port, cfg.WorldSeed)
|
|
log.Fatal(server.ListenAndServe())
|
|
}
|