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(); + } +})