diff --git a/Dockerfile b/Dockerfile index 322a05b..85910a8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/package.json b/package.json index 2ede4d0..8b18f69 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/public/silent-check-sso.html b/public/silent-check-sso.html new file mode 100644 index 0000000..88555dc --- /dev/null +++ b/public/silent-check-sso.html @@ -0,0 +1,2 @@ + +
diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..6a3e784 --- /dev/null +++ b/server/Dockerfile @@ -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"] diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..8bdad2d --- /dev/null +++ b/server/README.md @@ -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. diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..b071bbd --- /dev/null +++ b/server/go.mod @@ -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 +) diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..f6af221 --- /dev/null +++ b/server/main.go @@ -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 } diff --git a/server/main_test.go b/server/main_test.go new file mode 100644 index 0000000..8c2d70d --- /dev/null +++ b/server/main_test.go @@ -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") + } +} diff --git a/src/game-entry.js b/src/game-entry.js index 11c099e..2b1ea33 100644 --- a/src/game-entry.js +++ b/src/game-entry.js @@ -1,3 +1,4 @@ +import './multiplayer.js'; import './game-v4.js'; import './mobile-hud.js'; import './dock-ui.js'; diff --git a/src/game-v4.js b/src/game-v4.js index 281b175..24645f4 100644 --- a/src/game-v4.js +++ b/src/game-v4.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); diff --git a/src/multiplayer.js b/src/multiplayer.js new file mode 100644 index 0000000..ba1b3c3 --- /dev/null +++ b/src/multiplayer.js @@ -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'); +}); diff --git a/tests/wiring.test.mjs b/tests/wiring.test.mjs index 00389e6..cfb1170 100644 --- a/tests/wiring.test.mjs +++ b/tests/wiring.test.mjs @@ -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'),