diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6b0e5ab --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "postman.settings.dotenv-detection-notification-visibility": false +} \ No newline at end of file diff --git a/api/app/Http/Controllers/AuthController.php b/api/app/Http/Controllers/AuthController.php index 5064994..f60cabd 100644 --- a/api/app/Http/Controllers/AuthController.php +++ b/api/app/Http/Controllers/AuthController.php @@ -5,6 +5,8 @@ namespace App\Http\Controllers; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Validation\ValidationException; +use App\Models\User; +use App\Http\Requests\RegisterRequest; class AuthController extends Controller { @@ -38,4 +40,30 @@ class AuthController extends Controller 'message' => 'Logged out successfully', ]); } + + public function register(RegisterRequest $request) + { + if (User::where('email', $request->email)->exists()) { + return response()->json(['message' => 'Email already registered'], 409); + } + + $user = User::create([ + 'email' => $request->email, + 'nickname' => $request->nickname, + 'name' => $request->name, + 'password' => bcrypt($request->password), + 'photo_avatar_filename' => $request->photo, + 'type' => 'P', + 'blocked' => false, + 'coins_balance' => 10, + ]); + + $token = $user->createToken('auth-token')->plainTextToken; + + return response()->json([ + 'message' => 'User registered successfully', + 'user' => $user, + 'token' => $token, + ], 201); + } } diff --git a/api/app/Http/Controllers/GameController.php b/api/app/Http/Controllers/GameController.php index 1a38512..e731b2a 100644 --- a/api/app/Http/Controllers/GameController.php +++ b/api/app/Http/Controllers/GameController.php @@ -4,15 +4,14 @@ namespace App\Http\Controllers; use App\Models\Game; use Illuminate\Http\Request; +use App\Http\Requests\StoreGameRequest; +use Illuminate\Support\Facades\Auth; class GameController extends Controller { - /** - * Display a listing of the resource. - */ public function index(Request $request) { - $query = Game::query()->with(['winner']); + $query = Game::query()->with(['winner', 'player1', 'player2']); if ($request->has('type') && in_array($request->type, ['3', '9'])) { $query->where('type', $request->type); @@ -22,66 +21,114 @@ class GameController extends Controller $query->where('status', $request->status); } - // Sorting - $sortField = $request->input('sort_by', 'began_at'); - $sortDirection = $request->input('sort_direction', 'desc'); + $query->orderBy('began_at', 'desc'); - $allowedSortFields = [ - 'began_at', - 'ended_at', - 'total_time', - 'type', - 'status' - ]; - - if (in_array($sortField, $allowedSortFields)) { - $query->orderBy($sortField, $sortDirection === 'asc' ? 'asc' : 'desc'); - } - - // Pagination - $perPage = $request->input('per_page', 15); - $games = $query->paginate($perPage); - - return response()->json([ - 'data' => $games->items(), - 'meta' => [ - 'current_page' => $games->currentPage(), - 'last_page' => $games->lastPage(), - 'per_page' => $games->perPage(), - 'total' => $games->total() - ] - ]); + return response()->json($query->paginate(15)); } - /** - * Store a newly created resource in storage. - */ public function store(Request $request) { - // + $validated = $request->validate([ + 'type' => 'required|in:3,9', + ]); + + $user = Auth::user(); + + $activeGame = Game::where(function ($query) use ($user) { + $query->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' => $validated['type'], + 'status' => 'Pending', + 'player1_user_id' => $user->id, + 'player2_user_id' => $user->id, + 'winner_user_id' => $user->id, + 'loser_user_id' => $user->id, + 'began_at' => now(), + 'player1_points' => 0, + 'player2_points' => 0, + 'custom' => null + ]); + + return response()->json([ + 'message' => 'Multiplayer game room created', + 'game' => $game + ], 201); + } + + public function join(Game $game) + { + $user = Auth::user(); + + if ($game->player1_user_id == $user->id) { + return response()->json([ + 'message' => 'Waiting for opponent...', + 'game' => $game + ], 200); + } + + if ($game->status !== 'Pending') { + return response()->json(['message' => 'Game is full or already started'], 400); + } + + $activeGame = Game::where(function ($query) use ($user) { + $query->where('player1_user_id', $user->id) + ->orWhere('player2_user_id', $user->id); + }) + ->whereIn('status', ['Pending', 'Playing']) + ->exists(); + + if ($activeGame) { + return response()->json(['message' => 'You are already in another active game.'], 400); + } + + $game->update([ + 'status' => 'Playing', + 'player2_user_id' => $user->id, + ]); + + return response()->json([ + 'message' => 'Joined successfully! Game starts now.', + 'game' => $game + ], 200); } - /** - * Display the specified resource. - */ public function show(Game $game) { - // + $game->load(['player1', 'player2', 'winner']); + return response()->json($game); } - /** - * Update the specified resource in storage. - */ public function update(Request $request, Game $game) { - // - } + if ($game->status == 'Ended') { + return response()->json(['message' => 'Game already ended'], 400); + } - /** - * Remove the specified resource from storage. - */ - public function destroy(Game $game) - { - // + $data = $request->validate([ + 'status' => 'in:Playing,Ended,Interrupted', + 'winner_user_id' => 'exists:users,id', + 'loser_user_id' => 'exists:users,id', + 'player1_points' => 'integer', + 'player2_points' => 'integer', + 'is_draw' => 'boolean', + 'total_time' => 'numeric' + ]); + + if (isset($data['status']) && $data['status'] === 'Ended') { + $data['ended_at'] = now(); + } + + $game->update($data); + + return response()->json($game); } -} +} \ No newline at end of file diff --git a/api/app/Http/Controllers/UserController.php b/api/app/Http/Controllers/UserController.php new file mode 100644 index 0000000..d1ee12e --- /dev/null +++ b/api/app/Http/Controllers/UserController.php @@ -0,0 +1,150 @@ +user()->type !== 'A') { + return response()->json(['message' => 'Unauthorized'], 403); + } + + $query = User::query(); + + // Filtros úteis para o Backoffice + if ($request->has('type')) { + $query->where('type', $request->type); + } + if ($request->has('search')) { + $query->where('name', 'like', '%' . $request->search . '%') + ->orWhere('email', 'like', '%' . $request->search . '%') + ->orWhere('nickname', 'like', '%' . $request->search . '%'); + } + + return response()->json($query->paginate(15)); + } + + /** + * GET /api/users/me + * Retorna o perfil do utilizador autenticado atual. + * Usado pelo VueJS para saber quem está logado. + */ + public function me(Request $request) + { + return response()->json($request->user()); + } + + /** + * GET /api/users/{user} + * Mostra um perfil específico. + */ + public function show(User $user) + { + $currentUser = Auth::user(); + if ($currentUser->id !== $user->id && $currentUser->type !== 'A') { + return response()->json([ + 'id' => $user->id, + 'name' => $user->name, + 'nickname' => $user->nickname, + 'photo_avatar_filename' => $user->photo_avatar_filename, + 'type' => $user->type + ]); + } + + return response()->json($user); + } + + /** + * PUT/PATCH /api/users/{user} + * Atualizar perfil (Nome, Nickname, Password, Foto) + */ + public function update(Request $request, User $user) + { + $currentUser = Auth::user(); + + if ($currentUser->id !== $user->id && $currentUser->type !== 'A') { + return response()->json(['message' => 'Unauthorized'], 403); + } + + $validated = $request->validate([ + 'name' => 'sometimes|string|max:255', + 'nickname' => ['sometimes', 'string', 'max:20', Rule::unique('users')->ignore($user->id)], + 'email' => ['sometimes', 'email', Rule::unique('users')->ignore($user->id)], + 'password' => 'sometimes|string|min:3', + 'blocked' => 'sometimes|boolean', + ]); + + if (isset($validated['blocked']) && $currentUser->type !== 'A') { + return response()->json(['message' => 'Only admins can block users'], 403); + } + + // Lógica de Upload de Foto (Exemplo Básico) + // if ($request->hasFile('photo_avatar')) { + // $path = $request->file('photo_avatar')->store('avatars', 'public'); + // $validated['photo_avatar_filename'] = basename($path); + // } + + $user->update($validated); + + return response()->json($user); + } + + /** + * DELETE /api/users/{user} + * Apagar conta (Soft Delete) + */ + public function destroy(User $user) + { + $currentUser = Auth::user(); + + if ($currentUser->id !== $user->id && $currentUser->type !== 'A') { + return response()->json(['message' => 'Unauthorized'], 403); + } + + if ($currentUser->id === $user->id && $currentUser->type === 'A') { + if ($user->type === 'A') { + return response()->json(['message' => 'Admins cannot delete their own account'], 403); + } + } + + $user->delete(); + + return response()->json(['message' => 'User deleted successfully']); + } + + /** + * GET /api/users/{user}/matches + * Histórico de Jogos Multiplayer do Utilizador + */ + public function getMatches(Request $request, User $user) + { + $currentUser = Auth::user(); + + if ($currentUser->id !== $user->id && $currentUser->type !== 'A') { + return response()->json(['message' => 'Unauthorized'], 403); + } + + $matches = Game::query() + ->where(function($q) use ($user) { + $q->where('player1_user_id', $user->id) + ->orWhere('player2_user_id', $user->id); + }) + ->with(['winner', 'player1', 'player2']) + ->orderBy('began_at', 'desc') + ->paginate(10); + + return response()->json($matches); + } +} \ No newline at end of file diff --git a/api/app/Http/Requests/RegisterRequest.php b/api/app/Http/Requests/RegisterRequest.php new file mode 100644 index 0000000..5c0b21f --- /dev/null +++ b/api/app/Http/Requests/RegisterRequest.php @@ -0,0 +1,32 @@ +|string> + */ + public function rules(): array + { + return [ + 'email' => 'required|email|unique:users,email', + 'nickname' => 'required|string|max:255', + 'name' => 'required|string|max:255', + 'password' => 'required|string|min:3', + 'photo' => 'nullable|string', + ]; + } +} diff --git a/api/app/Http/Requests/StoreGameRequest.php b/api/app/Http/Requests/StoreGameRequest.php new file mode 100644 index 0000000..35452e0 --- /dev/null +++ b/api/app/Http/Requests/StoreGameRequest.php @@ -0,0 +1,42 @@ +|string> + */ + public function rules(): array + { + return [ + // Agora validamos diretamente '3' ou '9' para bater certo com a BD + 'type' => 'required|in:3,9', + ]; + } + + /** + * Mensagens personalizadas (Opcional) + */ + public function messages(): array + { + return [ + 'type.required' => 'You must choose a game type (Bisca de 3 or 9).', + 'type.in' => 'Invalid game type. Choose 3 or 9.', + ]; + } +} \ No newline at end of file diff --git a/api/app/Models/Game.php b/api/app/Models/Game.php index 44fe639..dd65b7f 100644 --- a/api/app/Models/Game.php +++ b/api/app/Models/Game.php @@ -3,11 +3,45 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use App\Http\Requests\StoreGameRequest; +use Illuminate\Database\Eloquent\Factories\HasFactory; + class Game extends Model { + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'type', + 'status', + 'player1_user_id', + 'player2_user_id', + 'winner_user_id', + 'loser_user_id', + 'began_at', + 'ended_at', + 'total_time', + 'player1_points', + 'player2_points', + 'custom', + 'is_draw', + 'match_id' + ]; + public function winner() { return $this->belongsTo(User::class, "winner_user_id"); } + + public function player1() + { + return $this->belongsTo(User::class, 'player1_user_id'); + } + + public function player2() + { + return $this->belongsTo(User::class, 'player2_user_id'); + } } diff --git a/api/app/Models/User.php b/api/app/Models/User.php index a6ab88e..ae8f8d0 100644 --- a/api/app/Models/User.php +++ b/api/app/Models/User.php @@ -4,6 +4,7 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; @@ -11,7 +12,7 @@ use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable, HasApiTokens; + use HasFactory, Notifiable, HasApiTokens, SoftDeletes; /** * The attributes that are mass assignable. @@ -22,6 +23,12 @@ class User extends Authenticatable 'name', 'email', 'password', + 'nickname', + 'photo_avatar_filename', + 'type', + 'blocked', + 'coins_balance', + 'custom', ]; /** @@ -44,6 +51,7 @@ class User extends Authenticatable return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'blocked' => 'boolean', ]; } } diff --git a/api/composer.json b/api/composer.json index 5b5a9c0..bd2f68f 100644 --- a/api/composer.json +++ b/api/composer.json @@ -17,7 +17,7 @@ "require-dev": { "fakerphp/faker": "^1.23", "laravel/pail": "^1.2.2", - "laravel/pint": "^1.24", + "laravel/pint": "^1.26", "laravel/sail": "^1.48", "mockery/mockery": "^1.6", "nunomaduro/collision": "^8.6", diff --git a/api/composer.lock b/api/composer.lock index f127d15..3780ad4 100644 --- a/api/composer.lock +++ b/api/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6ad8f0aa2267878e2374cd4ba4042946", + "content-hash": "353b4e6bf34bd12906db0059bb3a9207", "packages": [ { "name": "brick/math", @@ -6575,16 +6575,16 @@ }, { "name": "laravel/pint", - "version": "v1.25.1", + "version": "v1.26.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "5016e263f95d97670d71b9a987bd8996ade6d8d9" + "reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/5016e263f95d97670d71b9a987bd8996ade6d8d9", - "reference": "5016e263f95d97670d71b9a987bd8996ade6d8d9", + "url": "https://api.github.com/repos/laravel/pint/zipball/69dcca060ecb15e4b564af63d1f642c81a241d6f", + "reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f", "shasum": "" }, "require": { @@ -6595,13 +6595,13 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.87.2", - "illuminate/view": "^11.46.0", - "larastan/larastan": "^3.7.1", - "laravel-zero/framework": "^11.45.0", + "friendsofphp/php-cs-fixer": "^3.90.0", + "illuminate/view": "^12.40.1", + "larastan/larastan": "^3.8.0", + "laravel-zero/framework": "^12.0.4", "mockery/mockery": "^1.6.12", - "nunomaduro/termwind": "^2.3.1", - "pestphp/pest": "^2.36.0" + "nunomaduro/termwind": "^2.3.3", + "pestphp/pest": "^3.8.4" }, "bin": [ "builds/pint" @@ -6627,6 +6627,7 @@ "description": "An opinionated code formatter for PHP.", "homepage": "https://laravel.com", "keywords": [ + "dev", "format", "formatter", "lint", @@ -6637,7 +6638,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2025-09-19T02:57:12+00:00" + "time": "2025-11-25T21:15:52+00:00" }, { "name": "laravel/sail", @@ -9380,12 +9381,12 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": {}, + "stability-flags": [], "prefer-stable": true, "prefer-lowest": false, "platform": { "php": "^8.2" }, - "platform-dev": {}, + "platform-dev": [], "plugin-api-version": "2.6.0" } diff --git a/api/package-lock.json b/api/package-lock.json index 58c569a..15f979c 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -6,7 +6,7 @@ "": { "devDependencies": { "@tailwindcss/vite": "^4.0.0", - "axios": "^1.11.0", + "axios": "^1.13.2", "concurrently": "^9.0.1", "laravel-vite-plugin": "^2.0.0", "tailwindcss": "^4.0.0", diff --git a/api/package.json b/api/package.json index 7686b29..6ab44ff 100644 --- a/api/package.json +++ b/api/package.json @@ -8,7 +8,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", - "axios": "^1.11.0", + "axios": "^1.13.2", "concurrently": "^9.0.1", "laravel-vite-plugin": "^2.0.0", "tailwindcss": "^4.0.0", diff --git a/api/routes/api.php b/api/routes/api.php index 738544f..2b9fdcd 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -2,11 +2,12 @@ use App\Http\Controllers\AuthController; use App\Http\Controllers\GameController; +use App\Http\Controllers\UserController; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; - +# Authentication Routes Route::post('/login', [AuthController::class, 'login']); Route::middleware('auth:sanctum')->group(function () { @@ -16,4 +17,13 @@ Route::middleware('auth:sanctum')->group(function () { Route::post('logout', [AuthController::class, 'logout']); }); -Route::apiResource('games', GameController::class); \ No newline at end of file +Route::post('/register', [AuthController::class, 'register']); + +# Game Routes - Protected +Route::middleware('auth:sanctum')->group(function () { + Route::apiResource('games', GameController::class); + Route::post('games', [GameController::class, 'store']); + Route::post('/games/{game}/resign', [GameController::class, 'resign']); + Route::get('/users/{user}/matches', [UserController::class, 'getMatches']); + Route::post('games/{game}/join', [GameController::class, 'join']); +}); \ No newline at end of file diff --git a/frontend/src/stores/game.js b/frontend/src/stores/game.js new file mode 100644 index 0000000..f979538 --- /dev/null +++ b/frontend/src/stores/game.js @@ -0,0 +1,8 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { toast } from 'vue-sonner' +import { useApiStore } from './api' + +export const useGameStore = defineStore('game', () => { + +}) diff --git a/websockets/.env.example b/websockets/.env.example new file mode 100644 index 0000000..8f55cbd --- /dev/null +++ b/websockets/.env.example @@ -0,0 +1 @@ +PORT= \ No newline at end of file diff --git a/websockets/events/connection.js b/websockets/events/connection.js index 468d210..db4fe03 100644 --- a/websockets/events/connection.js +++ b/websockets/events/connection.js @@ -1,5 +1,46 @@ +import { addUser, removeUser } from "../state/connection"; + +const HEARTBEAT_INTERVAL = 30000; // 30 seconds +const HEARTBEAT_TIMEOUT = 10000; // 10 seconds + export const handleConnectionEvents = (io, socket) => { - socket.on("echo", (msg) => { - socket.emit("echo", msg); + let heartbeatInterval; + let heartbeatTimeout; + const startHeartbeat = () => { + heartbeatInterval = setInterval(() => { + socket.emit("heartbeat"); + + heartbeatTimeout = setTimeout(() => { + console.log( + `[Heartbeat] No response from ${socket.id}, disconnecting...` + ); + socket.disconnect(true); + }, HEARTBEAT_TIMEOUT); + }, HEARTBEAT_INTERVAL); + }; + + socket.on("heartbeat_ack", () => { + clearTimeout(heartbeatTimeout); + console.log(`[Heartbeat] Received pong from ${socket.id}`); + }); + + socket.on("join", (user) => { + addUser(socket, user); + console.log(`[Connection] User ${user.name} has joined the server`); + console.log(`[Connection] ${getUserCount()} users online`); + }); + + socket.on("leave", () => { + const user = removeUser(socket.id); + if (user) { + console.log(`[Connection] User ${user.name} has left the server`); + console.log(`[Connection] ${getUserCount()} users online`); + } + }); + + socket.on("disconnect", () => { + console.log("Connection Lost:", socket.id); + removeUser(socket.id); + console.log(`[Connection] ${getUserCount()} users online`); }); }; diff --git a/websockets/state/connection.js b/websockets/state/connection.js index e69de29..5f3b49b 100644 --- a/websockets/state/connection.js +++ b/websockets/state/connection.js @@ -0,0 +1,19 @@ +const users = new Map(); + +export const addUser = (socket, user) => { + users.set(socket.id, user); +}; + +export const removeUser = (socketID) => { + const userToDelete = { ...users.get(socketID) }; + users.delete(socketID); + return userToDelete; +}; + +export const getUser = (socketID) => { + return users.get(socketID); +}; + +export const getUserCount = () => { + return users.size; +};