1117 lines
29 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 = 5400.0
spawnX = 1800.0
spawnY = 2325.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 = 34.0
respawnDelay = 2200 * time.Millisecond
simTick = time.Second / 30
snapshotTick = 50 * time.Millisecond
windPatternMS = 16000.0
windMinStrength = 7.0
windMaxStrength = 24.0
routeMinCoreRadius = 220.0
routeEdgeDepth = 320.0
stormFrontPeriodMS = 110000.0
stormFrontRadius = 720.0
)
type config struct {
Port string
DatabaseURL string
Issuer string
ClientID string
AllowedOrigins map[string]bool
WorldSeed string
FakePlayers int
}
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"`
Bot bool `json:"bot,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 weatherRoute struct {
ID string `json:"id"`
FromX float64 `json:"fromX"`
FromY float64 `json:"fromY"`
ToX float64 `json:"toX"`
ToY float64 `json:"toY"`
Width float64 `json:"width"`
}
type weatherWind struct {
X float64
Y float64
Strength float64
RouteInfluence float64
RouteEdge float64
StormIntensity float64
}
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"`
Routes []weatherRoute `json:"routes,omitempty"`
}
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
botIndex int
}
func (c *client) writeJSON(v any) error {
if c.conn == nil {
return nil
}
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
weatherRoutes []weatherRoute
}
func newHub(seed string, fakePlayers ...int) *hub {
h := &hub{
clients: map[string]*client{},
projectiles: map[string]projectileState{},
worldSeed: seed,
}
count := 0
if len(fakePlayers) > 0 {
count = clampInt(fakePlayers[0], 0, 8)
}
h.addFakeCaptains(count)
return h
}
var fakeCaptainNames = []string{
"Mara Voss",
"Brass Jack",
"Finn Gale",
"Iris Vane",
"Rowan Pike",
"Ada Flint",
"Silas Crow",
"Nell Mercer",
}
func (h *hub) addFakeCaptains(count int) {
if count <= 0 {
return
}
for index := 0; index < count; index++ {
name := fakeCaptainNames[index%len(fakeCaptainNames)]
id := "bot:" + strings.ToLower(strings.ReplaceAll(name, " ", "-"))
state := newPlayerState(id, name, 0, 0)
phase := math.Pi * 2 * float64(index) / float64(count)
state.X = clampFloat(spawnX+math.Cos(phase)*650, 90, worldSize-90)
state.Y = clampFloat(spawnY+math.Sin(phase)*650, 90, worldSize-90)
state.Angle = normalizeAngle(phase*180/math.Pi + 90)
state.Bot = true
h.clients[id] = &client{
state: state, botIndex: index,
lastFire: map[string]time.Time{},
}
}
}
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) setWeatherRoutes(routes []weatherRoute) {
h.mu.Lock()
defer h.mu.Unlock()
if len(h.weatherRoutes) > 0 || len(routes) == 0 {
return
}
count := len(routes)
if count > 24 {
count = 24
}
h.weatherRoutes = make([]weatherRoute, 0, count)
for _, route := range routes[:count] {
h.weatherRoutes = append(h.weatherRoutes, weatherRoute{
ID: strings.TrimSpace(route.ID),
FromX: clampFloat(route.FromX, 0, worldSize),
FromY: clampFloat(route.FromY, 0, worldSize),
ToX: clampFloat(route.ToX, 0, worldSize),
ToY: clampFloat(route.ToY, 0, worldSize),
Width: clampFloat(route.Width, 120, 320),
})
}
}
func smootherStep01(value float64) float64 {
t := clampFloat(value, 0, 1)
return t * t * t * (t*(t*6-15) + 10)
}
func hash01(index float64, salt float64) float64 {
value := math.Sin(index*12.9898+salt*78.233) * 43758.5453123
return value - math.Floor(value)
}
func shortestAngleDeltaRad(from, to float64) float64 {
delta := math.Mod(to-from, math.Pi*2)
if delta > math.Pi {
delta -= math.Pi * 2
}
if delta < -math.Pi {
delta += math.Pi * 2
}
return delta
}
func windTargetForSlot(slot float64) (float64, float64) {
angle := hash01(slot, 1) * math.Pi * 2
strength := windMinStrength + hash01(slot, 2)*(windMaxStrength-windMinStrength)
return angle, strength
}
func semiRandomWindPatternAt(timeMs float64) (float64, float64) {
if timeMs < 0 {
timeMs = 0
}
slot := math.Floor(timeMs / windPatternMS)
phase := math.Mod(timeMs, windPatternMS) / windPatternMS
currentAngle, currentStrength := windTargetForSlot(slot)
nextAngle, nextStrength := windTargetForSlot(slot + 1)
blend := smootherStep01((phase - 0.18) / 0.82)
angle := currentAngle + shortestAngleDeltaRad(currentAngle, nextAngle)*blend
strength := currentStrength + (nextStrength-currentStrength)*blend
gust := math.Sin(timeMs/2300+slot*0.73) * (1.1 + hash01(slot, 3)*1.8)
return angle, math.Max(4, strength+gust)
}
func pointSegmentDistanceXY(x, y float64, route weatherRoute) float64 {
abX := route.ToX - route.FromX
abY := route.ToY - route.FromY
apX := x - route.FromX
apY := y - route.FromY
lengthSquared := abX*abX + abY*abY
if lengthSquared <= 1 {
return math.Hypot(apX, apY)
}
t := clampFloat((apX*abX+apY*abY)/lengthSquared, 0, 1)
nearestX := route.FromX + abX*t
nearestY := route.FromY + abY*t
return math.Hypot(x-nearestX, y-nearestY)
}
func (h *hub) routeWeatherAtLocked(x, y float64) (float64, float64) {
if len(h.weatherRoutes) == 0 {
return 0, 0
}
distance := math.Inf(1)
width := 220.0
for _, route := range h.weatherRoutes {
candidate := pointSegmentDistanceXY(x, y, route)
if candidate < distance {
distance = candidate
width = route.Width
}
}
core := math.Max(routeMinCoreRadius, width*0.9)
outer := core + routeEdgeDepth
calmStart := core * 0.65
influence := 1 - smootherStep01((distance-calmStart)/math.Max(1, outer-calmStart))
edgeCenter := core + routeEdgeDepth*0.5
edgeHalf := routeEdgeDepth * 0.62
edge := smootherStep01(1 - math.Abs(distance-edgeCenter)/edgeHalf)
return clampFloat(influence, 0, 1), clampFloat(edge, 0, 1)
}
func stormFrontIntensityAt(x, y, timeMs float64) float64 {
raw := math.Mod(timeMs, stormFrontPeriodMS)
if raw < 0 {
raw += stormFrontPeriodMS
}
phase := raw / stormFrontPeriodMS
front := func(offset, yBase, yWave, scale float64) float64 {
t := math.Mod(phase+offset, 1)
centerX := -900 + t*(worldSize+1800)
centerY := worldSize * (yBase + math.Sin(t*math.Pi*2+offset*4.7)*yWave)
distance := math.Hypot((x-centerX)/1.55, y-centerY)
return clampFloat(1-distance/stormFrontRadius, 0, 1) * scale
}
return math.Max(
front(0, 0.35, 0.18, 0.95),
front(0.53, 0.72, 0.13, 0.74),
)
}
func (h *hub) weatherWindAtLocked(x, y float64, now time.Time) weatherWind {
timeMs := float64(now.UnixMilli())
timeSeconds := timeMs / 1000
angle, strength := semiRandomWindPatternAt(timeMs)
routeInfluence, routeEdge := h.routeWeatherAtLocked(x, y)
openExposure := 1 - routeInfluence
storm := stormFrontIntensityAt(x, y, timeMs)
directionalVariance := 0.022 + openExposure*0.09 + routeEdge*0.055 + storm*0.2
localAngle := angle +
math.Sin((x+y)/880+timeSeconds/31)*directionalVariance +
math.Sin(timeSeconds/5.2+x/1400)*storm*0.12
strengthScale := 0.72 + openExposure*0.5 + routeEdge*0.16 + storm*0.68
localStrength := strength*strengthScale +
math.Sin((x-y)/760+timeSeconds/4.7)*(0.45+openExposure*0.9+storm*1.2)
return weatherWind{
X: math.Cos(localAngle) * localStrength,
Y: math.Sin(localAngle) * localStrength,
Strength: math.Abs(localStrength),
RouteInfluence: routeInfluence,
RouteEdge: routeEdge,
StormIntensity: storm,
}
}
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()
return h.fireLocked(h.clients[id], side, now)
}
func (h *hub) fireLocked(c *client, side string, now time.Time) bool {
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
shotWind := h.weatherWindAtLocked(c.state.X, c.state.Y, now)
for _, offset := range []float64{-7, 0, 7} {
angle := base + offset*math.Pi/180
h.nextProjectile++
projectileID := c.state.ID + ":" + strconv.FormatUint(h.nextProjectile, 10)
h.projectiles[projectileID] = projectileState{
ID: projectileID, OwnerID: c.state.ID,
X: c.state.X + math.Cos(base)*28,
Y: c.state.Y + math.Sin(base)*28,
VX: math.Cos(angle)*shotSpeed + shipVX*0.25 + shotWind.X*0.22,
VY: math.Sin(angle)*shotSpeed + shipVY*0.25 + shotWind.Y*0.22,
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 {
wind := h.weatherWindAtLocked(c.state.X, c.state.Y, now)
if c.state.Bot {
input, fireSide := h.fakeCaptainDecisionLocked(c, now)
c.input = input
stepPlayerWithWind(&c.state, c.input, now, dt, wind.X, wind.Y)
if fireSide != "" {
h.fireLocked(c, fireSide, now)
}
continue
}
stepPlayerWithWind(&c.state, c.input, now, dt, wind.X, wind.Y)
}
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 signedAngleDelta(value float64) float64 {
value = math.Mod(value+180, 360)
if value < 0 {
value += 360
}
return value - 180
}
func bearingDegrees(fromX, fromY, toX, toY float64) float64 {
return normalizeAngle(math.Atan2(toY-fromY, toX-fromX)*180/math.Pi + 90)
}
func (h *hub) fakeCaptainDecisionLocked(c *client, now time.Time) (inputState, string) {
seq := c.input.Seq + 1
if !c.state.Alive {
return inputState{Seq: seq}, ""
}
var target *client
bestRange := math.Inf(1)
for _, candidate := range h.clients {
if candidate == c || candidate.state.Bot || !candidate.state.Alive {
continue
}
dx := candidate.state.X - c.state.X
dy := candidate.state.Y - c.state.Y
distance := math.Hypot(dx, dy)
if distance <= 1100 && distance < bestRange {
target = candidate
bestRange = distance
}
}
if target == nil {
phase := float64(c.botIndex)*2.1 + float64(now.UnixMilli()%60000)/60000*math.Pi*2
radius := 720.0 + float64(c.botIndex)*110
targetX := spawnX + math.Cos(phase)*radius
targetY := spawnY + math.Sin(phase)*radius
bearing := bearingDegrees(c.state.X, c.state.Y, targetX, targetY)
delta := signedAngleDelta(bearing - c.state.Angle)
return inputState{
Throttle: 0.58,
Steer: clampFloat(delta/55, -1, 1),
Seq: seq,
}, ""
}
bearing := bearingDegrees(c.state.X, c.state.Y, target.state.X, target.state.Y)
relative := signedAngleDelta(bearing - c.state.Angle)
if bestRange > 620 {
return inputState{
Throttle: 0.9,
Steer: clampFloat(relative/55, -1, 1),
Seq: seq,
}, ""
}
side := "starboard"
desiredRelative := 90.0
if relative < 0 {
side = "port"
desiredRelative = -90
}
broadsideError := signedAngleDelta(relative - desiredRelative)
throttle := 0.62
if bestRange < 250 {
throttle = 0.28
}
fireSide := ""
if bestRange >= 130 && bestRange <= 560 && math.Abs(broadsideError) <= 20 {
fireSide = side
}
return inputState{
Throttle: throttle,
Steer: clampFloat(broadsideError/50, -1, 1),
Seq: seq,
}, fireSide
}
func stepPlayer(state *playerState, input inputState, now time.Time, dt float64) {
stepPlayerWithWind(state, input, now, dt, 0, 0)
}
func stepPlayerWithWind(
state *playerState,
input inputState,
now time.Time,
dt float64,
windX float64,
windY 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+windX)*dt, 0, worldSize)
state.Y = clampFloat(state.Y+(math.Sin(heading)*state.Speed+windY)*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 {
if c.conn != nil {
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"),
FakePlayers: envInt("FAKE_PLAYERS", 3),
}
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 envInt(key string, fallback int) int {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return parsed
}
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(8192)
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())
case "weather-layout":
a.hub.setWeatherRoutes(msg.Routes)
}
}
}
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, cfg.FakePlayers),
}
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 fake_players=%d",
cfg.Port,
cfg.WorldSeed,
cfg.FakePlayers,
)
log.Fatal(server.ListenAndServe())
}