diff --git a/api/app/Http/Controllers/GameController.php b/api/app/Http/Controllers/GameController.php index 057306e..dbaf22f 100644 --- a/api/app/Http/Controllers/GameController.php +++ b/api/app/Http/Controllers/GameController.php @@ -17,6 +17,7 @@ class GameController extends Controller $user = Auth::user(); $query = Game::query()->with(["winner", "player1", "player2"]); + // Se não for Admin, mostra apenas os jogos do utilizador if ($user->type !== 'A') { $query->where(function ($q) use ($user) { $q->where('player1_user_id', $user->id) @@ -40,75 +41,154 @@ class GameController extends Controller $query->where("status", $request->status); } - if ($request->has("sort_direction") && in_array($request->sort_direction, ["asc", "desc"])) { + if ($request->has("sort_direction") && in_array($request->sort_direction, ["asc", "desc"])) { $query->orderBy("began_at", $request->sort_direction); - }else{ + } else { $query->orderBy("began_at", "desc"); } return response()->json($query->paginate(15)); } + // --- MÉTODOS ADICIONADOS --- + + // Host a new multiplayer Game + public function host(Request $request) + { + $request->validate([ + 'type' => 'required|in:3,9' + ]); + + $user = $request->user(); + + // Check for active game + $activeGame = Game::where(function ($q) use ($user) { + $q->where("player1_user_id", $user->id) + ->orWhere("player2_user_id", $user->id); + }) + ->whereIn("status", ["Pending", "Playing"]) + ->exists(); + + if ($activeGame) { + return response()->json(["message" => "You already have an active game."], 400); + } + + $game = Game::create([ + 'type' => $request->type, + 'status' => 'Pending', + 'player1_user_id' => $user->id, + 'player2_user_id' => self::GHOST_ID, // Ghost ID = 1 (Waiting for player) + 'began_at' => now(), + 'player1_points' => 0, + 'player2_points' => 0, + ]); + + return response()->json($game, 201); + } + + // List open games (Available to join) + public function open(Request $request) + { + $type = $request->query('type', '9'); + $GHOST_ID = 1; + + $games = Game::where('status', 'Pending') + ->where('player2_user_id', $GHOST_ID) // Games waiting for P2 + ->where('type', $type) + ->with('player1') + ->orderBy('began_at', 'asc') + ->paginate(10); + + return response()->json($games); + } + // --------------------------- + public function store(Request $request) { $this->authorize('create', Game::class); $validated = $request->validate([ "type" => "required|in:3,9", + "player2_user_id" => "nullable|integer|exists:users,id", + "match_id" => "nullable|integer|exists:matches,id", + "status" => "nullable|in:Pending,Playing", + "began_at" => "nullable|date" ]); $user = Auth::user(); $activeGame = Game::where(function ($q) use ($user) { - $q->where("player1_user_id", $user->id) - ->orWhere("player2_user_id", $user->id); + $q->where("player1_user_id", $user->id) + ->orWhere("player2_user_id", $user->id); }) ->whereIn("status", ["Pending", "Playing"]) ->exists(); if ($activeGame) { - return response()->json(["message" => "You already have an active game."], 400); + return response()->json(["message" => "You already have an active game."], 400); + } + + $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'; } $game = Game::create([ "type" => $validated["type"], - "status" => "Pending", + "status" => $initialStatus, "player1_user_id" => $user->id, - "player2_user_id" => self::GHOST_ID, - "winner_user_id" => self::GHOST_ID, - "loser_user_id" => self::GHOST_ID, - "began_at" => now(), + "player2_user_id" => $player2Id, + "winner_user_id" => null, + "loser_user_id" => null, + "match_id" => $request->input('match_id', null), + "began_at" => $request->input('began_at', now()), "player1_points" => 0, "player2_points" => 0, "custom" => null, ]); return response()->json([ - "message" => "Multiplayer game room created", + "message" => "Game created", "game" => $game, ], 201); } -public function join(Request $request, Game $game) -{ - // Prevent joining if already full or started - if ($game->status !== 'Pending' || $game->player2_user_id !== 1) { - return response()->json(['message' => 'Game is not available'], 400); + public function join(Request $request, Game $game) + { + // Prevent joining if already full or started + if ($game->status !== 'Pending' || $game->player2_user_id !== self::GHOST_ID) { + return response()->json(['message' => 'Game is not available'], 400); + } + + // Prevent joining your own game + 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) + ->orWhere("player2_user_id", $request->user()->id); + }) + ->whereIn("status", ["Pending", "Playing"]) + ->exists(); + + if ($activeGame) { + return response()->json(["message" => "You already have an active game."], 400); + } + + $game->update([ + 'player2_user_id' => $request->user()->id, + 'status' => 'Playing', + ]); + + return response()->json($game); } - // Prevent joining your own game - if ($game->player1_user_id === $request->user()->id) { - return response()->json(['message' => 'Cannot join your own game'], 400); - } - - $game->update([ - 'player2_user_id' => $request->user()->id, - 'status' => 'Playing', - ]); - - return response()->json($game); -} - public function show(Game $game) { $this->authorize('view', $game); @@ -164,4 +244,4 @@ public function join(Request $request, Game $game) 'game' => $game->fresh(), ]); } -} +} \ No newline at end of file diff --git a/api/app/Http/Controllers/MatchController.php b/api/app/Http/Controllers/MatchController.php index fe6972f..444ccc9 100644 --- a/api/app/Http/Controllers/MatchController.php +++ b/api/app/Http/Controllers/MatchController.php @@ -3,12 +3,10 @@ namespace App\Http\Controllers; use App\Models\MatchGame; -use App\Models\Game; use App\Models\CoinTransaction; use App\Models\CoinTransactionType; use Illuminate\Http\Request; use App\Http\Requests\StoreMatchRequest; -use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; class MatchController extends Controller @@ -196,4 +194,77 @@ class MatchController extends Controller return response()->json($match); }); } + + public function host(Request $request) + { + $request->validate([ + 'type' => 'required|in:3,9', + 'stake' => 'required|integer|min:0' + ]); + + $user = $request->user(); + $stake = $request->stake; + + if ($user->coins_balance < $stake) { + return response()->json(['message' => 'Insufficient coin balance'], 400); + } + + $activeMatch = MatchGame::where(function ($q) use ($user) { + $q->where('player1_user_id', $user->id) + ->orWhere('player2_user_id', $user->id); + })->whereIn('status', ['Pending', 'Playing'])->exists(); + + if ($activeMatch) { + return response()->json(['message' => 'You already have an active match.'], 400); + } + + return DB::transaction(function () use ($request, $user, $stake) { + $GHOST_ID = 1; + + $user->decrement('coins_balance', $stake); + + $stakeType = CoinTransactionType::firstOrCreate( + ['name' => 'Match stake'], + ['type' => 'D'] // Debit + ); + + $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, + ]); + + CoinTransaction::create([ + 'user_id' => $user->id, + 'match_id' => $match->id, + 'coin_transaction_type_id' => $stakeType->id, + 'transaction_datetime' => now(), + 'coins' => -$stake, + ]); + + return response()->json($match, 201); + }); + } + + public function open(Request $request) + { + $type = $request->query('type', '9'); + $GHOST_ID = 1; + + $matches = MatchGame::where('status', 'Pending') + ->where('player2_user_id', $GHOST_ID) + ->where('type', $type) + ->with('player1') + ->orderBy('began_at', 'asc') + ->paginate(10); + + return response()->json($matches); + } } \ No newline at end of file diff --git a/api/app/Http/Controllers/UserController.php b/api/app/Http/Controllers/UserController.php index 98a063d..4e6a608 100644 --- a/api/app/Http/Controllers/UserController.php +++ b/api/app/Http/Controllers/UserController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Models\Game; use App\Models\User; +use App\Models\MatchGame; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; @@ -178,72 +179,87 @@ class UserController extends Controller } /** - * GET /api/users/{user}/matches - * Histórico de Jogos Multiplayer do Utilizador + * GET /api/users/{user}/games + * Histórico de Partidas Individuais (Cartas) */ - public function getMatches(Request $request, User $user) + public function getGames(Request $request, User $user) { $this->authorize('view', $user); - $outcomeSql = "CASE - WHEN is_draw = 1 THEN 'DRAW' - WHEN winner_user_id = {$user->id} THEN 'WIN' - ELSE 'LOSS' - END"; - - $query = Game::query() - ->select('*') - ->selectRaw("($outcomeSql) as outcome_text") - ->where(function ($q) use ($user) { - $q->where('player1_user_id', $user->id) - ->orWhere('player2_user_id', $user->id); - }) - ->with(['winner', 'player1', 'player2']); + // Define a query base para GAMES + $query = \App\Models\Game::query() + ->where(function ($q) use ($user) { + $q->where('player1_user_id', $user->id) + ->orWhere('player2_user_id', $user->id); + }) + ->with(['winner', 'player1', 'player2']); if ($request->has('type')) { - $query->where('type', $request->type); + $query->where('type', $request->type); } if ($request->has('status')) { $query->where('status', $request->status); } - if ($request->filled('outcome')) { - switch ($request->outcome) { - case 'win': - $query->where('winner_user_id', $user->id) - ->where('is_draw', 0); - break; - case 'loss': - case 'forfeit': // Forfeit treated as loss for now - $query->where('loser_user_id', $user->id) - ->where('is_draw', 0); - break; - case 'draw': - $query->where('is_draw', 1); - break; - } + $sortDirection = $request->get('sort_direction', 'desc'); + $query->orderBy('began_at', $sortDirection); + + + $games = $query->paginate(10); + + $games->getCollection()->transform(function ($game) use ($user) { + if ($game->winner_user_id == $user->id) { + $game->outcome = 'win'; + } elseif ($game->is_draw) { + $game->outcome = 'draw'; + } else { + $game->outcome = 'loss'; + } + return $game; + }); + + return response()->json($games); } - if ($request->has('date_from')) { - $query->whereDate('began_at', '>=', $request->date_from); + /** + * GET /api/users/{user}/matches + * Histórico de Matches (Apostas / Marcas) + */ + public function getMatches(Request $request, User $user) + { + $this->authorize('view', $user); + + $query = MatchGame::query() + ->where(function ($q) use ($user) { + $q->where('player1_user_id', $user->id) + ->orWhere('player2_user_id', $user->id); + }) + ->with(['winner', 'player1', 'player2']); + + if ($request->has('type')) { + $query->where('type', $request->type); + } + + if ($request->has('status')) { + $query->where('status', $request->status); } - if ($request->has('date_to')) { - $query->whereDate('ended_at', '<=', $request->date_to); - } + $sortDirection = $request->get('sort_direction', 'desc'); + $query->orderBy('began_at', $sortDirection); - $order = $request->get('sort_by', 'began_at'); - $direction = $request->get('sort_direction', 'desc'); + $matches = $query->paginate(10); - if ($order === 'outcome') { - $query->orderBy('outcome_text', $direction); - } else { - $query->orderBy($order, $direction); - } - - $matches = $query->orderBy($order, $direction) - ->paginate(10); + $matches->getCollection()->transform(function ($match) use ($user) { + if ($match->winner_user_id == $user->id) { + $match->outcome = 'win'; + } elseif ($match->winner_user_id && $match->winner_user_id != $user->id) { + $match->outcome = 'loss'; + } else { + $match->outcome = 'interrupted'; // Matches geralmente não empatam (alguém chega a 4 marcas) + } + return $match; + }); return response()->json($matches); } diff --git a/api/app/Policies/GamePolicy.php b/api/app/Policies/GamePolicy.php index 7345bfb..9fbdc9a 100644 --- a/api/app/Policies/GamePolicy.php +++ b/api/app/Policies/GamePolicy.php @@ -86,7 +86,6 @@ class GamePolicy return true; } - /** * Determine whether the user can update the model. */ @@ -96,17 +95,16 @@ class GamePolicy return false; } - if ( - $game->status !== "Playing" || - ($game->player1_user_id !== $user->id && - $game->player2_user_id !== $user->id) - ) { + if (!in_array($game->status, ['Playing', 'Pending'])) { + return false; + } + + if ($game->player1_user_id !== $user->id && $game->player2_user_id !== $user->id) { return false; } return true; } - /** * Determine whether the user can delete the model. */ diff --git a/api/routes/api.php b/api/routes/api.php index 1809c01..e4b0a6b 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -40,45 +40,14 @@ Route::prefix('v1')->group(function () { // User Resources Route::prefix('/users')->group(function () { Route::get('/{user}', [UserController::class, 'show']); - Route::get('/{user}/matches', [ - UserController::class, - 'getMatches', - ]); Route::get('/{user}/transactions', [UserController::class, 'getTransactions']); + Route::get('/{user}/games', [UserController::class, 'getGames']); + Route::get('/{user}/matches', [UserController::class, 'getMatches']); }); Route::prefix('games')->group(function () { - //Host a new multiplayer game - MUST come before resource routes - Route::post('host', function (\Illuminate\Http\Request $request) { - $request->validate([ - 'type' => 'required|in:3,9' - ]); - - $game = \App\Models\Game::create([ - 'type' => $request->type, - 'status' => 'Pending', - 'player1_user_id' => $request->user()->id, - 'player2_user_id' => 1, // Ghost player - 'began_at' => now(), - ]); - - return response()->json($game); - }); - - // List open games (available to join) - Route::get('open', function (\Illuminate\Http\Request $request) { - $type = $request->query('type', '9'); - - $games = \App\Models\Game::where('status', 'Pending') - ->where('player2_user_id', 1) // Only games waiting for a second player - ->where('type', $type) - ->with('player1') // Load player1 relationship - ->orderBy('began_at', 'asc') - ->paginate(10); - - return response()->json($games); - }); - + Route::post('host', [GameController::class, 'host']); + Route::get('open', [GameController::class, 'open']); Route::get('/', [GameController::class, 'index']); Route::get('/{game}', [GameController::class, 'show']); Route::post('/', [GameController::class, 'store']); @@ -91,6 +60,8 @@ 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::get('open', [MatchController::class, 'open']); }); // Admin Routes @@ -99,6 +70,8 @@ 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('/matches', [MatchController::class, 'index']); Route::prefix('users')->group(function () { Route::get('/', [UserController::class, 'index']); Route::post('/', [UserController::class, 'store']); diff --git a/frontend/src/pages/admin/AdminPage.vue b/frontend/src/pages/admin/AdminPage.vue index b23ee28..1245741 100644 --- a/frontend/src/pages/admin/AdminPage.vue +++ b/frontend/src/pages/admin/AdminPage.vue @@ -1,502 +1,846 @@ \ No newline at end of file + diff --git a/frontend/src/pages/home/HomePage.vue b/frontend/src/pages/home/HomePage.vue index 554ef38..78851dc 100644 --- a/frontend/src/pages/home/HomePage.vue +++ b/frontend/src/pages/home/HomePage.vue @@ -23,6 +23,11 @@ const gObserverTarget = ref(null); const gObserver = ref(null); const gLoading = ref(false); const gPage = ref(1); +const openMatches = ref([]) +const mObserverTarget = ref(null); +const mObserver = ref(null); +const mLoading = ref(false); +const mPage = ref(1); const showHostModal = ref(false) const showJoinModal = ref(false) const selectedGame = ref(null) @@ -38,6 +43,21 @@ const lbObserverTarget = ref(null) const lbObserver = ref(null) const lbMorePages = ref(true) const publicStats = ref(null) +const isLoggedIn = useAuthStore().isLoggedIn + +const setupMatchesObserver = () => { + if (mObserver.value) mObserver.value.disconnect(); + + mObserver.value = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && !mLoading.value && openMatches.value.length > 0) { + getPendingMatches(); + } + }, { threshold: 0.1 }); + + if (mObserverTarget.value) { + mObserver.value.observe(mObserverTarget.value); + } +} const setupGamesObserver = () => { if (gObserver.value) gObserver.value.disconnect(); @@ -67,14 +87,30 @@ const getPendingGames = async (mode = selectedMode.value) => { const newGames = response.data.data; openGames.value = [...openGames.value, ...newGames]; - gPage.value++; - } catch (error) { - console.error('Failed to fetch games:', error); } finally { gLoading.value = false; } } +const getPendingMatches = async (mode = selectedMode.value) => { + if (mLoading.value) return; + mLoading.value = true; + + try { + const response = await axios.get(`${API_BASE_URL}/matches/open`, { + params: { + type: mode, + page: mPage.value + } + }); + + const newMatches = response.data.data; + openMatches.value = [...openMatches.value, ...newMatches]; + } finally { + mLoading.value = false; + } +} + const fetchPublicStats = async () => { try { const response = await apiStore.getPublicStats() @@ -145,81 +181,77 @@ const setupLeaderboardObserver = () => { const clickMode = async (mode) => { if (selectedMode.value === mode) return; - openGames.value = []; selectedMode.value = mode; + openGames.value = []; gPage.value = 1; - await getPendingGames(); + openMatches.value = []; + mPage.value = 1; + await Promise.all([getPendingGames(), getPendingMatches()]); setupGamesObserver(); + setupMatchesObserver(); } const startSingle = () => { router.push({ name: 'bisca' + selectedMode.value }); } -const JoinGameModal = (game) => { - selectedGame.value = game - isReady.value = false - showJoinModal.value = true -} - -const hostGameModal = () => { - showHostModal.value = true -} - const toggleReady = () => { isReady.value = !isReady.value } onMounted(async () => { await fetchPublicStats(); - await getPendingGames(); - await nextTick(); - setupGamesObserver(); + if (isLoggedIn) { + await Promise.all([getPendingGames(), getPendingMatches()]); + await nextTick(); + setupGamesObserver(); + setupMatchesObserver(); + } }) const hostGame = async () => { - showHostModal.value = true + showHostModal.value = true - try { - const response = await axios.post(`${API_BASE_URL}/games/host`, { - type: selectedMode.value - }) + try { + const response = await axios.post(`${API_BASE_URL}/games/host`, { + type: selectedMode.value + }) - const gameId = response.data.id + const gameId = response.data.id - console.log('Game hosted:', gameId) + console.log('Game hosted:', gameId) - router.push({ - name: 'multiplayer-game', - params: { id: gameId }, - query: { type: selectedMode.value } - }) - } catch (error) { - console.error('Failed to host game:', error) - console.error('Error details:', error.response?.data) - showHostModal.value = false - alert('Failed to host game: ' + (error.response?.data?.message || error.message)) - } + router.push({ + name: 'multiplayer-game', + params: { id: gameId }, + query: { type: selectedMode.value } + }) + } catch (error) { + console.error('Failed to host game:', error) + console.error('Error details:', error.response?.data) + showHostModal.value = false + alert('Failed to host game: ' + (error.response?.data?.message || error.message)) + } } const joinGame = async (game) => { - selectedGame.value = game + selectedGame.value = game - try { - await axios.post(`${API_BASE_URL}/games/${game.id}/join`) + try { + await axios.post(`${API_BASE_URL}/games/${game.id}/join`) - console.log('Joined game:', game.id) + 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)) - } + 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)) + } } @@ -227,7 +259,7 @@ const joinGame = async (game) => {