From 23d0ea134346e57e6e47c840e76ec7270b69040e Mon Sep 17 00:00:00 2001 From: AfonsoCMSousa Date: Fri, 2 Jan 2026 20:12:18 +0000 Subject: [PATCH 1/6] SyntaxSquad/DADProject#16 fix: Timer issues and Forfeight button --- websockets/state/game.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/websockets/state/game.js b/websockets/state/game.js index 59691cb..372876a 100644 --- a/websockets/state/game.js +++ b/websockets/state/game.js @@ -33,9 +33,4 @@ const removeGame = (id) => { } }; -module.exports = { - createGame, - getGame, - removeGame, -}; - +export { createGame, getGame, removeGame }; From 1088749386c42c80b09723336914800642efe199 Mon Sep 17 00:00:00 2001 From: AfonsoCMSousa Date: Sat, 3 Jan 2026 00:51:42 +0000 Subject: [PATCH 2/6] SyntaxSquad/DADProject#16 fix: Surrender if leave --- .../src/pages/game/MultiplayerGamePage.vue | 30 +++++++++- websockets/events/game.js | 3 +- websockets/server.js | 59 ++++++++++++++++++- 3 files changed, 88 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/game/MultiplayerGamePage.vue b/frontend/src/pages/game/MultiplayerGamePage.vue index fe0d7cf..608bc26 100644 --- a/frontend/src/pages/game/MultiplayerGamePage.vue +++ b/frontend/src/pages/game/MultiplayerGamePage.vue @@ -91,7 +91,7 @@ \ No newline at end of file + diff --git a/frontend/src/components/ui/label/Label.vue b/frontend/src/components/ui/label/Label.vue new file mode 100644 index 0000000..b20aec0 --- /dev/null +++ b/frontend/src/components/ui/label/Label.vue @@ -0,0 +1,29 @@ + + + diff --git a/frontend/src/components/ui/label/index.js b/frontend/src/components/ui/label/index.js new file mode 100644 index 0000000..38eaa35 --- /dev/null +++ b/frontend/src/components/ui/label/index.js @@ -0,0 +1 @@ +export { default as Label } from "./Label.vue"; diff --git a/frontend/src/components/ui/switch/Switch.vue b/frontend/src/components/ui/switch/Switch.vue new file mode 100644 index 0000000..215ffac --- /dev/null +++ b/frontend/src/components/ui/switch/Switch.vue @@ -0,0 +1,49 @@ + + + diff --git a/frontend/src/components/ui/switch/index.js b/frontend/src/components/ui/switch/index.js new file mode 100644 index 0000000..c986f8a --- /dev/null +++ b/frontend/src/components/ui/switch/index.js @@ -0,0 +1 @@ +export { default as Switch } from "./Switch.vue"; diff --git a/frontend/src/pages/game/MultiplayerMatchPage.vue b/frontend/src/pages/game/MultiplayerMatchPage.vue new file mode 100644 index 0000000..003506c --- /dev/null +++ b/frontend/src/pages/game/MultiplayerMatchPage.vue @@ -0,0 +1,112 @@ + + + diff --git a/frontend/src/pages/home/HomePage.vue b/frontend/src/pages/home/HomePage.vue index a94705b..fb52f5d 100644 --- a/frontend/src/pages/home/HomePage.vue +++ b/frontend/src/pages/home/HomePage.vue @@ -1,18 +1,24 @@ diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index b2c0293..efc2070 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -10,6 +10,7 @@ import TestAllAnimations from '@/pages/TestAllAnimations.vue' import TestGameBoard from '@/pages/TestGameBoard.vue' import SinglePlayerGamePage from '@/pages/game/SinglePlayerGamePage.vue' import MultiplayerGamePage from '@/pages/game/MultiplayerGamePage.vue' +import MultiplayerMatchPage from '@/pages/game/MultiplayerMatchPage.vue' import RegisterPage from '@/pages/register/RegisterPage.vue' import CoinsPurchasePage from '@/pages/purchase/CoinPurchasePage.vue' import { useAuthStore } from '@/stores/auth' @@ -54,6 +55,12 @@ const router = createRouter({ component: MultiplayerGamePage, meta: { requiresAuth: true } }, + { + path: '/match/:id', + name: 'multiplayer-match', + component: MultiplayerMatchPage, + meta: { requiresAuth: true } + }, { path: '/user', component: UserPage, diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c93be32 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "DADProject", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} From 0fb64ff30c7b0975d2f8b5cc4555e64bdfb23bb3 Mon Sep 17 00:00:00 2001 From: AfonsoCMSousa Date: Sat, 3 Jan 2026 13:14:37 +0000 Subject: [PATCH 5/6] SyntaxSquad/DADProject#16 feat: MATCHES AND STUFF --- .../src/pages/game/MultiplayerGamePage.vue | 318 ++++++------ .../src/pages/game/MultiplayerMatchPage.vue | 454 ++++++++++++++---- frontend/src/pages/home/HomePage.vue | 432 +++++------------ 3 files changed, 616 insertions(+), 588 deletions(-) diff --git a/frontend/src/pages/game/MultiplayerGamePage.vue b/frontend/src/pages/game/MultiplayerGamePage.vue index e07da98..c70bd9f 100644 --- a/frontend/src/pages/game/MultiplayerGamePage.vue +++ b/frontend/src/pages/game/MultiplayerGamePage.vue @@ -1,26 +1,26 @@ @@ -115,11 +94,14 @@ const timeLeft = ref(20) const timerInterval = ref(null) const hasConnectedOnce = ref(false) +// --- STATE --- +const matchState = computed(() => wsStore.gameState || { id: gameId.value }) + const gameMetadata = ref({ - p1_id: null, - p1_name: 'Player 1', - p2_id: null, - p2_name: 'Opponent' + p1_id: null, + p1_name: 'Player 1', + p2_id: null, + p2_name: 'Opponent' }) const displayedTrick = ref({ playerCard: null, opponentCard: null }) @@ -130,38 +112,38 @@ const trickClearTimer = ref(null) const myUserId = computed(() => authStore.currentUser?.id) const shouldShowBoard = computed(() => { - const s = wsStore.gameState; - if (s && s.player2 && s.player2.id > 1) { - return true; - } - return false; + const s = wsStore.gameState; + if (s && s.player2 && s.player2.id > 1) { + return true; + } + return false; }) const amIHost = computed(() => { - if (gameMetadata.value.p1_id) { - return String(gameMetadata.value.p1_id) === String(myUserId.value) - } - const p1Id = wsStore.gameState?.player1_user_id || wsStore.gameState?.player1?.id - return String(p1Id) === String(myUserId.value) + if (gameMetadata.value.p1_id) { + return String(gameMetadata.value.p1_id) === String(myUserId.value) + } + const p1Id = wsStore.gameState?.player1_user_id || wsStore.gameState?.player1?.id + return String(p1Id) === String(myUserId.value) }) const myDisplayName = computed(() => amIHost.value ? gameMetadata.value.p1_name : gameMetadata.value.p2_name) const opponentDisplayName = computed(() => amIHost.value ? gameMetadata.value.p2_name : gameMetadata.value.p1_name) const mappedTrick = computed(() => { - const table = wsStore.gameState?.table - if (!table) return { playerCard: null, opponentCard: null } - if (amIHost.value) { - return { playerCard: table.playerCard, opponentCard: table.opponentCard } - } else { - return { playerCard: table.opponentCard, opponentCard: table.playerCard } - } + const table = wsStore.gameState?.table + if (!table) return { playerCard: null, opponentCard: null } + if (amIHost.value) { + return { playerCard: table.playerCard, opponentCard: table.opponentCard } + } else { + return { playerCard: table.opponentCard, opponentCard: table.playerCard } + } }) const currentTurnLabel = computed(() => { - const turnId = wsStore.gameState?.currentTurn - if (!turnId) return 'player' - return String(turnId) === String(myUserId.value) ? 'player' : 'opponent' + const turnId = wsStore.gameState?.currentTurn + if (!turnId) return 'player' + return String(turnId) === String(myUserId.value) ? 'player' : 'opponent' }) const trumpCard = computed(() => wsStore.gameState?.trumpCard || null) @@ -178,34 +160,34 @@ const opponentHand = computed(() => { // --- WATCHERS --- watch(() => wsStore.connected, (isConnected) => { - if (isConnected) { - hasConnectedOnce.value = true - wsStore.joinGame(gameId.value) - } + if (isConnected) { + hasConnectedOnce.value = true + wsStore.joinGame(gameId.value) + } }, { immediate: true }) watch(() => wsStore.gameState, async (newState, oldState) => { - const oldP2 = oldState?.player2?.id; - const newP2 = newState?.player2?.id; - if (newP2 && newP2 > 1 && newP2 !== oldP2) { - await fetchGameMetadata(); - } + const oldP2 = oldState?.player2?.id; + const newP2 = newState?.player2?.id; + if (newP2 && newP2 > 1 && newP2 !== oldP2) { + await fetchGameMetadata(); + } }, { deep: true }) watch(mappedTrick, (newVal) => { - if (newVal.playerCard || newVal.opponentCard) { - if (trickClearTimer.value) clearTimeout(trickClearTimer.value) - displayedTrick.value = { ...newVal } + if (newVal.playerCard || newVal.opponentCard) { + if (trickClearTimer.value) clearTimeout(trickClearTimer.value) + displayedTrick.value = { ...newVal } + } else { + const currentlyShowing = displayedTrick.value.playerCard || displayedTrick.value.opponentCard + if (currentlyShowing) { + trickClearTimer.value = setTimeout(() => { + displayedTrick.value = { playerCard: null, opponentCard: null } + }, 1500) } else { - const currentlyShowing = displayedTrick.value.playerCard || displayedTrick.value.opponentCard - if (currentlyShowing) { - trickClearTimer.value = setTimeout(() => { - displayedTrick.value = { playerCard: null, opponentCard: null } - }, 1500) - } else { - displayedTrick.value = { playerCard: null, opponentCard: null } - } + displayedTrick.value = { playerCard: null, opponentCard: null } } + } }, { deep: true }) // --- FIX: TIMER GHOSTING --- @@ -213,8 +195,8 @@ const startTimer = () => { if (timerInterval.value) clearInterval(timerInterval.value) if (!shouldShowBoard.value && wsStore.gameState?.status !== 'playing') { - timeLeft.value = 20; - return; + timeLeft.value = 20; + return; } // FORCE RESET to avoid showing old time for 1 frame @@ -238,44 +220,44 @@ const startTimer = () => { } watch(() => wsStore.gameState?.currentTurn, (newTurn) => { - if (newTurn) startTimer() + if (newTurn) startTimer() }, { immediate: true }) const animateDeal = async (cards) => { - if (isDealing.value) return - isDealing.value = true - visibleHand.value = [] - for (const card of cards) { - visibleHand.value.push(card) - await new Promise(r => setTimeout(r, 150)) - } - isDealing.value = false + if (isDealing.value) return + isDealing.value = true + visibleHand.value = [] + for (const card of cards) { + visibleHand.value.push(card) + await new Promise(r => setTimeout(r, 150)) + } + isDealing.value = false } watch(serverHand, (newHand) => { - if (visibleHand.value.length === 0 && newHand.length > 0 && !hasInitialDealHappened.value) { - animateDeal(newHand) - hasInitialDealHappened.value = true - } else if (!isDealing.value) { - visibleHand.value = [...newHand] - } + if (visibleHand.value.length === 0 && newHand.length > 0 && !hasInitialDealHappened.value) { + animateDeal(newHand) + hasInitialDealHappened.value = true + } else if (!isDealing.value) { + visibleHand.value = [...newHand] + } }, { deep: true }) // --- METHODS --- const fetchGameMetadata = async () => { - try { - const response = await axios.get(`${API_BASE_URL}/games/${gameId.value}`) - const game = response.data.data || response.data - gameMetadata.value = { - p1_id: game.player1_user_id, - p1_name: game.player1?.nickname || 'Host', - p2_id: game.player2_user_id, - p2_name: game.player2?.nickname || 'Opponent' - } - } catch (e) { - console.error("Failed to load metadata", e) + try { + const response = await axios.get(`${API_BASE_URL}/games/${gameId.value}`) + const game = response.data.data || response.data + gameMetadata.value = { + p1_id: game.player1_user_id, + p1_name: game.player1?.nickname || 'Host', + p2_id: game.player2_user_id, + p2_name: game.player2?.nickname || 'Opponent' } + } catch (e) { + console.error("Failed to load metadata", e) + } } const handlePlayCard = (card) => { @@ -293,31 +275,31 @@ const handleBeforeUnload = (event) => { // --- FIX: SURRENDER LOGIC --- const handleSurrender = () => { - if(confirm('Are you sure you want to surrender? You will lose.')) { - wsStore.surrender(gameId.value) - } + 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); - } + // 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'); - } + // 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' }); + // 3. Disconnect and go home + wsStore.disconnect(); + router.push({ name: 'home' }); } // --- LIFECYCLE --- @@ -325,29 +307,29 @@ const handleCloseModal = async () => { 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 (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; - } + 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) @@ -363,15 +345,15 @@ onBeforeRouteLeave(async (to, from, next) => { if (answer) { // ... (Your existing surrender logic) ... - try { await wsStore.surrender(gameId.value); } catch (e) {} + 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.socket.emit('notify_disconnect', () => { + wsStore.disconnect(); + next(); + }); + setTimeout(() => { if (wsStore.connected) { wsStore.disconnect(); next(); } }, 500); + return; } wsStore.disconnect(); next(); diff --git a/frontend/src/pages/game/MultiplayerMatchPage.vue b/frontend/src/pages/game/MultiplayerMatchPage.vue index 003506c..aadaa9f 100644 --- a/frontend/src/pages/game/MultiplayerMatchPage.vue +++ b/frontend/src/pages/game/MultiplayerMatchPage.vue @@ -1,112 +1,362 @@ + + - - diff --git a/frontend/src/pages/home/HomePage.vue b/frontend/src/pages/home/HomePage.vue index fb52f5d..059bf44 100644 --- a/frontend/src/pages/home/HomePage.vue +++ b/frontend/src/pages/home/HomePage.vue @@ -57,6 +57,8 @@ const userStore = useUserStore() const userLoggedIn = computed(() => authStore.isLoggedIn) const userCoins = ref(0) const hostLoading = ref(false) +const gHasMore = ref(true); +const mHasMore = ref(true); const setupMatchesObserver = () => { if (mObserver.value) mObserver.value.disconnect(); @@ -87,40 +89,40 @@ const setupGamesObserver = () => { } const getPendingGames = async (mode = selectedMode.value) => { - if (gLoading.value) return; + if (gLoading.value || !gHasMore.value) return; gLoading.value = true; try { const response = await axios.get(`${API_BASE_URL}/games/open`, { - params: { - type: mode, - page: gPage.value - } + params: { type: mode, page: gPage.value } }); - const newGames = response.data.data; - openGames.value = [...openGames.value, ...newGames]; + if (newGames.length === 0) { + gHasMore.value = false; + } else { + openGames.value = [...openGames.value, ...newGames]; + gPage.value++; + } } finally { gLoading.value = false; } } const getPendingMatches = async (mode = selectedMode.value) => { - if (mLoading.value) return; + if (mLoading.value || !mHasMore.value) return; mLoading.value = true; try { - // FIX: Incorrect endpoint used previously - // TAG: 404 const response = await axios.get(`${API_BASE_URL}/matches/open`, { - params: { - type: mode, - page: mPage.value - } + params: { type: mode, page: mPage.value } }); - const newMatches = response.data.data; - openMatches.value = [...openMatches.value, ...newMatches]; + if (newMatches.length === 0) { + mHasMore.value = false; + } else { + openMatches.value = [...openMatches.value, ...newMatches]; + mPage.value++; + } } finally { mLoading.value = false; } @@ -150,8 +152,8 @@ const openStats = async () => { const getAvatarUrl = (filename) => { return filename - ? `${API_BASE_URL.replace('/api/v1', '').replace('v1/')}/storage/photos_avatars/${filename}` - : `${API_BASE_URL.replace('/api/v1', '').replace('v1/')}/storage/photos_avatars/anonymous.png`; + ? `${API_BASE_URL.replace('/api/v1', '').replace('v1/', '')}/storage/photos_avatars/${filename}` + : `${API_BASE_URL.replace('/api/v1', '').replace('v1/', '')}/storage/photos_avatars/anonymous.png`; } const fetchLeaderboard = async () => { @@ -160,11 +162,9 @@ const fetchLeaderboard = async () => { try { const response = await apiStore.getLeaderboard({ page: lbPage.value }); const newEntries = response.data.data; - if (newEntries.length < 10) { lbMorePages.value = false; } - leaderboardEntries.value = [...leaderboardEntries.value, ...newEntries]; lbPage.value++; } finally { @@ -188,7 +188,6 @@ const setupLeaderboardObserver = () => { fetchLeaderboard(); } }, { threshold: 0.1 }); - if (lbObserverTarget.value) { lbObserver.value.observe(lbObserverTarget.value); } @@ -198,9 +197,11 @@ const clickMode = async (mode) => { if (selectedMode.value === mode) return; selectedMode.value = mode; openGames.value = []; - gPage.value = 1; openMatches.value = []; + gPage.value = 1; mPage.value = 1; + gHasMore.value = true; + mHasMore.value = true; await Promise.all([getPendingGames(), getPendingMatches()]); setupGamesObserver(); setupMatchesObserver(); @@ -210,136 +211,90 @@ const startSingle = () => { router.push({ name: 'bisca' + selectedMode.value }); } -const toggleReady = () => { - isReady.value = !isReady.value -} - -onMounted(async () => { - await fetchPublicStats(); - if (isLoggedIn) { - await Promise.all([getPendingGames(), getPendingMatches()]); - await nextTick(); - setupGamesObserver(); - setupMatchesObserver(); - } -}) - -// Update Host Game to handle Matches -const hostGame = async () => { - // 1. Validation - if (isBestOfThree.value) { - if (wagerAmount.value <= 0) { - alert("For a match, the stake must be at least 1 coin."); - return; - } - if (wagerAmount.value > userCoins.value) { - alert("You don't have enough coins to stake this amount!"); - return; - } - } - - hostLoading.value = true; // DISABLE BUTTON - - // 2. Prepare Payload - const endpoint = isBestOfThree.value ? '/matches' : '/games/host' - - // Note: Matches use 'stake', Games don't need it. - const payload = isBestOfThree.value - ? { type: parseInt(selectedMode.value), stake: parseInt(wagerAmount.value) } - : { type: parseInt(selectedMode.value) } - - try { - const response = await axios.post(`${API_BASE_URL}${endpoint}`, payload) - - const resultId = response.data.match?.id || response.data.id || response.data.data?.id; - - if (!resultId) { - throw new Error("Server response missing ID"); - } - - const routeName = isBestOfThree.value ? 'multiplayer-match' : 'multiplayer-game' - - console.log(`${isBestOfThree.value ? 'Match' : 'Game'} hosted:`, resultId) - - // Close modal immediately - showHostModal.value = false; - - router.push({ - name: routeName, - params: { id: resultId }, - query: { type: selectedMode.value } - }) - - } catch (error) { - console.error('Failed to host:', error); - - // Handle Validation Errors (422) - if (error.response?.data?.errors) { - const firstError = Object.values(error.response.data.errors)[0][0]; - alert(`Validation Error: ${firstError}`); - } - // Handle Logic Errors (e.g., "You already have a match") - else if (error.response?.data?.message) { - alert(error.response.data.message); - } - else { - alert('Failed to host: ' + error.message); - } - } finally { - hostLoading.value = false; // RE-ENABLE BUTTON - } -} - +// --- FIX: Correct Wager Logic --- const joinMatch = async (match) => { - selectedGame.value = match // Reusing selectedGame variable for modal context + // Use 'stake' because that's what the API returns + const cost = match.stake || match.wager || 0; - // Check balance - if (match.wager > userCoins.value) { - alert(`Insufficient funds! You need ${match.wager} coins to join this match.`) - return + if (cost > userCoins.value) { + toast.error(`Insufficient funds! You need ${cost} coins.`); + return; } - if (!confirm(`This match requires a wager of ${match.wager} coins. Do you want to join?`)) { + if (!confirm(`Join match for ${cost} coins?`)) { return; } try { await axios.post(`${API_BASE_URL}/matches/${match.id}/join`) router.push({ - name: 'multiplayer-match', // Redirect to Match Interface + name: 'multiplayer-match', params: { id: match.id }, query: { type: selectedMode.value } }) } catch (error) { - console.error('Failed to join match:', error) - alert(error.response?.data?.message || 'Failed to join match') + toast.error(error.response?.data?.message || 'Failed to join match'); + } +} + +const hostGame = async () => { + if (isBestOfThree.value) { + if (wagerAmount.value <= 0) { + toast.error("Stake must be at least 1 coin."); + return; + } + if (wagerAmount.value > userCoins.value) { + toast.error("Insufficient coins!"); + return; + } + } + + hostLoading.value = true; + const endpoint = isBestOfThree.value ? '/matches/host' : '/games/host' + const payload = isBestOfThree.value + ? { type: parseInt(selectedMode.value), stake: parseInt(wagerAmount.value) } + : { type: parseInt(selectedMode.value) } + + try { + const response = await axios.post(`${API_BASE_URL}${endpoint}`, payload) + const resultId = response.data.match?.id || response.data.id || response.data.data?.id; + + if (!resultId) throw new Error("Missing ID"); + + showHostModal.value = false; + router.push({ + name: isBestOfThree.value ? 'multiplayer-match' : 'multiplayer-game', + params: { id: resultId }, + query: { type: selectedMode.value } + }) + } catch (error) { + if (error.response?.data?.message) { + toast.error(error.response.data.message); + } else { + toast.error('Failed to host game'); + } + } finally { + hostLoading.value = false; } } const joinGame = async (game) => { - selectedGame.value = game - try { await axios.post(`${API_BASE_URL}/games/${game.id}/join`) - - console.log('Joined game:', game.id) - router.push({ name: 'multiplayer-game', params: { id: game.id }, query: { type: selectedMode.value } }) } catch (error) { - console.error('Failed to join game:', error) - console.error('Error details:', error.response?.data) - alert('Failed to join game: ' + (error.response?.data?.message || error.message)) + toast.error(error.response?.data?.message || 'Failed to join game'); } } const openHostModal = (forMatch) => { - isBestOfThree.value = forMatch; // Set the toggle automatically - showHostModal.value = true; // Open the modal - wagerAmount.value = 0; // Reset wager amount + isBestOfThree.value = forMatch; + showHostModal.value = true; + wagerAmount.value = 0; } watch(() => userLoggedIn, async (isLoggedIn) => { @@ -351,11 +306,19 @@ watch(() => userLoggedIn, async (isLoggedIn) => { } }, { immediate: true }) +onMounted(async () => { + await fetchPublicStats(); + if (isLoggedIn) { + await Promise.all([getPendingGames(), getPendingMatches()]); + await nextTick(); + setupGamesObserver(); + setupMatchesObserver(); + } +}) From ceba5178f3cf50be4fad141daa0a025aa1539c60 Mon Sep 17 00:00:00 2001 From: AfonsoCMSousa Date: Sat, 3 Jan 2026 13:14:59 +0000 Subject: [PATCH 6/6] SyntaxSquad/DADProject#16 fix: i forgot this --- api/app/Http/Controllers/MatchController.php | 51 +++- api/app/Models/CoinTransactionType.php | 16 +- api/routes/api.php | 1 + websockets/classes/BiscaGame.js | 7 +- websockets/classes/BiscaMatch.js | 270 ++++++++----------- websockets/events/match.js | 250 +++++++++-------- 6 files changed, 316 insertions(+), 279 deletions(-) diff --git a/api/app/Http/Controllers/MatchController.php b/api/app/Http/Controllers/MatchController.php index bcedc9a..220e291 100644 --- a/api/app/Http/Controllers/MatchController.php +++ b/api/app/Http/Controllers/MatchController.php @@ -255,7 +255,6 @@ class MatchController extends Controller public function open(Request $request) { - // FIX: Allow matches where P2 is NULL OR 'Ghost' (ID 1) $query = MatchGame::where('status', 'Pending') ->where(function($q) { $q->whereNull('player2_user_id') @@ -272,4 +271,54 @@ class MatchController extends Controller return response()->json($matches); } + + // --- FIX: Safely Delete Match by removing dependencies first --- + public function destroy(MatchGame $match) + { + $user = request()->user(); + + if ($match->player1_user_id !== $user->id) { + return response()->json(['message' => 'Unauthorized'], 403); + } + + if ($match->status !== 'Pending') { + return response()->json(['message' => 'Cannot cancel a match that has already started'], 400); + } + + return DB::transaction(function () use ($match, $user) { + // 1. Delete associated Games first (The FK constraint cause) + DB::table('games')->where('match_id', $match->id)->delete(); + + // 2. Unlink existing Coin Transactions (Set match_id to NULL) + CoinTransaction::where('match_id', $match->id)->update(['match_id' => null]); + + // 3. Process Refund + if ($match->stake > 0) { + $user->increment('coins_balance', $match->stake); + + $refundType = CoinTransactionType::firstOrCreate( + ['name' => 'Refund'], + ['type' => 'C'] + ); + + // Create Refund Transaction with NO LINK to the deleted match + CoinTransaction::create([ + 'user_id' => $user->id, + 'match_id' => null, // IMPORTANT: Must be null + 'coin_transaction_type_id' => $refundType->id, + 'transaction_datetime' => now(), + 'coins' => $match->stake, + 'custom' => "Refund for Match #{$match->id}" + ]); + } + + // 4. Finally delete the match + $match->delete(); + + return response()->json([ + 'message' => 'Match canceled and refunded', + 'balance' => $user->coins_balance + ]); + }); + } } diff --git a/api/app/Models/CoinTransactionType.php b/api/app/Models/CoinTransactionType.php index 6caca4b..cb42d53 100644 --- a/api/app/Models/CoinTransactionType.php +++ b/api/app/Models/CoinTransactionType.php @@ -4,17 +4,17 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\SoftDeletes; class CoinTransactionType extends Model { - use HasFactory, SoftDeletes; + use HasFactory; - protected $table = 'coin_transaction_types'; + // FIX: Disable auto-timestamps because the table doesn't have updated_at/created_at columns + public $timestamps = false; - protected $fillable = ['name', 'type', 'custom']; - - protected $casts = [ - 'custom' => 'array', + protected $fillable = [ + 'name', + 'type', + 'description' // Include description if your table has it ]; -} \ No newline at end of file +} diff --git a/api/routes/api.php b/api/routes/api.php index 7698599..0a57f82 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -62,6 +62,7 @@ Route::prefix('v1')->group(function () { Route::get('open', [MatchController::class, 'open']); Route::post('/{match}/join', [MatchController::class, 'join']); Route::post('/{match}/start', [MatchController::class, 'start']); // Changed to /start to be explicit + Route::delete('/{match}', [MatchController::class, 'destroy']); Route::apiResource('/', MatchController::class)->parameters(['' => 'match']); }); diff --git a/websockets/classes/BiscaGame.js b/websockets/classes/BiscaGame.js index 6542894..73d4e90 100644 --- a/websockets/classes/BiscaGame.js +++ b/websockets/classes/BiscaGame.js @@ -189,7 +189,9 @@ class BiscaGame { this.players[winnerId].tricks += 1; this.currentTurn = winnerId; - if (this.type == 3) { + // --- CRITICAL FIX: Always draw if cards remain --- + // Removed "if (this.type == 3)" check + if (this.deck.length > 0 || this.trumpCard) { this.drawCard(winnerId); this.drawCard(this.getOpponentId(winnerId)); } @@ -221,7 +223,7 @@ class BiscaGame { ? p2Obj.id : p1Obj.id; - return { + const result = { action: "game_ended", trickResult, winnerId: finalWinner, @@ -233,6 +235,7 @@ class BiscaGame { player2_points: p2Obj.points, totalTime: totalTime, }; + if (this.onGameEnd) { this.onGameEnd(result); } diff --git a/websockets/classes/BiscaMatch.js b/websockets/classes/BiscaMatch.js index 891c8dd..cb7aebe 100644 --- a/websockets/classes/BiscaMatch.js +++ b/websockets/classes/BiscaMatch.js @@ -4,9 +4,11 @@ import axios from "axios"; const API_URL = "http://localhost:8000/api/v1"; class BiscaMatch { - constructor(apiData, emitStateCallback, token) { + // FIX: Added autoPlayCallback parameter + constructor(apiData, emitStateCallback, token, autoPlayCallback) { this.id = apiData.id; this.emitStateCallback = emitStateCallback; + this.autoPlayCallback = autoPlayCallback; this.token = token; this.stake = apiData.stake; this.type = apiData.type; @@ -14,6 +16,8 @@ class BiscaMatch { this.player1Id = apiData.player1_user_id; this.player2Id = apiData.player2_user_id; + this.player1 = apiData.player1; + this.player2 = apiData.player2; this.marks = { [this.player1Id]: apiData.player1_marks || 0, @@ -33,11 +37,12 @@ class BiscaMatch { } } - async joinPlayer(userId) { + async joinPlayer(userId, user) { if (this.player2Id !== this.GHOST_ID) return false; if (userId === this.player1Id) return false; this.player2Id = userId; + this.player2 = user; this.marks[userId] = 0; this.points[userId] = 0; this.status = "Playing"; @@ -48,226 +53,185 @@ class BiscaMatch { getAuthHeaders() { return { - headers: { Authorization: this.token }, + headers: { + Authorization: this.token, + "Content-Type": "application/json", + "Accept": "application/json" + }, }; } async startNewGame() { - console.log(`[Match ${this.id}] A iniciar nova mão (Game DB)...`); - + console.log(`[Match ${this.id}] Starting new round...`); let newGameDbId = null; - try { - let payloadP1 = this.player1Id; - let payloadP2 = this.player2Id; - - if (this.player2Id !== this.GHOST_ID) { - console.log( - "🔄 Multiplayer detetado: Trocando ordem para satisfazer a API..." - ); - payloadP1 = this.player2Id; - payloadP2 = this.player1Id; - } - const payload = { type: this.type, - player1_user_id: payloadP1, - player2_user_id: payloadP2, + player1_user_id: this.player1Id, + player2_user_id: this.player2Id, match_id: this.id, status: "Playing", began_at: new Date().toISOString(), }; - - console.log("📤 [Payload Adaptado]:", JSON.stringify(payload, null, 2)); - - const res = await axios.post( - `${API_URL}/games`, - payload, - this.getAuthHeaders() - ); - + + const res = await axios.post(`${API_URL}/games`, payload, this.getAuthHeaders()); const gameData = res.data.game || res.data.data || res.data; newGameDbId = gameData.id; - console.log(`[Match ${this.id}] Game DB criado: #${newGameDbId}`); - - const apiP1 = gameData.player1_user_id; - const apiP2 = gameData.player2_user_id; - - const p1 = { id: apiP1, name: `User ${apiP1}` }; - const p2 = { id: apiP2, name: `User ${apiP2}` }; + const p1Name = this.player1?.nickname || `User ${this.player1Id}`; + const p2Name = this.player2?.nickname || `User ${this.player2Id}`; this.currentGame = new BiscaGame( this.id, this.type, - p1, - p2, + { id: this.player1Id, name: p1Name }, + { id: this.player2Id, name: p2Name }, newGameDbId, - (result) => { - this.handleGameEnd(result); - } + (result) => { this.handleGameEnd(result); } ); this.currentGame.init(); + + // FIX: Start Timer & Connect Callback + this.currentGame.startTurnTimer((uid, cid) => { + if (this.autoPlayCallback) this.autoPlayCallback(uid, cid); + }); if (this.emitStateCallback) { - this.emitStateCallback("match:new-round", this.getState()); + this.emitStateCallback("match:new-round-start", null); } } catch (e) { - if (e.response) { - console.error( - `❌ Erro API (${e.response.status}):`, - JSON.stringify(e.response.data) - ); - } else { - console.error(`❌ Erro Código: ${e.message}`); - } + console.error(`❌ Start Game Error: ${e.message}`); } } - async handleGameEnd(result) { - console.log( - `[Match ${this.id}] Mão terminada. Vencedor: ${result.winnerId}` - ); + // --- FIX: Close DB Game row on surrender --- + async abortCurrentGame(loserId) { + if (this.currentGame && this.currentGame.dbId) { + console.log(`[Match ${this.id}] Aborting Game #${this.currentGame.dbId}`); + try { + // Determine winner for the DB record based on who DIDN'T lose + const winnerId = loserId == this.player1Id ? this.player2Id : this.player1Id; + + const payload = { + status: "Ended", + winner_user_id: winnerId, + loser_user_id: loserId, + is_draw: false, + ended_at: new Date().toISOString(), + }; + await axios.put(`${API_URL}/games/${this.currentGame.dbId}`, payload, this.getAuthHeaders()); + } catch(e) { console.error("Error aborting game:", e.message); } + } + } - this.points[result.player1_id] += result.player1_points; - this.points[result.player2_id] += result.player2_points; + async handleGameEnd(result) { + console.log(`[Match ${this.id}] Round ended. Winner: ${result.winnerId}`); + + if (this.points[result.player1_id] !== undefined) this.points[result.player1_id] += result.player1_points; + if (this.points[result.player2_id] !== undefined) this.points[result.player2_id] += result.player2_points; if (this.currentGame && this.currentGame.dbId) { - try { - const payload = { - status: "Ended", - winner_user_id: result.winnerId, - loser_user_id: result.loserId, - is_draw: result.isDraw, - player1_points: result.player1_points, - player2_points: result.player2_points, - ended_at: new Date().toISOString(), - }; - - console.log( - `📤 A enviar update para Game #${this.currentGame.dbId}...` - ); - - // --- CORREÇÃO IMPORTANTE: Forçar o header aqui --- - const config = { - headers: { - Authorization: this.token, - "Content-Type": "application/json", - Accept: "application/json", - }, - }; - - await axios.put( - `${API_URL}/games/${this.currentGame.dbId}`, - payload, - config - ); - - console.log( - `✅ Game #${this.currentGame.dbId} atualizado com sucesso.` - ); - } catch (e) { - // --- LOG DETALHADO PARA VER O MOTIVO DO 403 --- - if (e.response) { - console.error(`❌ ERRO CRÍTICO API (${e.response.status}):`); - // Isto vai imprimir o JSON exato que o Laravel devolve - console.error(JSON.stringify(e.response.data, null, 2)); - } else { - console.error("Erro Axios:", e.message); - } - } - } else { - console.warn("⚠️ Ignorado: Não há ID de jogo na DB para atualizar."); + try { + const payload = { + status: "Ended", + winner_user_id: result.winnerId, + loser_user_id: result.loserId, + is_draw: result.isDraw, + player1_points: result.player1_points, + player2_points: result.player2_points, + ended_at: new Date().toISOString(), + }; + await axios.put(`${API_URL}/games/${this.currentGame.dbId}`, payload, this.getAuthHeaders()); + } catch (e) { console.error("DB Game Update Error:", e.message); } } - // ... (resto da lógica de atribuir marcas e finishMatch mantém-se igual) let marksToAdd = 0; let roundWinnerId = result.winnerId; if (roundWinnerId) { - const winningScore = - roundWinnerId == result.player1_id - ? result.player1_points - : result.player2_points; + const winningScore = roundWinnerId == result.player1_id ? result.player1_points : result.player2_points; if (winningScore === 120) marksToAdd = 4; else if (winningScore > 90) marksToAdd = 2; else if (winningScore >= 60) marksToAdd = 1; - this.marks[roundWinnerId] += marksToAdd; + + if (this.marks[roundWinnerId] !== undefined) this.marks[roundWinnerId] += marksToAdd; + } + + if (this.emitStateCallback) { + this.emitStateCallback("match:round-ended", { + winnerId: roundWinnerId, + marksAdded: marksToAdd, + p1Points: result.player1_points, + p2Points: result.player2_points, + p1Marks: this.marks[this.player1Id], + p2Marks: this.marks[this.player2Id], + reason: result.reason || 'points' + }); } if (this.marks[this.player1Id] >= 4 || this.marks[this.player2Id] >= 4) { await this.finishMatch(); - if (this.emitStateCallback) { - this.emitStateCallback("match:ended", this.getState()); - } + if (this.emitStateCallback) this.emitStateCallback("match:ended-signal", null); } else { - console.log(`[Match ${this.id}] Ninguém ganhou 4 marcas. A baralhar...`); - await this.startNewGame(); + setTimeout(() => { + this.startNewGame(); + }, 1000); } } async finishMatch() { this.status = "Ended"; + const winnerId = this.marks[this.player1Id] >= 4 ? this.player1Id : this.player2Id; + const loserId = winnerId == this.player1Id ? this.player2Id : this.player1Id; - const winnerId = - this.marks[this.player1Id] >= 4 ? this.player1Id : this.player2Id; - const loserId = - winnerId == this.player1Id ? this.player2Id : this.player1Id; + const payload = { + status: "Ended", + winner_user_id: winnerId, + loser_user_id: loserId, + player1_marks: this.marks[this.player1Id], + player2_marks: this.marks[this.player2Id], + player1_points: this.points[this.player1Id], + player2_points: this.points[this.player2Id], + }; try { - await axios.put( - `${API_URL}/matches/${this.id}`, - { - status: "Ended", - winner_user_id: winnerId, - loser_user_id: loserId, - player1_marks: this.marks[this.player1Id], - player2_marks: this.marks[this.player2Id], - player1_points: this.points[this.player1Id], - player2_points: this.points[this.player2Id], - }, - this.getAuthHeaders() - ); - console.log(`[Match ${this.id}] Encerrado com sucesso na API.`); + await axios.put(`${API_URL}/matches/${this.id}`, payload, this.getAuthHeaders()); + console.log(`[Match ${this.id}] Match Closed in DB.`); } catch (e) { - console.error(`Erro ao fechar Match API: ${e.message}`); + console.error(`Match Close Error: ${e.message}`); } } playCard(userId, cardId) { if (!this.currentGame || this.status !== "Playing") return; - return this.currentGame.playCard(userId, cardId); + + const result = this.currentGame.playCard(userId, cardId); + + // FIX: Restart timer if game continues + if (result && (result.action === 'next_turn' || result.action === 'trick_resolved')) { + this.currentGame.startTurnTimer((uid, cid) => { + if (this.autoPlayCallback) this.autoPlayCallback(uid, cid); + }); + } + + return result; } - getState() { - let gameState = null; - - if (this.currentGame) { - const p1Id = this.player1Id; - const p2Id = this.player2Id; - - if (this.currentGame.players && this.currentGame.players[p1Id]) { - gameState = this.currentGame.getStateForPlayer(p1Id); - } else if (this.currentGame.players && this.currentGame.players[p2Id]) { - gameState = this.currentGame.getStateForPlayer(p2Id); - } else if (this.currentGame.players) { - const availableIds = Object.keys(this.currentGame.players); - if (availableIds.length > 0) { - console.warn( - `⚠️ [Match ${this.id}] IDs desalinhados! A usar vista do user ${availableIds[0]}` - ); - gameState = this.currentGame.getStateForPlayer(availableIds[0]); - } - } - } + getGameState(userId) { + if (!this.currentGame) return null; + if (this.currentGame.players[userId]) return this.currentGame.getStateForPlayer(userId); + return null; + } + getPublicState() { return { matchId: this.id, status: this.status, marks: this.marks, points: this.points, - game: gameState, + player1: this.player1, + player2: this.player2 }; } } diff --git a/websockets/events/match.js b/websockets/events/match.js index 7cff0a7..7be44cc 100644 --- a/websockets/events/match.js +++ b/websockets/events/match.js @@ -5,134 +5,154 @@ import axios from "axios"; const API_URL = "http://localhost:8000/api/v1"; export default (io, socket) => { - const createMatchEmitter = (matchId) => (eventName, payload) => { - io.to(`match_${matchId}`).emit(eventName, payload); - }; + + const broadcastMatchUpdate = (matchId, match) => { + const roomName = `match_${matchId}`; + const room = io.sockets.adapter.rooms.get(roomName); + if (room) { + for (const socketId of room) { + const clientSocket = io.sockets.sockets.get(socketId); + if (!clientSocket || !clientSocket.user) continue; + const userId = clientSocket.user.id; + const publicState = match.getPublicState(); + const gameState = match.getGameState(userId); + clientSocket.emit("match:update", { ...publicState, game: gameState }); + } + } + }; - socket.on("match:enter", async (data) => { - const { matchId } = data; + const createMatchEmitter = (matchId) => (eventName, payload) => { + const match = getMatch(matchId); + if (!match) return; - if (!socket.user || !socket.user.id) { - return socket.emit("error", { message: "User not authenticated" }); - } + if (eventName === 'match:new-round-start' || eventName === 'match:ended-signal') { + broadcastMatchUpdate(matchId, match); + } else if (eventName === 'match:round-ended') { + io.to(`match_${matchId}`).emit('match:round-ended', payload); + } else { + io.to(`match_${matchId}`).emit(eventName, payload); + } + }; - const userId = socket.user.id; - const room = `match_${matchId}`; + // --- FIX: Timer Callback --- + const handleAutoPlay = (matchId) => (userId, cardId) => { + const match = getMatch(matchId); + if (!match || match.status !== "Playing") return; - let match = getMatch(matchId); + console.log(`[Match ${matchId}] Auto-playing for User ${userId}`); + const result = match.playCard(userId, cardId); - if (!match) { - try { - console.log(`A pedir match ${matchId} à API...`); - const res = await axios.get(`${API_URL}/matches/${matchId}`, { - headers: { Authorization: socket.token }, - }); + if (result && result.action === "trick_resolved") { + io.to(`match_${matchId}`).emit("game:trick-end", result.trickResult); + setTimeout(() => { broadcastMatchUpdate(matchId, match); }, 1500); + } else { + broadcastMatchUpdate(matchId, match); + } + }; - const matchData = res.data.data || res.data; + socket.on("match:enter", async (data) => { + const { matchId } = data; + if (!socket.user || !socket.user.id) return socket.emit("error", { message: "Auth failed" }); + const userId = socket.user.id; + socket.activeMatchId = matchId; - match = new BiscaMatch( - matchData, - createMatchEmitter(matchId), - socket.token - ); - addMatch(match); - console.log(`Match ${matchId} carregado em memória.`); - } catch (e) { - console.error("Erro ao carregar Match da API:", e.message); - socket.emit("error", "Match not found or API error"); - return; - } - } else { - match.token = socket.token; - } + const room = `match_${matchId}`; + let match = getMatch(matchId); - if (match) { - match.token = socket.token; - } + if (!match) { + try { + const res = await axios.get(`${API_URL}/matches/${matchId}`, { + headers: { Authorization: socket.token || socket.handshake.auth.token }, + }); + const matchData = res.data.data || res.data; + + // Pass handleAutoPlay to constructor + match = new BiscaMatch( + matchData, + createMatchEmitter(matchId), + socket.token, + handleAutoPlay(matchId) + ); + addMatch(match); + } catch (e) { + return socket.emit("error", "Match not found"); + } + } + match.emitStateCallback = createMatchEmitter(matchId); + if (userId !== match.player1Id && match.player2Id === 1) await match.joinPlayer(userId, socket.user); - match.emitStateCallback = createMatchEmitter(matchId); + socket.join(room); + const publicState = match.getPublicState(); + const gameState = match.getGameState(userId); + socket.emit("match:update", { ...publicState, game: gameState }); + if (match.status === 'Playing') broadcastMatchUpdate(matchId, match); + }); - if (userId !== match.player1Id && match.player2Id === 1) { - console.log(`User ${userId} a substituir o Ghost no Match ${matchId}`); - await match.joinPlayer(userId); - } + socket.on("game:play-card", (data) => { + const { matchId, cardId } = data; + const match = getMatch(matchId); + if (!match || match.status !== "Playing") return; + const result = match.playCard(socket.user.id, cardId); + if (result && result.error) return socket.emit("error", { message: result.error }); + if (result && result.action === "trick_resolved") { + io.to(`match_${matchId}`).emit("game:trick-end", result.trickResult); + setTimeout(() => { broadcastMatchUpdate(matchId, match); }, 1500); + } else { + broadcastMatchUpdate(matchId, match); + } + }); - socket.join(room); - console.log(`Socket ${socket.id} entrou na sala ${room}`); + socket.on("game:surrender", async ({ matchId }) => { + const match = getMatch(matchId); + if(!match || match.status !== "Playing") return; + const userId = socket.user.id; + console.log(`[Match ${matchId}] User ${userId} surrendered ROUND.`); + const opponentId = match.player1Id == userId ? match.player2Id : match.player1Id; + const result = { + winnerId: opponentId, + loserId: userId, + isDraw: false, + player1_id: match.player1Id, + player2_id: match.player2Id, + player1_points: match.player1Id == opponentId ? 91 : 0, + player2_points: match.player2Id == opponentId ? 91 : 0, + reason: 'surrender' + }; + await match.handleGameEnd(result); + }); - if (match.status === "Playing") { - socket.emit("match:update", match.getState()); - } else { - socket.emit("match:waiting", { msg: "Waiting for opponent..." }); - } - }); + socket.on("match:surrender", async ({ matchId }) => { + const match = getMatch(matchId); + if(!match || match.status !== "Playing") return; + const userId = socket.user.id; + + // FIX: Abort current game to remove ghost + await match.abortCurrentGame(userId); - socket.on("game:play-card", (data) => { - const { matchId, cardId } = data; - const match = getMatch(matchId); + console.log(`[Match ${matchId}] User ${userId} surrendered MATCH.`); + const opponentId = match.player1Id == userId ? match.player2Id : match.player1Id; + match.marks[opponentId] = 4; + await match.finishMatch(); + broadcastMatchUpdate(matchId, match); + }); - if (!match || match.status !== "Playing") { - console.log( - "Tentativa de jogar sem match ativo ou match não encontrado." - ); - return; - } + socket.on("disconnect", async () => { + if (socket.activeMatchId) { + const match = getMatch(socket.activeMatchId); + if (match && match.status === 'Playing') { + const userId = socket.user?.id; + if (userId === match.player1Id || userId === match.player2Id) { + console.log(`[Match ${match.id}] Player ${userId} disconnected.`); + + // FIX: Abort current game + await match.abortCurrentGame(userId); - const result = match.playCard(socket.user.id, cardId); - - if (result && result.error) { - console.log( - `❌ [Erro Jogo] User ${socket.user.id} tentou jogar carta ${cardId} mas: ${result.error}` - ); - - socket.emit("error", { message: result.error }); - return; - } - - if (match.currentGame) { - io.to(`match_${matchId}`).emit( - "game:update", - match.currentGame.getStateForPlayer(socket.user.id) - ); - } - }); - socket.on("debug:end-game", async (data) => { - const { matchId } = data; - const match = getMatch(matchId); - if (match) { - console.log(`[DEBUG] User ${socket.user.id} a forçar fim de jogo.`); - // Simula que o user que clicou ganhou 120 pontos - const winnerId = socket.user.id; - const loserId = - winnerId == match.player1Id ? match.player2Id : match.player1Id; - - // Objeto de resultado falso para fechar o jogo - const fakeResult = { - winnerId: winnerId, - loserId: loserId, - isDraw: false, - player1_points: winnerId == match.player1Id ? 120 : 0, - player2_points: winnerId == match.player2Id ? 120 : 0, - player1_id: match.player1Id, - player2_id: match.player2Id, - }; - - await match.handleGameEnd(fakeResult); - } - }); - - // DEBUG: Forçar fim do Match Completo - socket.on("debug:end-match", async (data) => { - const { matchId } = data; - const match = getMatch(matchId); - if (match) { - console.log(`[DEBUG] User ${socket.user.id} a forçar fim de MATCH.`); - // Dá 4 marcas a quem clicou - match.marks[socket.user.id] = 4; - await match.finishMatch(); - - // Emite o estado final - io.to(`match_${matchId}`).emit("match:ended", match.getState()); - } - }); + const opponentId = match.player1Id == userId ? match.player2Id : match.player1Id; + match.marks[opponentId] = 4; + await match.finishMatch(); + broadcastMatchUpdate(match.id, match); + } + } + } + }); };