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/app/Http/Controllers/MatchController.php b/api/app/Http/Controllers/MatchController.php index 516be7d..f8c1ca7 100644 --- a/api/app/Http/Controllers/MatchController.php +++ b/api/app/Http/Controllers/MatchController.php @@ -41,7 +41,7 @@ class MatchController extends Controller { $validated = $request->validated(); $user = $request->user(); - $stake = $validated["stake"]; + $stake = $validated['stake']; if ($user->coins_balance < $stake) { return response()->json( @@ -60,10 +60,7 @@ class MatchController extends Controller ->exists(); if ($activeMatch) { - return response()->json( - ["message" => "You already have an active match."], - 400, - ); + return response()->json(['message' => 'You already have an active match.'], 400); } return DB::transaction(function () use ($validated, $user, $stake) { @@ -165,11 +162,11 @@ class MatchController extends Controller ); CoinTransaction::create([ - "user_id" => $user->id, - "match_id" => $match->id, - "coin_transaction_type_id" => $stakeType->id, - "transaction_datetime" => now(), - "coins" => -$match->stake, + 'user_id' => $user->id, + 'match_id' => $match->id, + 'coin_transaction_type_id' => $stakeType->id, + 'transaction_datetime' => now(), + 'coins' => -$match->stake, ]); $match->update([ @@ -203,8 +200,8 @@ class MatchController extends Controller ]); return DB::transaction(function () use ($match, $data) { - if (isset($data["status"]) && $data["status"] === "Ended") { - $data["ended_at"] = now(); + if (isset($data['status']) && $data['status'] === 'Ended') { + $data['ended_at'] = now(); } $match->update($data); @@ -280,18 +277,17 @@ class MatchController extends Controller ); $match = MatchGame::create([ - "type" => $request->type, - "status" => "Pending", - "player1_user_id" => $user->id, - "player2_user_id" => $GHOST_ID, - "winner_user_id" => null, - "loser_user_id" => null, - "stake" => $stake, - "began_at" => now(), - "player1_marks" => 0, - "player2_marks" => 0, - "player1_points" => 0, - "player2_points" => 0, + 'type' => $request->type, + 'status' => 'Pending', + 'player1_user_id' => $user->id, + 'player2_user_id' => $GHOST_ID, + 'winner_user_id' => null, + 'loser_user_id' => null, + 'stake' => $stake, + 'began_at' => now(), + 'player1_marks' => 0, 'player2_marks' => 0, + 'player1_points' => 0, 'player2_points' => 0, + ]); CoinTransaction::create([ @@ -308,16 +304,70 @@ class MatchController extends Controller public function open(Request $request) { - $type = $request->query("type", "9"); - $GHOST_ID = 1; + $query = MatchGame::where('status', 'Pending') + ->where(function($q) { + $q->whereNull('player2_user_id') + ->orWhere('player2_user_id', 1); // 1 = Ghost ID + }); - $matches = MatchGame::where("status", "Pending") - ->where("player2_user_id", $GHOST_ID) - ->where("type", $type) - ->with("player1") - ->orderBy("began_at", "asc") + if ($request->has('type')) { + $query->where('type', $request->type); + } + + $matches = $query->with('player1:id,nickname') + ->orderBy('began_at', 'desc') ->paginate(10); 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 e4b0a6b..0a57f82 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -58,10 +58,12 @@ Route::prefix('v1')->group(function () { }); Route::prefix('matches')->group(function () { - Route::apiResource('/', MatchController::class)->parameters(['' => 'match']); - Route::post('/{match}/join', [MatchController::class, 'join']); - Route::post('host', [MatchController::class, 'host']); // <--- NOVA + Route::post('host', [MatchController::class, 'host']); 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']); }); // Admin Routes @@ -70,7 +72,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/package.json b/frontend/package.json index 11ff168..1204dbd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,7 +16,7 @@ "dependencies": { "@tailwindcss/vite": "^4.1.17", "@tanstack/vue-table": "^8.21.3", - "@vueuse/core": "^14.0.0", + "@vueuse/core": "^14.1.0", "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -24,7 +24,7 @@ "lucide-react": "^0.562.0", "lucide-vue-next": "^0.554.0", "pinia": "^3.0.3", - "reka-ui": "^2.6.0", + "reka-ui": "^2.7.0", "socket.io-client": "^4.8.1", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.17", diff --git a/frontend/src/components/layout/NavBar.vue b/frontend/src/components/layout/NavBar.vue index c012324..40fecb7 100644 --- a/frontend/src/components/layout/NavBar.vue +++ b/frontend/src/components/layout/NavBar.vue @@ -9,14 +9,9 @@ - -
+ +
$
@@ -83,7 +78,5 @@ watch( } else { userStore.coins = 0 } - }, - { immediate: true }, -) + }, { immediate: true }) 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/MultiplayerGamePage.vue b/frontend/src/pages/game/MultiplayerGamePage.vue index fe0d7cf..c70bd9f 100644 --- a/frontend/src/pages/game/MultiplayerGamePage.vue +++ b/frontend/src/pages/game/MultiplayerGamePage.vue @@ -1,26 +1,26 @@ + + diff --git a/frontend/src/pages/home/HomePage.vue b/frontend/src/pages/home/HomePage.vue index 78851dc..059bf44 100644 --- a/frontend/src/pages/home/HomePage.vue +++ b/frontend/src/pages/home/HomePage.vue @@ -1,18 +1,24 @@ diff --git a/frontend/src/pages/user/UserPage.vue b/frontend/src/pages/user/UserPage.vue index c1854aa..0e32aef 100644 --- a/frontend/src/pages/user/UserPage.vue +++ b/frontend/src/pages/user/UserPage.vue @@ -605,7 +605,8 @@
-
{{ game.amount }} pts
+ +
{{ game.points }} pts
{{ new Date(game.began_at).toLocaleDateString() }} • {{ @@ -1151,10 +1152,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/router/index.js b/frontend/src/router/index.js index c7aca9e..509a1ea 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -4,6 +4,7 @@ import UserPage from '@/pages/user/UserPage.vue' import { createRouter, createWebHistory } from 'vue-router' 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' @@ -48,6 +49,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/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/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": {} +} 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/game.js b/websockets/events/game.js index 0d21285..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, @@ -248,7 +249,8 @@ export default (io, socket) => { } socket.join(`game_${gameId}`); - + socket.activeGameId = gameId; + if (game.players[myId]) { game.players[myId].token = socket.handshake.auth.token; 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); + } + } + } + }); }; diff --git a/websockets/server.js b/websockets/server.js index 0f9d912..1965ad4 100644 --- a/websockets/server.js +++ b/websockets/server.js @@ -6,73 +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 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; - - 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("disconnect", () => { - removeUser(socket.id); }); - }); -}; \ No newline at end of file + + 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 e3caa9e..55f3e17 100644 --- a/websockets/state/game.js +++ b/websockets/state/game.js @@ -32,3 +32,4 @@ export const removeGame = (id) => { console.log(`[Game State] Game removed: ${id}`); } }; +