From 3dcd8df4c7a2b7fd8caa5eb32764facbc608ae1b Mon Sep 17 00:00:00 2001 From: AfonsoCMSousa Date: Sat, 3 Jan 2026 02:19:00 +0000 Subject: [PATCH] SyntaxSquad/DADProject#16 fix: Cancle and Return is working and some other fixes involving game duration --- api/app/Http/Controllers/GameController.php | 23 +- api/routes/api.php | 2 +- .../src/pages/game/MultiplayerGamePage.vue | 92 +++++-- frontend/src/pages/home/HomePage.vue | 2 + frontend/src/pages/user/UserPage.vue | 8 +- frontend/src/stores/socket.js | 1 - websockets/events/game.js | 1 + websockets/server.js | 239 +++++++++--------- websockets/state/game.js | 2 - 9 files changed, 227 insertions(+), 143 deletions(-) diff --git a/api/app/Http/Controllers/GameController.php b/api/app/Http/Controllers/GameController.php index dbaf22f..949a347 100644 --- a/api/app/Http/Controllers/GameController.php +++ b/api/app/Http/Controllers/GameController.php @@ -131,7 +131,7 @@ class GameController extends Controller $player2Id = $request->input('player2_user_id', self::GHOST_ID); $initialStatus = $request->input('status', 'Pending'); - + // If specific player 2 is provided, auto-start game if ($player2Id !== self::GHOST_ID && !$request->has('status')) { $initialStatus = 'Playing'; @@ -141,7 +141,7 @@ class GameController extends Controller "type" => $validated["type"], "status" => $initialStatus, "player1_user_id" => $user->id, - "player2_user_id" => $player2Id, + "player2_user_id" => $player2Id, "winner_user_id" => null, "loser_user_id" => null, "match_id" => $request->input('match_id', null), @@ -168,7 +168,7 @@ class GameController extends Controller if ($game->player1_user_id === $request->user()->id) { return response()->json(['message' => 'Cannot join your own game'], 400); } - + // Check if joining user has another active game $activeGame = Game::where(function ($q) use ($request) { $q->where("player1_user_id", $request->user()->id) @@ -244,4 +244,19 @@ class GameController extends Controller 'game' => $game->fresh(), ]); } -} \ No newline at end of file + + public function destroy(Game $game) + { + // Ensure only the host can delete, and ONLY if it's still Pending + if ($game->player1_user_id !== auth()->id()) { + return response()->json(['message' => 'Unauthorized'], 403); + } + + if ($game->status !== 'Pending') { + return response()->json(['message' => 'Cannot delete a game in progress'], 400); + } + + $game->delete(); + return response()->noContent(); + } +} diff --git a/api/routes/api.php b/api/routes/api.php index e4b0a6b..d04aa94 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -70,7 +70,7 @@ Route::prefix('v1')->group(function () { ->group(function () { Route::get('/statistics', [StatisticsController::class, 'getAdminStats']); Route::get('/transactions', [TransactionController::class, 'index']); - Route::get('/games', [GameController::class, 'index']); + Route::get('/games', [GameController::class, 'index']); Route::get('/matches', [MatchController::class, 'index']); Route::prefix('users')->group(function () { Route::get('/', [UserController::class, 'index']); diff --git a/frontend/src/pages/game/MultiplayerGamePage.vue b/frontend/src/pages/game/MultiplayerGamePage.vue index 608bc26..e07da98 100644 --- a/frontend/src/pages/game/MultiplayerGamePage.vue +++ b/frontend/src/pages/game/MultiplayerGamePage.vue @@ -291,18 +291,88 @@ const handleBeforeUnload = (event) => { } }; -onBeforeRouteLeave((to, from, next) => { - if (gameOver.value || !shouldShowBoard.value) { +// --- FIX: SURRENDER LOGIC --- +const handleSurrender = () => { + if(confirm('Are you sure you want to surrender? You will lose.')) { + wsStore.surrender(gameId.value) + } +} + +const handleCloseModal = async () => { + // 1. If game is still in Lobby (Pending) and I am the Host, DELETE it. + if (!shouldShowBoard.value && amIHost.value && !gameOver.value) { + try { + // This calls Route::delete('/{game}', ...) in your api.php + await axios.delete(`${API_BASE_URL}/games/${gameId.value}`); + console.log("Lobby cancelled and deleted."); + } catch (e) { + console.error("Failed to delete pending game:", e); + } + } + + // 2. Notify server of voluntary exit (to skip the 10s reconnect timer) + if (wsStore.socket) { + wsStore.socket.emit('notify_disconnect'); + } + + // 3. Disconnect and go home + wsStore.disconnect(); + router.push({ name: 'home' }); +} + +// --- LIFECYCLE --- + +onBeforeRouteLeave(async (to, from, next) => { + // CASE A: Game is Pending (Waiting Room) + if (!shouldShowBoard.value && !gameOver.value) { + if (amIHost.value) { + // Ask for confirmation to close the lobby + const confirmClose = window.confirm("This will cancel the lobby. Are you sure?"); + if (confirmClose) { + try { + // Delete the game from DB so it doesn't get stuck as "Pending" + await axios.delete(`${API_BASE_URL}/games/${gameId.value}`); + } catch(e) { console.error(e) } + + if(wsStore.socket) wsStore.socket.emit('notify_disconnect'); + wsStore.disconnect(); + next(); + } else { + next(false); // Stay on page + } + return; + } else { + // If I am NOT host (just a guest waiting), just leave + if(wsStore.socket) wsStore.socket.emit('notify_disconnect'); + wsStore.disconnect(); + next(); + return; + } + } + + // CASE B: Game is Over (Standard exit) + if (gameOver.value) { next(); return; } + // CASE C: Game is Active/Playing (The Surrender Logic we built before) const answer = window.confirm( "If you leave, you will be register has 'losing' or 'surrendered', Are you sure you want to leave?" ); if (answer) { - wsStore.surrender(gameId.value); + // ... (Your existing surrender logic) ... + try { await wsStore.surrender(gameId.value); } catch (e) {} + + if (wsStore.socket) { + wsStore.socket.emit('notify_disconnect', () => { + wsStore.disconnect(); + next(); + }); + setTimeout(() => { if (wsStore.connected) { wsStore.disconnect(); next(); } }, 500); + return; + } wsStore.disconnect(); next(); } else { @@ -310,22 +380,6 @@ onBeforeRouteLeave((to, from, next) => { } }); -// --- FIX: SURRENDER LOGIC --- -const handleSurrender = () => { - if(confirm('Are you sure you want to surrender? You will lose.')) { - // Send event to server. Do NOT disconnect locally yet. - // Wait for the server to send "Game Over" event back. - wsStore.surrender(gameId.value) - } -} - -const handleCloseModal = () => { - wsStore.disconnect() - router.push({ name: 'home' }) -} - -// --- LIFECYCLE --- - onMounted(async () => { await fetchGameMetadata() wsStore.connect() diff --git a/frontend/src/pages/home/HomePage.vue b/frontend/src/pages/home/HomePage.vue index 78851dc..a94705b 100644 --- a/frontend/src/pages/home/HomePage.vue +++ b/frontend/src/pages/home/HomePage.vue @@ -97,6 +97,8 @@ const getPendingMatches = async (mode = selectedMode.value) => { mLoading.value = true; try { + // FIX: Incorrect endpoint used previously + // TAG: 404 const response = await axios.get(`${API_BASE_URL}/matches/open`, { params: { type: mode, diff --git a/frontend/src/pages/user/UserPage.vue b/frontend/src/pages/user/UserPage.vue index 66484d6..36b6c9c 100644 --- a/frontend/src/pages/user/UserPage.vue +++ b/frontend/src/pages/user/UserPage.vue @@ -621,7 +621,8 @@
-
{{ game.amount }} pts
+ +
{{ game.points }} pts
{{ new Date(game.began_at).toLocaleDateString() }} • {{ @@ -1185,10 +1186,15 @@ const fetchData = async () => { return { id: game.id, + player1_id: game.player1.id, + player2_id: game.player2.id, variant: `Type ${game.type}`, outcome: outcome, opponent: null, amount: game.total_points || 0, + points: game.player1_user_id === authStore.currentUser.id + ? game.player1_points + : game.player2_points, duration: `${Math.floor(game.total_time / 60)}m ${game.total_time % 60}s`, began_at: game.began_at, details: { diff --git a/frontend/src/stores/socket.js b/frontend/src/stores/socket.js index b77d0a7..90b800e 100644 --- a/frontend/src/stores/socket.js +++ b/frontend/src/stores/socket.js @@ -92,7 +92,6 @@ export const useSocketStore = defineStore('websocket', () => { socket.value.emit('play-card', { gameId, cardId }) } - // --- NEW: Surrender Action --- const surrender = (gameId) => { if (!socket.value?.connected) return console.log('[Game] Surrendering:', gameId) diff --git a/websockets/events/game.js b/websockets/events/game.js index 610a9ba..8723a39 100644 --- a/websockets/events/game.js +++ b/websockets/events/game.js @@ -96,6 +96,7 @@ const performGameEnd = (io, game, gameId, winnerId, loserId, reason) => { action: "game_ended", winnerId: parseInt(winnerId), loserId: parseInt(loserId), + totalTime: game.startTime ? Math.floor((Date.now() - game.startTime) / 1000) : 0, isDraw: false, player1_points: game.players[Object.keys(game.players)[0]]?.points || 0, player2_points: game.players[Object.keys(game.players)[1]]?.points || 0, diff --git a/websockets/server.js b/websockets/server.js index a897102..1965ad4 100644 --- a/websockets/server.js +++ b/websockets/server.js @@ -6,128 +6,137 @@ import gameEvents from "./events/game.js"; import matchEvents from "./events/match.js"; export const server = { - io: null, + io: null, }; const API_URL = "http://localhost:8000/api/v1"; const disconnectTimers = new Map(); -const DISCONNECT_TIMEOUT = 1 * 60 * 1000; // 1 minutes +const RECONNECT_GRACE_PERIOD = 1 * 60 * 1000; // 1 minutes + +const userId = (socket) => socket.user && socket.user.id; export const serverStart = (port) => { - server.io = new Server(port, { - cors: { - origin: "*", - }, - }); - - server.io.use(async (socket, next) => { - let token = socket.handshake.auth.token; - - if (!token) { - return next(new Error("Authentication error: No token provided")); - } - - if (!token.startsWith("Bearer ")) { - token = "Bearer " + token; - } - - try { - const response = await axios.get(`${API_URL}/users/me`, { - headers: { - Authorization: token, - Accept: "application/json" + server.io = new Server(port, { + cors: { + origin: "*", }, - }); - - const user = response.data.data || response.data; - - if (!user || !user.id) { - return next(new Error("Authentication error: User data not found")); - } - - socket.user = user; - socket.token = token; - socket.handshake.auth.token = token; - - if (disconnectTimers.has(user.id)) { - console.log(`[Connection] User ${user.id} reconnected! Cancelling surrender timer.`); - clearTimeout(disconnectTimers.get(user.id)); - disconnectTimers.delete(user.id); - } - - addUser(socket.id, user); - - next(); - } catch (error) { - if (error.response) { - console.error( - "❌ Erro Laravel:", - error.response.status, - error.response.data - ); - } else { - console.error("❌ Erro Rede:", error.message); - } - next(new Error("Authentication error: Invalid token")); - } - }); - - server.io.on("connection", (socket) => { - gameEvents(server.io, socket); - console.log(`[Connection] User ${userId} connected. Socket ID: ${socket.id}`); - - matchEvents(server.io, socket); - - handleConnectionEvents(server.io, socket); - - socket.on("disconnect", () => { - // Remove from active socket list - removeUser(socket.id); - - if (userId) { - console.log(`[Disconnect] User ${userId} disconnected. Waiting ${RECONNECT_GRACE_PERIOD/1000}s...`); - - // ----------------------------------------------------------- - // 2. START SURRENDER TIMER - // ----------------------------------------------------------- - const timer = setTimeout(async () => { - console.log(`[Timeout] User ${userId} did not return. Forcing surrender.`); - - // A. Find the Game ID this user is playing (You need a way to look this up) - // Ideally, your 'socket' object or a global map knows which gameID the user is in. - // For this example, let's assume you stored `socket.activeGameId` when they joined. - const gameId = socket.activeGameId; - - if (gameId) { - try { - // B. Call Laravel API to resign on their behalf - // We use the token we saved during the handshake - await axios.post(`${API_URL}/games/${gameId}/resign`, {}, { - headers: { - Authorization: socket.handshake.auth.token, - Accept: "application/json" - } - }); - - // C. Notify the room (The API likely triggers a Pusher/Socket event, - // but we can also emit locally if needed) - server.io.to(gameId).emit("game_over", { - winner: "opponent", - reason: "disconnect" - }); - - console.log(`[Timeout] Successfully resigned game ${gameId} for user ${userId}`); - } catch (err) { - console.error(`[Timeout] Failed to resign game for user ${userId}:`, err.message); - } - } - - disconnectTimers.delete(userId); - }, RECONNECT_GRACE_PERIOD); - - disconnectTimers.set(userId, timer); - } }); - }); + + server.io.use(async (socket, next) => { + let token = socket.handshake.auth.token; + + if (!token) { + return next(new Error("Authentication error: No token provided")); + } + + if (!token.startsWith("Bearer ")) { + token = "Bearer " + token; + } + + try { + const response = await axios.get(`${API_URL}/users/me`, { + headers: { + Authorization: token, + Accept: "application/json" + }, + }); + + const user = response.data.data || response.data; + + if (!user || !user.id) { + return next(new Error("Authentication error: User data not found")); + } + + socket.user = user; + socket.token = token; + socket.handshake.auth.token = token; + + if (disconnectTimers.has(user.id)) { + console.log(`[Connection] User ${user.id} reconnected! Cancelling surrender timer.`); + clearTimeout(disconnectTimers.get(user.id)); + disconnectTimers.delete(user.id); + } + + addUser(socket.id, user); + + next(); + } catch (error) { + if (error.response) { + console.error( + "❌ Erro Laravel:", + error.response.status, + error.response.data + ); + } else { + console.error("❌ Erro Rede:", error.message); + } + next(new Error("Authentication error: Invalid token")); + } + }); + + server.io.on("connection", (socket) => { + gameEvents(server.io, socket); + matchEvents(server.io, socket); + + handleConnectionEvents(server.io, socket); + + socket.on("notify_disconnect", () => { + socket.isVoluntaryDisconnect = true; + }); + + socket.on("disconnect", () => { + // Remove from active socket list + removeUser(socket.id); + + if (socket.isVoluntaryDisconnect) { + console.log(`[Disconnect] User ${userId} disconnected voluntarily. No surrender needed.`); + return; + } + + if (userId) { + console.log(`[Disconnect] User ${userId} disconnected. Waiting ${RECONNECT_GRACE_PERIOD / 1000}s...`); + + // ----------------------------------------------------------- + // 2. START SURRENDER TIMER + // ----------------------------------------------------------- + const timer = setTimeout(async () => { + console.log(`[Timeout] User ${userId} did not return. Forcing surrender.`); + + // A. Find the Game ID this user is playing (You need a way to look this up) + // Ideally, your 'socket' object or a global map knows which gameID the user is in. + // For this example, let's assume you stored `socket.activeGameId` when they joined. + const gameId = socket.activeGameId; + + if (gameId) { + try { + // B. Call Laravel API to resign on their behalf + // We use the token we saved during the handshake + await axios.post(`${API_URL}/games/${gameId}/resign`, {}, { + headers: { + Authorization: socket.handshake.auth.token, + Accept: "application/json" + } + }); + + // C. Notify the room (The API likely triggers a Pusher/Socket event, + // but we can also emit locally if needed) + server.io.to(gameId).emit("game_over", { + winner: "opponent", + reason: "disconnect" + }); + + console.log(`[Timeout] Successfully resigned game ${gameId} for user ${userId}`); + } catch (err) { + console.error(`[Timeout] Failed to resign game for user ${userId}:`, err.message); + } + } + + disconnectTimers.delete(userId); + }, RECONNECT_GRACE_PERIOD); + + disconnectTimers.set(userId, timer); + } + }); + }); }; diff --git a/websockets/state/game.js b/websockets/state/game.js index 899351b..55f3e17 100644 --- a/websockets/state/game.js +++ b/websockets/state/game.js @@ -33,5 +33,3 @@ export const removeGame = (id) => { } }; -export { createGame, getGame, removeGame }; -