From ab1dd08225cfb02a0be2593ce9eadf46f46fe82b Mon Sep 17 00:00:00 2001 From: doomtube Date: Fri, 9 Jan 2026 00:49:07 -0500 Subject: [PATCH] fixes lol --- .../controllers/ChatWebSocketController.cpp | 40 +++++- frontend/src/lib/chat/chatWebSocket.js | 69 ++++++--- .../lib/components/ChessGameOverlay.svelte | 6 +- frontend/src/routes/[realm]/live/+page.svelte | 79 +++++++--- .../src/routes/[realm]/watch/+page.svelte | 22 +-- nakama/go-modules/main.go | 136 +++++++++--------- openresty/lua/thumbnail.lua | 4 +- 7 files changed, 216 insertions(+), 140 deletions(-) diff --git a/chat-service/src/controllers/ChatWebSocketController.cpp b/chat-service/src/controllers/ChatWebSocketController.cpp index 3ce4d58..155de65 100644 --- a/chat-service/src/controllers/ChatWebSocketController.cpp +++ b/chat-service/src/controllers/ChatWebSocketController.cpp @@ -38,6 +38,10 @@ void ChatWebSocketController::broadcastParticipantJoined(const std::string& real participant["isGuest"] = joinedUser.isGuest; participant["isModerator"] = joinedUser.isModerator; participant["isStreamer"] = joinedUser.isStreamer; + // Include join timestamp for ordering (milliseconds since epoch) + auto joinedAtMs = std::chrono::duration_cast( + joinedUser.connectionTime.time_since_epoch()).count(); + participant["joinedAt"] = static_cast(joinedAtMs); broadcast["participant"] = participant; // Count participants in realm @@ -867,6 +871,10 @@ void ChatWebSocketController::handleGetParticipants(const WebSocketConnectionPtr participant["isGuest"] = connInfo.isGuest; participant["isModerator"] = connInfo.isModerator; participant["isStreamer"] = connInfo.isStreamer; + // Include join timestamp for ordering (milliseconds since epoch) + auto joinedAtMs = std::chrono::duration_cast( + connInfo.connectionTime.time_since_epoch()).count(); + participant["joinedAt"] = static_cast(joinedAtMs); response["participants"].append(participant); } } @@ -957,7 +965,10 @@ void ChatWebSocketController::handleRename(const WebSocketConnectionPtr& wsConnP auto it = connections_.find(wsConnPtr); if (it != connections_.end()) { it->second.username = newName; + // Also update userId to match new username (for guests, userId = "guest:" + username) + it->second.userId = "guest:" + newName; info.username = newName; + info.userId = it->second.userId; // Update username lookup map: remove old, add new usernameToConnection_.erase(oldLowerName); @@ -983,6 +994,15 @@ void ChatWebSocketController::handleRename(const WebSocketConnectionPtr& wsConnP wsConnPtr->send(Json::writeString(Json::StreamWriterBuilder(), response)); LOG_INFO << "Guest renamed from " << oldName << " to " << newName; + + // Broadcast updated participant info to all clients in the realm + if (!info.realmId.empty()) { + std::lock_guard lock(connectionsMutex_); + auto it = connections_.find(wsConnPtr); + if (it != connections_.end()) { + broadcastParticipantJoined(info.realmId, it->second); + } + } } // SECURITY FIX #9: Handle auth token/apiKey sent as WebSocket message (not in URL) @@ -1027,14 +1047,18 @@ void ChatWebSocketController::handleAuthMessage(const WebSocketConnectionPtr& ws bool connectionFound = false; std::string oldUsername; + std::string oldUserId; + std::string realmId; // Update connection info with authenticated user details { std::lock_guard lock(connectionsMutex_); auto it = connections_.find(wsConnPtr); if (it != connections_.end()) { - // Save old username to update usernameToConnection_ map + // Save old identity info to update participants and usernameToConnection_ map oldUsername = it->second.username; + oldUserId = it->second.userId; + realmId = it->second.realmId; // Upgrade from guest to authenticated user it->second.userId = claims->userId; @@ -1070,6 +1094,20 @@ void ChatWebSocketController::handleAuthMessage(const WebSocketConnectionPtr& ws if (connectionFound) { LOG_INFO << "User authenticated via message: " << claims->username; wsConnPtr->send(Json::writeString(Json::StreamWriterBuilder(), welcome)); + + // Broadcast participant update to other clients in the realm + // First remove old guest identity, then add new authenticated identity + if (!realmId.empty()) { + // Broadcast participant_left for old guest identity + broadcastParticipantLeft(realmId, oldUserId, oldUsername); + + // Broadcast participant_joined with new authenticated identity + std::lock_guard lock(connectionsMutex_); + auto it = connections_.find(wsConnPtr); + if (it != connections_.end()) { + broadcastParticipantJoined(realmId, it->second); + } + } } } diff --git a/frontend/src/lib/chat/chatWebSocket.js b/frontend/src/lib/chat/chatWebSocket.js index 972c5b0..5ed26d8 100644 --- a/frontend/src/lib/chat/chatWebSocket.js +++ b/frontend/src/lib/chat/chatWebSocket.js @@ -162,6 +162,14 @@ class ChatWebSocket { break; case 'auth_success': + // Get old userId before updating (for participant cleanup) + const oldUserId = get(chatUserInfo).userId; + + // Remove old participant entry (guest identity) + if (oldUserId) { + participants.update((list) => list.filter((p) => p.userId !== oldUserId)); + } + // Update user info after successful authentication chatUserInfo.set({ username: data.username, @@ -174,6 +182,9 @@ class ChatWebSocket { avatarUrl: data.avatarUrl || '', userColor: data.userColor || '#FFFFFF' }); + + // Request fresh participants list to ensure consistency + this.getParticipants(); console.log('Authentication successful:', data.username); break; @@ -197,19 +208,27 @@ class ChatWebSocket { break; case 'participants_list': - participants.set(data.participants || []); + // Sort by joinedAt ascending (oldest/longest-joined first) + const sortedList = (data.participants || []).sort( + (a, b) => (a.joinedAt || 0) - (b.joinedAt || 0) + ); + participants.set(sortedList); console.log(`Participants in realm: ${data.count}`); break; case 'participant_joined': // Add new participant to the list participants.update((list) => { - // Check if already in list (avoid duplicates) - const exists = list.some((p) => p.userId === data.participant.userId); - if (!exists) { - return [...list, data.participant]; - } - return list; + // Remove any existing entry with same userId OR same username + // This handles auth transitions (userId changes) and reconnects + const filtered = list.filter( + (p) => + p.userId !== data.participant.userId && + p.username !== data.participant.username + ); + // Add new participant and re-sort by joinedAt (oldest first) + const updated = [...filtered, data.participant]; + return updated.sort((a, b) => (a.joinedAt || 0) - (b.joinedAt || 0)); }); console.log(`Participant joined: ${data.participant.username} (${data.participantCount} total)`); break; @@ -431,6 +450,7 @@ class ChatWebSocket { /** * Manually trigger reconnection - resets attempt counter and tries immediately + * Also handles authentication on existing connection (e.g., when user logs in while connected as guest) */ async manualReconnect() { console.log('Manual reconnect triggered'); @@ -440,25 +460,28 @@ class ChatWebSocket { } this.reconnectAttempts = 0; - // Refresh token before reconnecting if we have one - if (this.token && this.realmId) { - try { - const response = await fetch('/api/auth/refresh', { - method: 'POST', - credentials: 'include' - }); - if (response.ok) { - const data = await response.json(); - if (data.token) { - this.token = data.token; - } - } - } catch (e) { - console.warn('Token refresh error during manual reconnect:', e); - } + // Get fresh token from localStorage (may have been set by login) + const freshToken = + typeof localStorage !== 'undefined' ? localStorage.getItem('token') : null; + + // If we have a token and connection is open, just send auth message + // This handles the case where user logs in while already connected as guest + if (freshToken && this.ws && this.ws.readyState === WebSocket.OPEN) { + console.log('[ChatWebSocket] Sending auth message on existing connection'); + this.token = freshToken; + this.ws.send(JSON.stringify({ type: 'auth', token: freshToken })); + return; } + // Otherwise, do full reconnect + this.token = freshToken; if (this.realmId) { + // Close existing connection to force fresh connect + if (this.ws) { + this.ws.onclose = null; // Prevent auto-reconnect + this.ws.close(); + this.ws = null; + } this.connect(this.realmId, this.token); } } diff --git a/frontend/src/lib/components/ChessGameOverlay.svelte b/frontend/src/lib/components/ChessGameOverlay.svelte index d7ef70c..cfc455d 100644 --- a/frontend/src/lib/components/ChessGameOverlay.svelte +++ b/frontend/src/lib/components/ChessGameOverlay.svelte @@ -530,9 +530,11 @@ } if (result === '1-0') { - return myColor === 'w' ? 'You win!' : 'You lose'; + const winText = myColor === 'w' ? 'You win!' : 'You lose'; + return reason ? `${winText} (${reason})` : winText; } else if (result === '0-1') { - return myColor === 'b' ? 'You win!' : 'You lose'; + const winText = myColor === 'b' ? 'You win!' : 'You lose'; + return reason ? `${winText} (${reason})` : winText; } else if (result === 'timeout') { return reason || 'Match timed out'; } else if (result === 'cancelled') { diff --git a/frontend/src/routes/[realm]/live/+page.svelte b/frontend/src/routes/[realm]/live/+page.svelte index 820575c..04f6529 100644 --- a/frontend/src/routes/[realm]/live/+page.svelte +++ b/frontend/src/routes/[realm]/live/+page.svelte @@ -312,9 +312,35 @@ startDurationTimer(); } - // Only update isStreaming from stats if player isn't actively playing - // This prevents stats from overriding the player's live state during connection - if (!playerPlaying) { + // Handle stream going OFFLINE - force states to show offline overlay + if (!stats.isLive && wasLive) { + console.log('Stream went offline - resetting player states'); + playerPlaying = false; + playerBuffering = false; + isStreaming = false; + // Stop the player to release resources and clear stale buffers + if (player) { + try { + player.stop(); + } catch (e) { + console.error('Error stopping player:', e); + } + } + } + // Handle stream coming back ONLINE - reinitialize player + else if (stats.isLive && !wasLive) { + console.log('Stream came back online - reinitializing player'); + // Reset retry counters for fresh attempt + llhlsRetryAttempts = 0; + playerReconnectAttempts = 0; + playerInitializing = true; + isStreaming = true; + + // Reinitialize player with fresh token and sources + reinitializePlayerForReconnect(); + } + // Normal update - only update isStreaming if player isn't actively playing + else if (!playerPlaying) { isStreaming = stats.isLive; } @@ -323,6 +349,31 @@ realm.viewerCount = stats.connections; } } + + async function reinitializePlayerForReconnect() { + // Remove old player instance + if (player) { + try { + player.remove(); + } catch (e) { + console.error('Error removing player:', e); + } + player = null; + } + + // Refresh viewer token (may have expired during offline period) + const tokenSuccess = await getViewerToken(); + if (!tokenSuccess) { + console.error('Failed to refresh viewer token for reconnect'); + playerInitializing = false; + return; + } + + // Small delay to ensure stream is fully available + setTimeout(() => { + initializePlayer(); + }, 1000); + } function initializePlayer() { const playerElement = document.getElementById('player'); @@ -662,9 +713,7 @@ .player-wrapper { background: #000; - border-radius: 12px; overflow: hidden; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); margin-bottom: 1rem; position: relative; } @@ -695,27 +744,13 @@ } .stream-info-section { - background: #111; - border: 1px solid var(--border); - border-radius: 8px; + background: #000; padding: 1rem; position: relative; overflow: hidden; } - .stream-info-section::before { - content: ''; - position: absolute; - top: 0; - left: 0; - width: 4px; - height: 100%; - background: var(--user-color, var(--primary)); - opacity: 0.6; - } - .stream-header { - padding-left: 1rem; } .header-top { @@ -868,9 +903,7 @@ } .chat-section { - background: #111; - border: 1px solid var(--border); - border-radius: 8px; + background: #000; overflow: hidden; flex: 1 1 0; /* Grow and shrink to fill remaining space */ min-height: 0; /* Allow shrinking below content size */ diff --git a/frontend/src/routes/[realm]/watch/+page.svelte b/frontend/src/routes/[realm]/watch/+page.svelte index c0a8bbd..1f0bdd8 100644 --- a/frontend/src/routes/[realm]/watch/+page.svelte +++ b/frontend/src/routes/[realm]/watch/+page.svelte @@ -259,35 +259,19 @@ .player-wrapper { background: #000; - border-radius: 12px; overflow: hidden; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); position: relative; } .room-info-section { - background: #111; - border: 1px solid var(--border); - border-radius: 8px; + background: #000; padding: 0.6rem 1rem; position: relative; overflow: hidden; } - .room-info-section::before { - content: ''; - position: absolute; - top: 0; - left: 0; - width: 4px; - height: 100%; - background: var(--user-color, var(--primary)); - opacity: 0.6; - } - .room-header { - padding-left: 1rem; } .header-top { @@ -403,9 +387,7 @@ } .chat-section { - background: #111; - border: 1px solid var(--border); - border-radius: 8px; + background: #000; overflow: hidden; flex: 1 1 0; min-height: 0; diff --git a/nakama/go-modules/main.go b/nakama/go-modules/main.go index 7fab08a..65b00cb 100644 --- a/nakama/go-modules/main.go +++ b/nakama/go-modules/main.go @@ -33,22 +33,23 @@ const ( // ChessState represents the match state type ChessState struct { - PositionID int `json:"positionId"` - FEN string `json:"fen"` - PGN string `json:"pgn"` - WhiteID string `json:"whiteId"` - BlackID string `json:"blackId"` - WhiteName string `json:"whiteName"` - BlackName string `json:"blackName"` - WhiteColor string `json:"whiteColor"` - BlackColor string `json:"blackColor"` - Turn string `json:"turn"` - GameOver bool `json:"gameOver"` - Result string `json:"result,omitempty"` - MoveHistory []string `json:"moveHistory"` - CreatedAt int64 `json:"createdAt"` - Spectators []string `json:"spectators"` - PendingCancel bool `json:"pendingCancel,omitempty"` + PositionID int `json:"positionId"` + FEN string `json:"fen"` + PGN string `json:"pgn"` + WhiteID string `json:"whiteId"` + BlackID string `json:"blackId"` + WhiteName string `json:"whiteName"` + BlackName string `json:"blackName"` + WhiteColor string `json:"whiteColor"` + BlackColor string `json:"blackColor"` + Turn string `json:"turn"` + GameOver bool `json:"gameOver"` + Result string `json:"result,omitempty"` + MoveHistory []string `json:"moveHistory"` + CreatedAt int64 `json:"createdAt"` + LastMoveAt int64 `json:"lastMoveAt"` + Spectators []string `json:"spectators"` + PendingCancel bool `json:"pendingCancel,omitempty"` } // AppJwtClaims from the main app @@ -427,6 +428,7 @@ func (m *ChessMatch) MatchJoin(ctx context.Context, logger runtime.Logger, db *s s.BlackID = userID s.BlackName = username s.BlackColor = userColor + s.LastMoveAt = time.Now().Unix() // Game starts - white has 3 days to move logger.Info("%s joined as BLACK", username) label := MatchLabel{ @@ -521,6 +523,46 @@ func (m *ChessMatch) MatchLoop(ctx context.Context, logger runtime.Logger, db *s } } + // Check for move timeout (3 days without a move = forfeit) + if tick%60 == 0 && !s.GameOver && s.WhiteID != "" && s.BlackID != "" && s.LastMoveAt > 0 { + now := time.Now().Unix() + const moveTimeout = 3 * 24 * 60 * 60 // 3 days in seconds + + if (now - s.LastMoveAt) > moveTimeout { + // Current player (whose turn it is) forfeits + if s.Turn == "w" { + logger.Info("White player forfeit due to move timeout (3 days)") + s.GameOver = true + s.Result = "0-1" + updateElo(ctx, nk, logger, s.BlackID, s.BlackName, s.WhiteID, s.WhiteName, false) + } else { + logger.Info("Black player forfeit due to move timeout (3 days)") + s.GameOver = true + s.Result = "1-0" + updateElo(ctx, nk, logger, s.WhiteID, s.WhiteName, s.BlackID, s.BlackName, false) + } + + msg, _ := json.Marshal(map[string]interface{}{ + "result": s.Result, + "reason": "timeout - no move in 3 days", + }) + dispatcher.BroadcastMessage(OpCodeGameOver, msg, nil, nil, true) + + label := MatchLabel{ + Game: "chess960", + Status: "finished", + Result: s.Result, + White: s.WhiteName, + WhiteID: s.WhiteID, + Black: s.BlackName, + BlackID: s.BlackID, + PositionID: s.PositionID, + } + labelJSON, _ := json.Marshal(label) + dispatcher.MatchLabelUpdate(string(labelJSON)) + } + } + for _, message := range messages { playerID := message.GetUserId() @@ -559,6 +601,7 @@ func (m *ChessMatch) MatchLoop(ctx context.Context, logger runtime.Logger, db *s s.FEN = result.NewFEN s.Turn = result.Turn s.MoveHistory = append(s.MoveHistory, fmt.Sprintf("%s-%s", moveData.From, moveData.To)) + s.LastMoveAt = time.Now().Unix() // Reset move timer // ALWAYS broadcast the move to all players/spectators first moveMsg, _ := json.Marshal(map[string]interface{}{ @@ -662,60 +705,11 @@ func (m *ChessMatch) MatchLeave(ctx context.Context, logger runtime.Logger, db * } } - // Player leaving + // Player leaving (disconnect/refresh) if s.WhiteID == userID || s.BlackID == userID { - logger.Info("Player %s left the match", username) - - // If waiting for opponent, don't terminate immediately - if s.WhiteID != "" && s.BlackID == "" && userID == s.WhiteID { - logger.Info("White player disconnected from waiting challenge") - return s - } - - // If game in progress, opponent wins by forfeit - if !s.GameOver && s.WhiteID != "" && s.BlackID != "" { - isWhiteLeaving := userID == s.WhiteID - s.GameOver = true - if isWhiteLeaving { - s.Result = "0-1" - } else { - s.Result = "1-0" - } - - var winnerID, winnerName, loserName string - if isWhiteLeaving { - winnerID = s.BlackID - winnerName = s.BlackName - loserName = s.WhiteName - } else { - winnerID = s.WhiteID - winnerName = s.WhiteName - loserName = s.BlackName - } - updateElo(ctx, nk, logger, winnerID, winnerName, userID, loserName, false) - - msg, _ := json.Marshal(map[string]interface{}{ - "result": s.Result, - "reason": "forfeit", - }) - dispatcher.BroadcastMessage(OpCodeGameOver, msg, nil, nil, true) - - // Update match label to finished so it's not detected as active - label := MatchLabel{ - Game: "chess960", - Status: "finished", - Result: s.Result, - White: s.WhiteName, - WhiteID: s.WhiteID, - Black: s.BlackName, - BlackID: s.BlackID, - PositionID: s.PositionID, - } - labelJSON, _ := json.Marshal(label) - dispatcher.MatchLabelUpdate(string(labelJSON)) - - logger.Info("Game forfeited: %s", s.Result) - } + logger.Info("Player %s disconnected from match (can rejoin anytime)", username) + // Games persist indefinitely - player can rejoin anytime + // No forfeit on disconnect - only manual resign ends the game } } @@ -1054,17 +1048,21 @@ func listChessMatchesRpc(ctx context.Context, logger runtime.Logger, db *sql.DB, } userID, _ := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string) + logger.Info("listChessMatches: found %d total matches, filtering by status=%s", len(matches), params.Status) var result []map[string]interface{} for _, match := range matches { var label MatchLabel if err := json.Unmarshal([]byte(match.Label.Value), &label); err != nil { + logger.Warn("listChessMatches: failed to parse label for match %s", match.MatchId) continue } if label.Game != "chess960" { continue } + logger.Info("listChessMatches: match %s has status=%s", match.MatchId, label.Status) + // Filter by status if params.Status != "" && params.Status != "all" && label.Status != params.Status { continue diff --git a/openresty/lua/thumbnail.lua b/openresty/lua/thumbnail.lua index 080b4ea..b2f02ab 100644 --- a/openresty/lua/thumbnail.lua +++ b/openresty/lua/thumbnail.lua @@ -36,7 +36,7 @@ local function get_stream_key_for_realm(realm_name) local http = require "resty.http" local httpc = http.new() - local res, err = httpc:request_uri("http://backend:3000/internal/realm-stream-key/" .. ngx.escape_uri(realm_name), { + local res, err = httpc:request_uri("http://drogon-backend:8080/internal/realm-stream-key/" .. ngx.escape_uri(realm_name), { method = "GET", headers = { ["Content-Type"] = "application/json" } }) @@ -64,7 +64,7 @@ local function get_live_realms() local http = require "resty.http" local httpc = http.new() - local res, err = httpc:request_uri("http://backend:3000/api/realms/live", { + local res, err = httpc:request_uri("http://drogon-backend:8080/api/realms/live", { method = "GET", headers = { ["Content-Type"] = "application/json" } })