Sync multiplayer auth presence and game server
This commit is contained in:
parent
b19a3c01f8
commit
afe5808731
@ -3,9 +3,11 @@ WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
COPY index.html ./
|
||||
COPY public ./public
|
||||
COPY src ./src
|
||||
COPY scripts ./scripts
|
||||
COPY tests ./tests
|
||||
COPY server ./server
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
@ -11,7 +11,8 @@
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"phaser": "^3.90.0"
|
||||
"phaser": "^3.90.0",
|
||||
"keycloak-js": "^26.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^7.0.0"
|
||||
|
||||
2
public/silent-check-sso.html
Normal file
2
public/silent-check-sso.html
Normal file
@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html><body><script>parent.postMessage(location.href, location.origin);</script></body></html>
|
||||
14
server/Dockerfile
Normal file
14
server/Dockerfile
Normal file
@ -0,0 +1,14 @@
|
||||
FROM golang:1.23-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go test -mod=mod ./... && go build -mod=mod -trimpath -ldflags='-s -w' -o /out/airship-server .
|
||||
|
||||
FROM alpine:3.21
|
||||
RUN apk add --no-cache ca-certificates
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/airship-server /app/airship-server
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=5 CMD wget -qO- http://127.0.0.1:8080/health >/dev/null || exit 1
|
||||
ENTRYPOINT ["/app/airship-server"]
|
||||
14
server/README.md
Normal file
14
server/README.md
Normal file
@ -0,0 +1,14 @@
|
||||
# Airship multiplayer server
|
||||
|
||||
Go transport/authentication service for the Phaser airship game.
|
||||
|
||||
Current slice:
|
||||
|
||||
- verifies Keycloak access tokens
|
||||
- persists authenticated player profiles to PostgreSQL
|
||||
- issues one-time 30-second WebSocket tickets
|
||||
- maintains one live connection per Keycloak subject
|
||||
- validates and broadcasts player transform/hull snapshots at 10 Hz
|
||||
- exposes `/health`, `/api/me`, `/api/ws-ticket`, and `/ws`
|
||||
|
||||
This is intentionally a transport/presence milestone. Player combat, pirates, projectiles, trading and economy still run in the Phaser client and are the next systems to move server-side.
|
||||
9
server/go.mod
Normal file
9
server/go.mod
Normal file
@ -0,0 +1,9 @@
|
||||
module airship-server
|
||||
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.12.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jackc/pgx/v5 v5.7.2
|
||||
)
|
||||
459
server/main.go
Normal file
459
server/main.go
Normal 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 }
|
||||
50
server/main_test.go
Normal file
50
server/main_test.go
Normal file
@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTicketSingleUse(t *testing.T) {
|
||||
store := newTicketStore()
|
||||
value, err := store.issue("player-1", "Dave")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if item, ok := store.consume(value); !ok || item.Subject != "player-1" {
|
||||
t.Fatalf("first consume failed: %#v %v", item, ok)
|
||||
}
|
||||
if _, ok := store.consume(value); ok {
|
||||
t.Fatal("ticket must be one-time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransformSanitizers(t *testing.T) {
|
||||
if got := clampFloat(-5, 0, 3600); got != 0 {
|
||||
t.Fatalf("clamp low=%v", got)
|
||||
}
|
||||
if got := clampFloat(4000, 0, 3600); got != 3600 {
|
||||
t.Fatalf("clamp high=%v", got)
|
||||
}
|
||||
if got := clampFloat(math.NaN(), 0, 3600); got != 0 {
|
||||
t.Fatalf("nan=%v", got)
|
||||
}
|
||||
if got := normalizeAngle(-90); got != 270 {
|
||||
t.Fatalf("angle=%v", got)
|
||||
}
|
||||
if got := clampInt(900, 0, 500); got != 500 {
|
||||
t.Fatalf("hull=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudienceMatch(t *testing.T) {
|
||||
if !audienceMatches(claims{AuthorizedParty: "airship-web"}, "airship-web") {
|
||||
t.Fatal("azp should match")
|
||||
}
|
||||
if !audienceMatches(claims{Audience: []interface{}{"account", "airship-web"}}, "airship-web") {
|
||||
t.Fatal("aud should match")
|
||||
}
|
||||
if audienceMatches(claims{AuthorizedParty: "other"}, "airship-web") {
|
||||
t.Fatal("wrong client must not match")
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
import './multiplayer.js';
|
||||
import './game-v4.js';
|
||||
import './mobile-hud.js';
|
||||
import './dock-ui.js';
|
||||
|
||||
@ -144,6 +144,8 @@ class GameScene extends Phaser.Scene {
|
||||
this.tradeDeliveries = 0;
|
||||
this.tradeLosses = 0;
|
||||
this.playerTradeLosses = 0;
|
||||
this.remotePlayers = new Map();
|
||||
this.multiplayerSendAccumulatorMs = 0;
|
||||
|
||||
airshipLog('scene:init', {
|
||||
hull: this.playerHull,
|
||||
@ -182,6 +184,11 @@ class GameScene extends Phaser.Scene {
|
||||
this.player.setFrame(0);
|
||||
this.createPlayerSmoke();
|
||||
|
||||
this.handleMultiplayerSnapshot = (event) => {
|
||||
this.syncRemotePlayers(event.detail?.players || []);
|
||||
};
|
||||
window.addEventListener('airship:multiplayer-snapshot', this.handleMultiplayerSnapshot);
|
||||
|
||||
this.pirates = this.physics.add.group();
|
||||
this.merchants = this.physics.add.group();
|
||||
this.playerShots = this.physics.add.group();
|
||||
@ -276,6 +283,12 @@ class GameScene extends Phaser.Scene {
|
||||
registerDebugSnapshot(null);
|
||||
window.removeEventListener('airship:helm-vector', this.handleHelmVector);
|
||||
window.removeEventListener('airship:action', this.handleMobileAction);
|
||||
window.removeEventListener('airship:multiplayer-snapshot', this.handleMultiplayerSnapshot);
|
||||
for (const remote of this.remotePlayers.values()) {
|
||||
remote.sprite?.destroy();
|
||||
remote.label?.destroy();
|
||||
}
|
||||
this.remotePlayers.clear();
|
||||
airshipLog('scene:shutdown', {
|
||||
gameState: this.gameState,
|
||||
hull: this.playerHull,
|
||||
@ -1401,6 +1414,7 @@ class GameScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
this.updateProgressionInteraction();
|
||||
this.updateMultiplayer(deltaMs);
|
||||
if (this.dockOpen) {
|
||||
this.player.setVelocity(0, 0);
|
||||
const hudState = this.buildHudState();
|
||||
@ -1437,6 +1451,85 @@ class GameScene extends Phaser.Scene {
|
||||
}
|
||||
}
|
||||
|
||||
syncRemotePlayers(players = []) {
|
||||
const seen = new Set();
|
||||
|
||||
for (const state of players) {
|
||||
if (!state?.id) continue;
|
||||
seen.add(state.id);
|
||||
|
||||
let remote = this.remotePlayers.get(state.id);
|
||||
if (!remote) {
|
||||
const sprite = this.add.sprite(
|
||||
Number(state.x) || this.world.spawn.x,
|
||||
Number(state.y) || this.world.spawn.y,
|
||||
'player-airship',
|
||||
0,
|
||||
);
|
||||
sprite
|
||||
.setDepth(5.5)
|
||||
.setDisplaySize(60, 114)
|
||||
.setTint(0x8cd3ff)
|
||||
.setAlpha(0.86);
|
||||
sprite.play('player-forward');
|
||||
sprite.anims.timeScale = 0.72;
|
||||
|
||||
const label = this.add.text(sprite.x, sprite.y - 70, state.name || 'CAPTAIN', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '10px',
|
||||
color: '#bfe6ff',
|
||||
backgroundColor: '#071018aa',
|
||||
padding: { x: 4, y: 2 },
|
||||
}).setOrigin(0.5).setDepth(12);
|
||||
|
||||
remote = {
|
||||
sprite,
|
||||
label,
|
||||
targetX: sprite.x,
|
||||
targetY: sprite.y,
|
||||
targetAngle: Number(state.angle) || 0,
|
||||
hull: Number(state.hull) || 100,
|
||||
};
|
||||
this.remotePlayers.set(state.id, remote);
|
||||
}
|
||||
|
||||
remote.targetX = clamp(Number(state.x) || 0, 0, this.world.width);
|
||||
remote.targetY = clamp(Number(state.y) || 0, 0, this.world.height);
|
||||
remote.targetAngle = normalizeDegrees(Number(state.angle) || 0);
|
||||
remote.hull = Math.max(0, Number(state.hull) || 0);
|
||||
remote.label.setText(`${state.name || 'CAPTAIN'} ${Math.round(remote.hull)}`);
|
||||
}
|
||||
|
||||
for (const [id, remote] of this.remotePlayers) {
|
||||
if (seen.has(id)) continue;
|
||||
remote.sprite?.destroy();
|
||||
remote.label?.destroy();
|
||||
this.remotePlayers.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
updateMultiplayer(deltaMs) {
|
||||
this.multiplayerSendAccumulatorMs += deltaMs;
|
||||
if (this.multiplayerSendAccumulatorMs >= 100) {
|
||||
this.multiplayerSendAccumulatorMs %= 100;
|
||||
window.airshipMultiplayer?.sendState?.({
|
||||
x: this.player?.x || 0,
|
||||
y: this.player?.y || 0,
|
||||
angle: this.player?.angle || 0,
|
||||
hull: this.playerHull,
|
||||
});
|
||||
}
|
||||
|
||||
for (const remote of this.remotePlayers.values()) {
|
||||
const sprite = remote.sprite;
|
||||
if (!sprite?.active) continue;
|
||||
sprite.x = Phaser.Math.Linear(sprite.x, remote.targetX, 0.32);
|
||||
sprite.y = Phaser.Math.Linear(sprite.y, remote.targetY, 0.32);
|
||||
sprite.angle += signedAngleDelta(remote.targetAngle, sprite.angle) * 0.32;
|
||||
remote.label.setPosition(sprite.x, sprite.y - 70);
|
||||
}
|
||||
}
|
||||
|
||||
updatePlayer(dt, deltaMs) {
|
||||
if (!this.player?.body) return;
|
||||
const stats = this.playerStats || deriveShipStats(this.progression?.loadout, this.progression?.installedConditions);
|
||||
|
||||
197
src/multiplayer.js
Normal file
197
src/multiplayer.js
Normal file
@ -0,0 +1,197 @@
|
||||
import Keycloak from 'keycloak-js';
|
||||
|
||||
const API_BASE = 'https://game.arrowstack.dev';
|
||||
const WS_BASE = 'wss://game.arrowstack.dev';
|
||||
const KEYCLOAK_CONFIG = {
|
||||
url: 'https://auth.arrowstack.dev',
|
||||
realm: 'dev',
|
||||
clientId: 'airship-web',
|
||||
};
|
||||
|
||||
const keycloak = new Keycloak(KEYCLOAK_CONFIG);
|
||||
let socket = null;
|
||||
let selfId = null;
|
||||
let reconnectTimer = null;
|
||||
let authenticated = false;
|
||||
let playerCount = 0;
|
||||
|
||||
const statusButton = document.createElement('button');
|
||||
statusButton.id = 'multiplayer-status';
|
||||
statusButton.type = 'button';
|
||||
statusButton.textContent = 'MULTIPLAYER';
|
||||
statusButton.setAttribute('aria-label', 'Multiplayer login');
|
||||
document.body.appendChild(statusButton);
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
#multiplayer-status {
|
||||
position: fixed;
|
||||
top: max(10px, env(safe-area-inset-top));
|
||||
left: max(10px, env(safe-area-inset-left));
|
||||
z-index: 410;
|
||||
min-height: 38px;
|
||||
max-width: 180px;
|
||||
padding: 7px 11px;
|
||||
border: 1px solid rgba(220, 168, 79, .72);
|
||||
border-radius: 999px;
|
||||
background: rgba(5, 25, 31, .86);
|
||||
color: #f5dda0;
|
||||
font: 700 10px/1.1 Georgia, 'Times New Roman', serif;
|
||||
letter-spacing: .05em;
|
||||
box-shadow: 0 3px 12px rgba(0,0,0,.35);
|
||||
touch-action: manipulation;
|
||||
}
|
||||
#multiplayer-status[data-state="online"] { border-color: rgba(115, 207, 145, .82); color: #c8f0cf; }
|
||||
#multiplayer-status[data-state="error"] { border-color: rgba(215, 92, 76, .85); color: #ffd0c7; }
|
||||
body.dock-open #multiplayer-status,
|
||||
body.shipyard-open #multiplayer-status { visibility: hidden; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
const setStatus = (state, label) => {
|
||||
statusButton.dataset.state = state;
|
||||
statusButton.textContent = label;
|
||||
};
|
||||
|
||||
const emitConnection = (state, extra = {}) => {
|
||||
window.dispatchEvent(new CustomEvent('airship:multiplayer-state', {
|
||||
detail: { state, authenticated, playerCount, selfId, ...extra },
|
||||
}));
|
||||
};
|
||||
|
||||
const authFetch = async (path, options = {}) => {
|
||||
await keycloak.updateToken(30);
|
||||
return fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...(options.headers || {}),
|
||||
Authorization: `Bearer ${keycloak.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const requestSocketTicket = async () => {
|
||||
const response = await authFetch('/api/ws-ticket', { method: 'POST' });
|
||||
if (!response.ok) throw new Error(`ticket ${response.status}`);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (!authenticated || reconnectTimer) return;
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect().catch(() => scheduleReconnect());
|
||||
}, 1800);
|
||||
};
|
||||
|
||||
const connect = async () => {
|
||||
if (!authenticated) return;
|
||||
if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) return;
|
||||
|
||||
setStatus('connecting', 'MULTIPLAYER • CONNECTING');
|
||||
const { ticket } = await requestSocketTicket();
|
||||
const ws = new WebSocket(`${WS_BASE}/ws?ticket=${encodeURIComponent(ticket)}`);
|
||||
socket = ws;
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
setStatus('online', 'MULTIPLAYER • ONLINE');
|
||||
emitConnection('online');
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
let message;
|
||||
try { message = JSON.parse(event.data); } catch { return; }
|
||||
|
||||
if (message.type === 'welcome') {
|
||||
selfId = message.playerId || selfId;
|
||||
emitConnection('online', { worldSeed: message.worldSeed });
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'snapshot') {
|
||||
const players = Array.isArray(message.players)
|
||||
? message.players.filter((player) => player?.id && player.id !== selfId)
|
||||
: [];
|
||||
playerCount = players.length + 1;
|
||||
setStatus('online', `MULTIPLAYER • ${playerCount}`);
|
||||
window.dispatchEvent(new CustomEvent('airship:multiplayer-snapshot', {
|
||||
detail: { players, serverTime: message.serverTime || 0 },
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
if (socket === ws) socket = null;
|
||||
playerCount = 0;
|
||||
setStatus(authenticated ? 'connecting' : 'offline', authenticated ? 'MULTIPLAYER • RECONNECT' : 'MULTIPLAYER');
|
||||
emitConnection('offline');
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
setStatus('error', 'MULTIPLAYER • RETRY');
|
||||
});
|
||||
};
|
||||
|
||||
const sendState = (state) => {
|
||||
if (socket?.readyState !== WebSocket.OPEN || !state) return false;
|
||||
socket.send(JSON.stringify({
|
||||
type: 'state',
|
||||
x: Number(state.x) || 0,
|
||||
y: Number(state.y) || 0,
|
||||
angle: Number(state.angle) || 0,
|
||||
hull: Math.max(0, Math.round(Number(state.hull) || 0)),
|
||||
}));
|
||||
return true;
|
||||
};
|
||||
|
||||
const login = () => keycloak.login({ redirectUri: window.location.href });
|
||||
const logout = () => keycloak.logout({ redirectUri: `${window.location.origin}/` });
|
||||
|
||||
statusButton.addEventListener('click', () => {
|
||||
if (!authenticated) {
|
||||
login();
|
||||
return;
|
||||
}
|
||||
if (socket?.readyState !== WebSocket.OPEN) connect().catch(() => setStatus('error', 'MULTIPLAYER • RETRY'));
|
||||
});
|
||||
|
||||
window.airshipMultiplayer = {
|
||||
sendState,
|
||||
login,
|
||||
logout,
|
||||
connect,
|
||||
isAuthenticated: () => authenticated,
|
||||
isConnected: () => socket?.readyState === WebSocket.OPEN,
|
||||
};
|
||||
|
||||
keycloak.onTokenExpired = () => {
|
||||
keycloak.updateToken(30).catch(() => {
|
||||
authenticated = false;
|
||||
if (socket) socket.close();
|
||||
setStatus('offline', 'MULTIPLAYER');
|
||||
});
|
||||
};
|
||||
|
||||
keycloak.init({
|
||||
onLoad: 'check-sso',
|
||||
pkceMethod: 'S256',
|
||||
checkLoginIframe: false,
|
||||
silentCheckSsoRedirectUri: `${window.location.origin}/silent-check-sso.html`,
|
||||
}).then((ok) => {
|
||||
authenticated = Boolean(ok);
|
||||
if (!authenticated) {
|
||||
setStatus('offline', 'MULTIPLAYER');
|
||||
emitConnection('signed-out');
|
||||
return;
|
||||
}
|
||||
setStatus('connecting', 'MULTIPLAYER • CONNECTING');
|
||||
connect().catch(() => {
|
||||
setStatus('error', 'MULTIPLAYER • RETRY');
|
||||
scheduleReconnect();
|
||||
});
|
||||
}).catch(() => {
|
||||
authenticated = false;
|
||||
setStatus('error', 'MULTIPLAYER • LOGIN');
|
||||
});
|
||||
@ -263,6 +263,31 @@ test('environment physics adds wind, soft island collision, turbulence, and proj
|
||||
assert.match(source, /windSpeed: this\.environmentWind\?\.strength/);
|
||||
});
|
||||
|
||||
test('multiplayer transport uses Keycloak tickets and renders remote Sparrows without physics authority', async () => {
|
||||
const [game, entry, multiplayer, pkg, server] = await Promise.all([
|
||||
read('src/game-v4.js'),
|
||||
read('src/game-entry.js'),
|
||||
read('src/multiplayer.js'),
|
||||
read('package.json'),
|
||||
read('server/main.go'),
|
||||
]);
|
||||
|
||||
assert.match(entry, /import '.\/multiplayer\.js'/);
|
||||
assert.match(pkg, /"keycloak-js"/);
|
||||
assert.match(multiplayer, /clientId: 'airship-web'/);
|
||||
assert.match(multiplayer, /\/api\/ws-ticket/);
|
||||
assert.match(multiplayer, /Authorization:/);
|
||||
assert.match(multiplayer, /new WebSocket/);
|
||||
assert.match(game, /airship:multiplayer-snapshot/);
|
||||
assert.match(game, /this\.remotePlayers = new Map\(\)/);
|
||||
assert.match(game, /window\.airshipMultiplayer\?\.sendState/);
|
||||
assert.match(game, /this\.add\.sprite\([\s\S]*?'player-airship'/);
|
||||
assert.match(server, /CREATE TABLE IF NOT EXISTS player_profiles/);
|
||||
assert.match(server, /a\.tickets\.consume/);
|
||||
assert.match(server, /clampFloat\(msg\.X, 0, 3600\)/);
|
||||
assert.match(server, /time\.NewTicker\(100 \* time\.Millisecond\)/);
|
||||
});
|
||||
|
||||
test('mobile controls enter a dock hub before shipyard, trade, or missions', async () => {
|
||||
const [game, hud, index, entry, dockUi] = await Promise.all([
|
||||
read('src/game-v4.js'),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user