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 };
-