Compare commits

...
Author SHA1 Message Date
Edd b6845ee0ea fixing game history and removing comments 2026-01-03 16:59:14 +00:00
FernandoJVideira 3ba3487f81 Merge pull request 'Version 1.0' (#102) from develop into master
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/102
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
Reviewed-by: KZix <[email protected]>
Reviewed-by: Edd <[email protected]>
2026-01-03 14:49:00 +00:00
FernandoJVideira 55399ab06a Merge pull request 'removed comments' (#101) from na/comment-removal into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/101
Reviewed-by: Edd <[email protected]>
Reviewed-by: FernandoJVideira <[email protected]>
2026-01-03 14:46:58 +00:00
KZix f7328d59eb removed comments 2026-01-03 14:45:14 +00:00
FernandoJVideira b046a85806 Merge pull request 'fix/single-player' (#100) from fix/single-player into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/100
Reviewed-by: Edd <[email protected]>
Reviewed-by: FernandoJVideira <[email protected]>
2026-01-03 14:26:49 +00:00
AfonsoCMSousa a3da9af90d hotfix: Singler player now plays fine 2026-01-03 14:02:58 +00:00
AfonsoCMSousa 83832aaaa9 hotfix: Finnal fix 2026-01-03 14:01:38 +00:00
FernandoJVideira 4cfab79611 Merge pull request 'feature/bestof-series-interface' (#99) from feature/bestof-series-interface into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/99
Reviewed-by: KZix <[email protected]>
Reviewed-by: Edd <[email protected]>
Reviewed-by: FernandoJVideira <[email protected]>
2026-01-03 13:26:18 +00:00
37 changed files with 706 additions and 3357 deletions
+2 -14
View File
@@ -17,7 +17,6 @@ class GameController extends Controller
$user = Auth::user(); $user = Auth::user();
$query = Game::query()->with(["winner", "player1", "player2"]); $query = Game::query()->with(["winner", "player1", "player2"]);
// Se não for Admin, mostra apenas os jogos do utilizador
if ($user->type !== 'A') { if ($user->type !== 'A') {
$query->where(function ($q) use ($user) { $query->where(function ($q) use ($user) {
$q->where('player1_user_id', $user->id) $q->where('player1_user_id', $user->id)
@@ -50,9 +49,6 @@ class GameController extends Controller
return response()->json($query->paginate(15)); return response()->json($query->paginate(15));
} }
// --- MÉTODOS ADICIONADOS ---
// Host a new multiplayer Game
public function host(Request $request) public function host(Request $request)
{ {
$request->validate([ $request->validate([
@@ -61,7 +57,6 @@ class GameController extends Controller
$user = $request->user(); $user = $request->user();
// Check for active game
$activeGame = Game::where(function ($q) use ($user) { $activeGame = Game::where(function ($q) use ($user) {
$q->where("player1_user_id", $user->id) $q->where("player1_user_id", $user->id)
->orWhere("player2_user_id", $user->id); ->orWhere("player2_user_id", $user->id);
@@ -77,7 +72,7 @@ class GameController extends Controller
'type' => $request->type, 'type' => $request->type,
'status' => 'Pending', 'status' => 'Pending',
'player1_user_id' => $user->id, 'player1_user_id' => $user->id,
'player2_user_id' => self::GHOST_ID, // Ghost ID = 1 (Waiting for player) 'player2_user_id' => self::GHOST_ID,
'began_at' => now(), 'began_at' => now(),
'player1_points' => 0, 'player1_points' => 0,
'player2_points' => 0, 'player2_points' => 0,
@@ -86,14 +81,13 @@ class GameController extends Controller
return response()->json($game, 201); return response()->json($game, 201);
} }
// List open games (Available to join)
public function open(Request $request) public function open(Request $request)
{ {
$type = $request->query('type', '9'); $type = $request->query('type', '9');
$GHOST_ID = 1; $GHOST_ID = 1;
$games = Game::where('status', 'Pending') $games = Game::where('status', 'Pending')
->where('player2_user_id', $GHOST_ID) // Games waiting for P2 ->where('player2_user_id', $GHOST_ID)
->where('type', $type) ->where('type', $type)
->with('player1') ->with('player1')
->orderBy('began_at', 'asc') ->orderBy('began_at', 'asc')
@@ -101,7 +95,6 @@ class GameController extends Controller
return response()->json($games); return response()->json($games);
} }
// ---------------------------
public function store(Request $request) public function store(Request $request)
{ {
@@ -132,7 +125,6 @@ class GameController extends Controller
$initialStatus = $request->input('status', 'Pending'); $initialStatus = $request->input('status', 'Pending');
// If specific player 2 is provided, auto-start game
if ($player2Id !== self::GHOST_ID && !$request->has('status')) { if ($player2Id !== self::GHOST_ID && !$request->has('status')) {
$initialStatus = 'Playing'; $initialStatus = 'Playing';
} }
@@ -159,17 +151,14 @@ class GameController extends Controller
public function join(Request $request, Game $game) 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) { if ($game->status !== 'Pending' || $game->player2_user_id !== self::GHOST_ID) {
return response()->json(['message' => 'Game is not available'], 400); return response()->json(['message' => 'Game is not available'], 400);
} }
// Prevent joining your own game
if ($game->player1_user_id === $request->user()->id) { if ($game->player1_user_id === $request->user()->id) {
return response()->json(['message' => 'Cannot join your own game'], 400); 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) { $activeGame = Game::where(function ($q) use ($request) {
$q->where("player1_user_id", $request->user()->id) $q->where("player1_user_id", $request->user()->id)
->orWhere("player2_user_id", $request->user()->id); ->orWhere("player2_user_id", $request->user()->id);
@@ -247,7 +236,6 @@ class GameController extends Controller
public function destroy(Game $game) 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()) { if ($game->player1_user_id !== auth()->id()) {
return response()->json(['message' => 'Unauthorized'], 403); return response()->json(['message' => 'Unauthorized'], 403);
} }
+5 -11
View File
@@ -70,7 +70,7 @@ class MatchController extends Controller
$stakeType = CoinTransactionType::firstOrCreate( $stakeType = CoinTransactionType::firstOrCreate(
["name" => "Match stake"], ["name" => "Match stake"],
["type" => "D"], // Debit ["type" => "D"],
); );
$match = MatchGame::create([ $match = MatchGame::create([
@@ -213,7 +213,7 @@ class MatchController extends Controller
) { ) {
$payoutType = CoinTransactionType::firstOrCreate( $payoutType = CoinTransactionType::firstOrCreate(
["name" => "Match payout"], ["name" => "Match payout"],
["type" => "C"], // Credit ["type" => "C"],
); );
$totalPrize = $match->stake * 2; $totalPrize = $match->stake * 2;
@@ -273,7 +273,7 @@ class MatchController extends Controller
$stakeType = CoinTransactionType::firstOrCreate( $stakeType = CoinTransactionType::firstOrCreate(
["name" => "Match stake"], ["name" => "Match stake"],
["type" => "D"], // Debit ["type" => "D"],
); );
$match = MatchGame::create([ $match = MatchGame::create([
@@ -307,7 +307,7 @@ class MatchController extends Controller
$query = MatchGame::where('status', 'Pending') $query = MatchGame::where('status', 'Pending')
->where(function($q) { ->where(function($q) {
$q->whereNull('player2_user_id') $q->whereNull('player2_user_id')
->orWhere('player2_user_id', 1); // 1 = Ghost ID ->orWhere('player2_user_id', 1);
}); });
if ($request->has('type')) { if ($request->has('type')) {
@@ -321,7 +321,6 @@ class MatchController extends Controller
return response()->json($matches); return response()->json($matches);
} }
// --- FIX: Safely Delete Match by removing dependencies first ---
public function destroy(MatchGame $match) public function destroy(MatchGame $match)
{ {
$user = request()->user(); $user = request()->user();
@@ -335,13 +334,10 @@ class MatchController extends Controller
} }
return DB::transaction(function () use ($match, $user) { 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(); 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]); CoinTransaction::where('match_id', $match->id)->update(['match_id' => null]);
// 3. Process Refund
if ($match->stake > 0) { if ($match->stake > 0) {
$user->increment('coins_balance', $match->stake); $user->increment('coins_balance', $match->stake);
@@ -350,10 +346,9 @@ class MatchController extends Controller
['type' => 'C'] ['type' => 'C']
); );
// Create Refund Transaction with NO LINK to the deleted match
CoinTransaction::create([ CoinTransaction::create([
'user_id' => $user->id, 'user_id' => $user->id,
'match_id' => null, // IMPORTANT: Must be null 'match_id' => null,
'coin_transaction_type_id' => $refundType->id, 'coin_transaction_type_id' => $refundType->id,
'transaction_datetime' => now(), 'transaction_datetime' => now(),
'coins' => $match->stake, 'coins' => $match->stake,
@@ -361,7 +356,6 @@ class MatchController extends Controller
]); ]);
} }
// 4. Finally delete the match
$match->delete(); $match->delete();
return response()->json([ return response()->json([
@@ -82,7 +82,7 @@ class ProfileController extends Controller
]); ]);
} }
public function updateAvatar(Request $request) public function uploadAvatar(Request $request)
{ {
$request->validate([ $request->validate([
"avatar" => "required|image|mimes:jpeg,png,jpg,gif|max:2048", "avatar" => "required|image|mimes:jpeg,png,jpg,gif|max:2048",
@@ -65,7 +65,7 @@ class StatisticsController extends Controller
->where('winner_user_id', $user->id) ->where('winner_user_id', $user->id)
->count(); ->count();
$totalLosses = $totalGames - $totalWins; // Simplest math $totalLosses = $totalGames - $totalWins;
$winRate = 0; $winRate = 0;
if ($totalGames > 0) { if ($totalGames > 0) {
@@ -78,7 +78,7 @@ class StatisticsController extends Controller
'total_wins' => $totalWins, 'total_wins' => $totalWins,
'total_losses' => $totalLosses, 'total_losses' => $totalLosses,
'win_rate' => $winRate . '%', 'win_rate' => $winRate . '%',
'current_balance' => $user->coins_balance, // Extra info 'current_balance' => $user->coins_balance,
]); ]);
} }
+3 -54
View File
@@ -12,10 +12,6 @@ use Illuminate\Validation\Rule;
class UserController extends Controller class UserController extends Controller
{ {
/**
* GET /api/users
* Lista todos os utilizadores (Apenas para Admins)
*/
public function index(Request $request) public function index(Request $request)
{ {
$this->authorize("viewAny", User::class); $this->authorize("viewAny", User::class);
@@ -41,10 +37,6 @@ class UserController extends Controller
return response()->json($query->paginate(15)); return response()->json($query->paginate(15));
} }
/**
* POST /api/users
* Registar um novo utilizador (Admin only)
*/
public function store(Request $request) public function store(Request $request)
{ {
$this->authorize("create", User::class); $this->authorize("create", User::class);
@@ -63,25 +55,15 @@ class UserController extends Controller
return response()->json($user, 201); return response()->json($user, 201);
} }
/**
* GET /api/users/me
* Retorna o perfil do utilizador autenticado atual.
* Usado pelo VueJS para saber quem está logado.
*/
public function me(Request $request) public function me(Request $request)
{ {
return response()->json($request->user()); return response()->json($request->user());
} }
/**
* GET /api/users/{user}
* Mostra um perfil específico.
*/
public function show(User $user) public function show(User $user)
{ {
$currentUser = Auth::user(); $currentUser = Auth::user();
// Admins and owners get full profile, others get limited view
if ($currentUser->type === "A" || $currentUser->id === $user->id) { if ($currentUser->type === "A" || $currentUser->id === $user->id) {
return response()->json($user); return response()->json($user);
} else { } else {
@@ -95,10 +77,6 @@ class UserController extends Controller
} }
} }
/**
* PUT/PATCH /api/users/{user}
* Atualizar perfil (Nome, Nickname, Password, Foto)
*/
public function update(Request $request, User $user) public function update(Request $request, User $user)
{ {
$this->authorize("update", $user); $this->authorize("update", $user);
@@ -119,12 +97,6 @@ class UserController extends Controller
"password" => "sometimes|string|min:3", "password" => "sometimes|string|min:3",
]); ]);
// 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); $user->update($validated);
return response()->json($user); return response()->json($user);
@@ -139,7 +111,6 @@ class UserController extends Controller
"new_password" => "required|string|min:3|confirmed", "new_password" => "required|string|min:3|confirmed",
]); ]);
// Verificar se a password atual está correta
if (!Hash::check($validated["current_password"], $user->password)) { if (!Hash::check($validated["current_password"], $user->password)) {
return response()->json( return response()->json(
["message" => "Current password is incorrect"], ["message" => "Current password is incorrect"],
@@ -147,22 +118,16 @@ class UserController extends Controller
); );
} }
// Atualizar para a nova password
$user->password = Hash::make($validated["new_password"]); $user->password = Hash::make($validated["new_password"]);
$user->save(); $user->save();
return response()->json(["message" => "Password updated successfully"]); return response()->json(["message" => "Password updated successfully"]);
} }
/**
* DELETE /api/users/{user}
* Apagar conta (Soft Delete)
*/
public function destroy(User $user) public function destroy(User $user)
{ {
$this->authorize("delete", $user); $this->authorize("delete", $user);
// Check if user has transactions or games - if so, soft delete
$hasTransactions = $user->transactions()->exists(); $hasTransactions = $user->transactions()->exists();
$hasGames = Game::where(function ($q) use ($user) { $hasGames = Game::where(function ($q) use ($user) {
$q->where("player1_user_id", $user->id)->orWhere( $q->where("player1_user_id", $user->id)->orWhere(
@@ -172,26 +137,21 @@ class UserController extends Controller
})->exists(); })->exists();
if ($hasTransactions || $hasGames) { if ($hasTransactions || $hasGames) {
$user->delete(); // Soft delete $user->delete();
$message = $message =
"User account deactivated (soft-deleted due to transaction/game history)"; "User account deactivated (soft-deleted due to transaction/game history)";
} else { } else {
$user->forceDelete(); // Hard delete $user->forceDelete();
$message = "User permanently deleted"; $message = "User permanently deleted";
} }
return response()->json(["message" => $message]); return response()->json(["message" => $message]);
} }
/**
* GET /api/users/{user}/games
* Histórico de Partidas Individuais (Cartas)
*/
public function getGames(Request $request, User $user) public function getGames(Request $request, User $user)
{ {
$this->authorize("view", $user); $this->authorize("view", $user);
// Define a query base para GAMES
$query = \App\Models\Game::query() $query = \App\Models\Game::query()
->where(function ($q) use ($user) { ->where(function ($q) use ($user) {
$q->where("player1_user_id", $user->id)->orWhere( $q->where("player1_user_id", $user->id)->orWhere(
@@ -209,7 +169,6 @@ class UserController extends Controller
$query->where("status", $request->status); $query->where("status", $request->status);
} }
// Date filtering
if ($request->has("date_from")) { if ($request->has("date_from")) {
$query->whereDate("began_at", ">=", $request->date_from); $query->whereDate("began_at", ">=", $request->date_from);
} }
@@ -218,11 +177,9 @@ class UserController extends Controller
$query->whereDate("began_at", "<=", $request->date_to); $query->whereDate("began_at", "<=", $request->date_to);
} }
// Flexible sorting
$sortColumn = $request->get("sort_by", "began_at"); $sortColumn = $request->get("sort_by", "began_at");
$sortDirection = $request->get("sort_direction", "desc"); $sortDirection = $request->get("sort_direction", "desc");
// Whitelist sortable columns for security
$allowedColumns = [ $allowedColumns = [
"began_at", "began_at",
"ended_at", "ended_at",
@@ -251,10 +208,6 @@ class UserController extends Controller
return response()->json($games); return response()->json($games);
} }
/**
* GET /api/users/{user}/matches
* Histórico de Matches (Apostas / Marcas)
*/
public function getMatches(Request $request, User $user) public function getMatches(Request $request, User $user)
{ {
$this->authorize("view", $user); $this->authorize("view", $user);
@@ -276,7 +229,6 @@ class UserController extends Controller
$query->where("status", $request->status); $query->where("status", $request->status);
} }
// Date filtering
if ($request->has("date_from")) { if ($request->has("date_from")) {
$query->whereDate("began_at", ">=", $request->date_from); $query->whereDate("began_at", ">=", $request->date_from);
} }
@@ -285,11 +237,9 @@ class UserController extends Controller
$query->whereDate("began_at", "<=", $request->date_to); $query->whereDate("began_at", "<=", $request->date_to);
} }
// Flexible sorting
$sortColumn = $request->get("sort_by", "began_at"); $sortColumn = $request->get("sort_by", "began_at");
$sortDirection = $request->get("sort_direction", "desc"); $sortDirection = $request->get("sort_direction", "desc");
// Whitelist sortable columns for security
$allowedColumns = ["began_at", "ended_at", "stake", "total_time"]; $allowedColumns = ["began_at", "ended_at", "stake", "total_time"];
if (in_array($sortColumn, $allowedColumns)) { if (in_array($sortColumn, $allowedColumns)) {
$query->orderBy($sortColumn, $sortDirection); $query->orderBy($sortColumn, $sortDirection);
@@ -308,7 +258,7 @@ class UserController extends Controller
) { ) {
$match->outcome = "loss"; $match->outcome = "loss";
} else { } else {
$match->outcome = "interrupted"; // Matches geralmente não empatam (alguém chega a 4 marcas) $match->outcome = "interrupted";
} }
return $match; return $match;
}); });
@@ -323,7 +273,6 @@ class UserController extends Controller
$user->blocked = true; $user->blocked = true;
$user->save(); $user->save();
// Revoke all active tokens for immediate effect
$user->tokens()->delete(); $user->tokens()->delete();
return response()->json(["message" => "User blocked successfully"]); return response()->json(["message" => "User blocked successfully"]);
@@ -7,31 +7,18 @@ use Illuminate\Support\Facades\Auth;
class StoreGameRequest extends FormRequest class StoreGameRequest extends FormRequest
{ {
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool public function authorize(): bool
{ {
// Só utilizadores autenticados podem criar jogos
return Auth::check(); return Auth::check();
} }
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array public function rules(): array
{ {
return [ return [
// Agora validamos diretamente '3' ou '9' para bater certo com a BD
'type' => 'required|in:3,9', 'type' => 'required|in:3,9',
]; ];
} }
/**
* Mensagens personalizadas (Opcional)
*/
public function messages(): array public function messages(): array
{ {
return [ return [
+1 -2
View File
@@ -9,12 +9,11 @@ class CoinTransactionType extends Model
{ {
use HasFactory; use HasFactory;
// FIX: Disable auto-timestamps because the table doesn't have updated_at/created_at columns
public $timestamps = false; public $timestamps = false;
protected $fillable = [ protected $fillable = [
'name', 'name',
'type', 'type',
'description' // Include description if your table has it 'description'
]; ];
} }
+2 -22
View File
@@ -7,17 +7,11 @@ use App\Models\User;
class GamePolicy class GamePolicy
{ {
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool public function viewAny(User $user): bool
{ {
return true; return true;
} }
/**2
* Determine whether the user can view the model.
*/
public function view(User $user, Game $game): bool public function view(User $user, Game $game): bool
{ {
if ($user->type === "A") { if ($user->type === "A") {
@@ -38,9 +32,6 @@ class GamePolicy
return false; return false;
} }
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool public function create(User $user): bool
{ {
if ($user->type === "A") { if ($user->type === "A") {
@@ -86,9 +77,7 @@ class GamePolicy
return true; return true;
} }
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Game $game): bool public function update(User $user, Game $game): bool
{ {
if ($user->type === "A") { if ($user->type === "A") {
@@ -105,26 +94,17 @@ class GamePolicy
return true; return true;
} }
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, Game $game): bool public function delete(User $user, Game $game): bool
{ {
// Only admins can delete games
return $user->type === "A"; return $user->type === "A";
} }
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, Game $game): bool public function restore(User $user, Game $game): bool
{ {
return false; return false;
} }
/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, Game $game): bool public function forceDelete(User $user, Game $game): bool
{ {
return false; return false;
-1
View File
@@ -17,6 +17,5 @@ return Application::configure(basePath: dirname(__DIR__))
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
//
}) })
->create(); ->create();
-5
View File
@@ -64,11 +64,6 @@ return [
'driver' => 'eloquent', 'driver' => 'eloquent',
'model' => env('AUTH_MODEL', App\Models\User::class), 'model' => env('AUTH_MODEL', App\Models\User::class),
], ],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
], ],
/* /*
-1
View File
@@ -61,7 +61,6 @@ return [
env('MEMCACHED_PASSWORD'), env('MEMCACHED_PASSWORD'),
], ],
'options' => [ 'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
], ],
'servers' => [ 'servers' => [
[ [
-2376
View File
File diff suppressed because it is too large Load Diff
+1 -5
View File
@@ -11,14 +11,12 @@ use App\Http\Controllers\TransactionController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::prefix('v1')->group(function () { Route::prefix('v1')->group(function () {
// Public Routes
Route::post('/login', [AuthController::class, 'login']); Route::post('/login', [AuthController::class, 'login']);
Route::post('/register', [AuthController::class, 'register']); Route::post('/register', [AuthController::class, 'register']);
Route::get('/statistics/public', [StatisticsController::class, 'getPublicStats']); Route::get('/statistics/public', [StatisticsController::class, 'getPublicStats']);
Route::get('/leaderboard', [StatisticsController::class, 'getLeaderboard']); Route::get('/leaderboard', [StatisticsController::class, 'getLeaderboard']);
// Authenticated Routes
Route::middleware(['auth:sanctum', 'throttle:60,1'])->group(function () { Route::middleware(['auth:sanctum', 'throttle:60,1'])->group(function () {
Route::post('/logout', [AuthController::class, 'logout']); Route::post('/logout', [AuthController::class, 'logout']);
Route::post('/coin-purchases', [CoinPurchaseController::class, 'store']); Route::post('/coin-purchases', [CoinPurchaseController::class, 'store']);
@@ -37,7 +35,6 @@ Route::prefix('v1')->group(function () {
Route::get('/transactions', [UserController::class, 'getMyTransactions']); Route::get('/transactions', [UserController::class, 'getMyTransactions']);
}); });
// User Resources
Route::prefix('/users')->group(function () { Route::prefix('/users')->group(function () {
Route::get('/{user}', [UserController::class, 'show']); Route::get('/{user}', [UserController::class, 'show']);
Route::get('/{user}/transactions', [UserController::class, 'getTransactions']); Route::get('/{user}/transactions', [UserController::class, 'getTransactions']);
@@ -61,12 +58,11 @@ Route::prefix('v1')->group(function () {
Route::post('host', [MatchController::class, 'host']); Route::post('host', [MatchController::class, 'host']);
Route::get('open', [MatchController::class, 'open']); Route::get('open', [MatchController::class, 'open']);
Route::post('/{match}/join', [MatchController::class, 'join']); Route::post('/{match}/join', [MatchController::class, 'join']);
Route::post('/{match}/start', [MatchController::class, 'start']); // Changed to /start to be explicit Route::post('/{match}/start', [MatchController::class, 'start']);
Route::delete('/{match}', [MatchController::class, 'destroy']); Route::delete('/{match}', [MatchController::class, 'destroy']);
Route::apiResource('/', MatchController::class)->parameters(['' => 'match']); Route::apiResource('/', MatchController::class)->parameters(['' => 'match']);
}); });
// Admin Routes
Route::middleware('user.type:A') Route::middleware('user.type:A')
->prefix('admin') ->prefix('admin')
->group(function () { ->group(function () {
-2
View File
@@ -42,8 +42,6 @@ const logout = () => {
onMounted(async () => { onMounted(async () => {
await authStore.restoreSession() await authStore.restoreSession()
// REMOVED - The WebSocket for multiplayer is handled separately in the WebSocketStore
// socketStore.handleConnection()
}) })
</script> </script>
+1 -3
View File
@@ -88,7 +88,6 @@ const props = defineProps({
const deckPulse = ref(false) const deckPulse = ref(false)
const trumpReveal = ref(false) const trumpReveal = ref(false)
// Pulse animation when cards remaining changes
watch( watch(
() => props.cardsRemaining, () => props.cardsRemaining,
() => { () => {
@@ -99,7 +98,6 @@ watch(
}, },
) )
// Trump reveal animation
watch( watch(
() => props.revealTrump, () => props.revealTrump,
(newValue) => { (newValue) => {
@@ -107,7 +105,7 @@ watch(
trumpReveal.value = true trumpReveal.value = true
setTimeout(() => { setTimeout(() => {
trumpReveal.value = false trumpReveal.value = false
}, 3000) // Show for 3 seconds }, 3000)
} }
}, },
) )
@@ -54,18 +54,14 @@ const isAnimating = ref(false)
const currentPosition = ref({ x: 0, y: 0 }) const currentPosition = ref({ x: 0, y: 0 })
onMounted(() => { onMounted(() => {
// Start at the deck position
currentPosition.value = { ...props.startPosition } currentPosition.value = { ...props.startPosition }
isVisible.value = true isVisible.value = true
// Wait for delay, then start animation
setTimeout(() => { setTimeout(() => {
// Force a reflow to ensure starting position is set
requestAnimationFrame(() => { requestAnimationFrame(() => {
isAnimating.value = true isAnimating.value = true
currentPosition.value = { ...props.endPosition } currentPosition.value = { ...props.endPosition }
// After animation completes, emit event and hide
setTimeout(() => { setTimeout(() => {
emit('complete') emit('complete')
isVisible.value = false isVisible.value = false
@@ -61,7 +61,6 @@ const props = defineProps({
cardsRemaining: { type: Number, default: 0 }, cardsRemaining: { type: Number, default: 0 },
deckIsEmpty: { type: Boolean, default: false }, deckIsEmpty: { type: Boolean, default: false },
maxCards: { type: Number, default: 3 }, maxCards: { type: Number, default: 3 },
// REMOVIDO: playableCardIndices (já não precisamos dele)
playerScore: { type: Number, default: 0 }, playerScore: { type: Number, default: 0 },
opponentScore: { type: Number, default: 0 }, opponentScore: { type: Number, default: 0 },
playerName: { type: String, default: 'You' }, playerName: { type: String, default: 'You' },
@@ -218,21 +218,18 @@ const showConfetti = ref(false)
const displayPlayerScore = ref(0) const displayPlayerScore = ref(0)
const displayOpponentScore = ref(0) const displayOpponentScore = ref(0)
// Computed title based on winner
const title = computed(() => { const title = computed(() => {
if (props.winner === 'player') return 'Victory!' if (props.winner === 'player') return 'Victory!'
if (props.winner === 'opponent') return 'Defeat' if (props.winner === 'opponent') return 'Defeat'
return 'Draw!' return 'Draw!'
}) })
// Computed subtitle
const subtitle = computed(() => { const subtitle = computed(() => {
if (props.winner === 'player') return 'Congratulations! You won the game!' if (props.winner === 'player') return 'Congratulations! You won the game!'
if (props.winner === 'opponent') return 'Better luck next time!' if (props.winner === 'opponent') return 'Better luck next time!'
return 'The game ended in a draw' return 'The game ended in a draw'
}) })
// Animate score count-up
const animateScore = (fromValue, toValue, callback) => { const animateScore = (fromValue, toValue, callback) => {
const duration = 1000 const duration = 1000
const steps = 30 const steps = 30
@@ -255,19 +252,16 @@ const animateScore = (fromValue, toValue, callback) => {
}, stepDuration) }, stepDuration)
} }
// Watch for visibility and trigger animations
watch( watch(
() => props.isVisible, () => props.isVisible,
(newValue) => { (newValue) => {
if (newValue) { if (newValue) {
// Start confetti if player wins
if (props.winner === 'player') { if (props.winner === 'player') {
setTimeout(() => { setTimeout(() => {
showConfetti.value = true showConfetti.value = true
}, 300) }, 300)
} }
// Animate scores counting up
setTimeout(() => { setTimeout(() => {
animateScore(0, props.playerScore, (value) => { animateScore(0, props.playerScore, (value) => {
displayPlayerScore.value = value displayPlayerScore.value = value
@@ -99,10 +99,9 @@ const turnChanged = ref(false)
const displayPlayerScore = ref(props.playerScore) const displayPlayerScore = ref(props.playerScore)
const displayOpponentScore = ref(props.opponentScore) const displayOpponentScore = ref(props.opponentScore)
// Animate score count-up
const animateScore = (fromValue, toValue, callback) => { const animateScore = (fromValue, toValue, callback) => {
const duration = 500 // Total animation duration in ms const duration = 500
const steps = 20 // Number of increments const steps = 20
const stepDuration = duration / steps const stepDuration = duration / steps
const increment = (toValue - fromValue) / steps const increment = (toValue - fromValue) / steps
@@ -122,7 +121,6 @@ const animateScore = (fromValue, toValue, callback) => {
}, stepDuration) }, stepDuration)
} }
// Watch for player score changes and trigger count-up animation
watch( watch(
() => props.playerScore, () => props.playerScore,
(newScore, oldScore) => { (newScore, oldScore) => {
@@ -143,7 +141,6 @@ watch(
{ immediate: true }, { immediate: true },
) )
// Watch for opponent score changes and trigger count-up animation
watch( watch(
() => props.opponentScore, () => props.opponentScore,
(newScore, oldScore) => { (newScore, oldScore) => {
@@ -164,7 +161,6 @@ watch(
{ immediate: true }, { immediate: true },
) )
// Watch for turn changes and trigger pulse animation
watch( watch(
() => props.currentTurn, () => props.currentTurn,
() => { () => {
-1
View File
@@ -13,7 +13,6 @@ console.log('[main.js] ws connection', wsConnection)
const app = createApp(App) const app = createApp(App)
//app.provide('socket', io(wsConnection))
app.provide('serverBaseURL', `http://${apiDomain}`) app.provide('serverBaseURL', `http://${apiDomain}`)
app.provide('apiBaseURL', `http://${apiDomain}/api/v1`) app.provide('apiBaseURL', `http://${apiDomain}/api/v1`)
+5 -6
View File
@@ -812,7 +812,7 @@ const stats = ref({})
const users = ref([]) const users = ref([])
const transactions = ref([]) const transactions = ref([])
const games = ref([]) const games = ref([])
const matches = ref([]) // NEW const matches = ref([])
const isLoading = ref(false) const isLoading = ref(false)
const loadMoreTrigger = ref(null) const loadMoreTrigger = ref(null)
@@ -822,7 +822,7 @@ const pagination = reactive({
users: { page: 1, lastPage: 1 }, users: { page: 1, lastPage: 1 },
transactions: { page: 1, lastPage: 1 }, transactions: { page: 1, lastPage: 1 },
games: { page: 1, lastPage: 1 }, games: { page: 1, lastPage: 1 },
matches: { page: 1, lastPage: 1 }, // NEW matches: { page: 1, lastPage: 1 },
}) })
const filters = reactive({ const filters = reactive({
@@ -832,7 +832,6 @@ const filters = reactive({
sort_coins: null, sort_coins: null,
game_sort_direction: 'desc', game_sort_direction: 'desc',
game_type: null, game_type: null,
// Match filters
match_type: null, match_type: null,
match_outcome: null, match_outcome: null,
match_date_from: '', match_date_from: '',
@@ -852,7 +851,7 @@ const tabs = [
{ id: 'users', name: 'User Management' }, { id: 'users', name: 'User Management' },
{ id: 'transactions', name: 'Transactions' }, { id: 'transactions', name: 'Transactions' },
{ id: 'games', name: 'Games History' }, { id: 'games', name: 'Games History' },
{ id: 'matches', name: 'Matches History' }, // NEW { id: 'matches', name: 'Matches History' },
] ]
const chartData = computed(() => { const chartData = computed(() => {
@@ -1028,10 +1027,10 @@ const handleSort = async (column) => {
filters.match_type = filters.match_type =
filters.match_type === null ? '9' : filters.match_type === '9' ? '3' : null filters.match_type === null ? '9' : filters.match_type === '9' ? '3' : null
} else if (column === 'date') { } else if (column === 'date') {
filters.pot = null // Reset pot sort when sorting by date filters.pot = null
filters.match_sort_direction = filters.match_sort_direction === 'desc' ? 'asc' : 'desc' filters.match_sort_direction = filters.match_sort_direction === 'desc' ? 'asc' : 'desc'
} else if (column === 'pot') { } else if (column === 'pot') {
filters.match_sort_direction = null // Reset date sort when sorting by pot filters.match_sort_direction = null
if (filters.pot === null) filters.pot = 'desc' if (filters.pot === null) filters.pot = 'desc'
else if (filters.pot === 'desc') filters.pot = 'asc' else if (filters.pot === 'desc') filters.pot = 'asc'
else filters.pot = 'desc' else filters.pot = 'desc'
+195 -143
View File
@@ -1,28 +1,44 @@
<template> <template>
<div class="relative min-h-screen overflow-hidden bg-green-900"> <div class="relative min-h-screen overflow-hidden bg-green-900">
<div v-if="shouldShowBoard && !gameOver" class="absolute top-4 right-4 z-50"> <div v-if="shouldShowBoard && !gameOver" class="absolute top-4 right-4 z-50">
<button @click="handleSurrender" <button
class="bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-lg shadow-lg border-2 border-red-800 transition-all transform hover:scale-105 flex items-center gap-2"> @click="handleSurrender"
class="bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-lg shadow-lg border-2 border-red-800 transition-all transform hover:scale-105 flex items-center gap-2"
>
<span>🏳</span> Surrender <span>🏳</span> Surrender
</button> </button>
</div> </div>
<div v-if="!wsStore.connected && hasConnectedOnce" <div
class="absolute top-4 left-4 z-50 flex items-center gap-2 bg-gray-800 px-4 py-2 rounded-lg border border-gray-700 shadow-xl"> v-if="!wsStore.connected && hasConnectedOnce"
class="absolute top-4 left-4 z-50 flex items-center gap-2 bg-gray-800 px-4 py-2 rounded-lg border border-gray-700 shadow-xl"
>
<div class="w-3 h-3 rounded-full bg-red-500 animate-pulse"></div> <div class="w-3 h-3 rounded-full bg-red-500 animate-pulse"></div>
<span class="text-white text-sm">Reconnecting...</span> <span class="text-white text-sm">Reconnecting...</span>
</div> </div>
<div v-if="shouldShowBoard && !gameOver && isMyTurn" class="absolute top-4 left-1/2 -translate-x-1/2 z-50"> <div
<div class="bg-gray-800 px-6 py-3 rounded-lg border-2 shadow-xl transition-colors duration-300" v-if="shouldShowBoard && !gameOver && isMyTurn"
:class="timeLeft <= 5 ? 'border-red-500 bg-red-900/20' : 'border-gray-700'"> class="absolute top-4 left-1/2 -translate-x-1/2 z-50"
>
<div
class="bg-gray-800 px-6 py-3 rounded-lg border-2 shadow-xl transition-colors duration-300"
:class="timeLeft <= 5 ? 'border-red-500 bg-red-900/20' : 'border-gray-700'"
>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<svg class="w-6 h-6" :class="timeLeft <= 5 ? 'text-red-500 animate-bounce' : 'text-white'" viewBox="0 0 24 24" <svg
fill="none" stroke="currentColor"> class="w-6 h-6"
:class="timeLeft <= 5 ? 'text-red-500 animate-bounce' : 'text-white'"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
>
<circle cx="12" cy="12" r="10" stroke-width="2" /> <circle cx="12" cy="12" r="10" stroke-width="2" />
<polyline points="12 6 12 12 16 14" stroke-width="2" /> <polyline points="12 6 12 12 16 14" stroke-width="2" />
</svg> </svg>
<span :class="['text-3xl font-mono font-bold', timeLeft <= 5 ? 'text-red-500' : 'text-white']"> <span
:class="['text-3xl font-mono font-bold', timeLeft <= 5 ? 'text-red-500' : 'text-white']"
>
{{ timeLeft }} {{ timeLeft }}
</span> </span>
</div> </div>
@@ -30,41 +46,67 @@
</div> </div>
<div v-if="shouldShowBoard"> <div v-if="shouldShowBoard">
<GameBoard :trump-card="trumpCard" :cards-remaining="deckCount + (trumpCard ? 1 : 0)" :player-hand="visibleHand" <GameBoard
:opponent-hand="opponentHand" :player-score="playerScore" :opponent-score="opponentScore" :trump-card="trumpCard"
:player-name="myDisplayName" :opponent-name="opponentDisplayName" :current-turn="currentTurnLabel" :cards-remaining="deckCount + (trumpCard ? 1 : 0)"
:current-trick="displayedTrick" :is-my-turn="isMyTurn" @play-card="handlePlayCard" /> :player-hand="visibleHand"
:opponent-hand="opponentHand"
:player-score="playerScore"
:opponent-score="opponentScore"
:player-name="myDisplayName"
:opponent-name="opponentDisplayName"
:current-turn="currentTurnLabel"
:current-trick="displayedTrick"
:is-my-turn="isMyTurn"
@play-card="handlePlayCard"
/>
</div> </div>
<div v-else-if="!gameOver" class="flex h-screen items-center justify-center bg-green-900 text-white"> <div
<div class="text-center p-8 bg-green-800/50 rounded-2xl border border-green-700 shadow-2xl backdrop-blur-sm"> v-else-if="!gameOver"
class="flex h-screen items-center justify-center bg-green-900 text-white"
>
<div
class="text-center p-8 bg-green-800/50 rounded-2xl border border-green-700 shadow-2xl backdrop-blur-sm"
>
<div class="relative mx-auto mb-6 h-16 w-16"> <div class="relative mx-auto mb-6 h-16 w-16">
<div class="absolute inset-0 rounded-full border-4 border-green-600"></div> <div class="absolute inset-0 rounded-full border-4 border-green-600"></div>
<div <div
class="absolute inset-0 rounded-full border-4 border-t-white border-r-transparent border-b-transparent border-l-transparent animate-spin"> class="absolute inset-0 rounded-full border-4 border-t-white border-r-transparent border-b-transparent border-l-transparent animate-spin"
</div> ></div>
</div> </div>
<h2 class="text-2xl font-bold mb-2"> <h2 class="text-2xl font-bold mb-2">Waiting for Opponent...</h2>
Waiting for Opponent...
</h2>
<p class="text-green-100 mb-8 max-w-sm"> <p class="text-green-100 mb-8 max-w-sm">
Share the game code or wait for a player to join your lobby. Share the game code or wait for a player to join your lobby.
</p> </p>
<div class="bg-black/20 p-3 rounded mb-6 font-mono text-sm border border-white/10 text-green-100"> <div
class="bg-black/20 p-3 rounded mb-6 font-mono text-sm border border-white/10 text-green-100"
>
Match ID: <span class="font-bold text-white">{{ matchState.id }}</span> Match ID: <span class="font-bold text-white">{{ matchState.id }}</span>
</div> </div>
<button @click="handleCloseModal" <button
class="px-6 py-3 bg-red-600 hover:bg-red-700 text-white rounded-lg font-semibold transition-all shadow-lg hover:shadow-red-500/20"> @click="handleCloseModal"
class="px-6 py-3 bg-red-600 hover:bg-red-700 text-white rounded-lg font-semibold transition-all shadow-lg hover:shadow-red-500/20"
>
Cancel & Return Home Cancel & Return Home
</button> </button>
</div> </div>
</div> </div>
<GameOver :is-visible="gameOver" :winner="winnerResult" :player-score="playerScore" :opponent-score="opponentScore" <GameOver
:player-name="myDisplayName" :opponent-name="opponentDisplayName" :stats="{ playerTricks: 0, opponentTricks: 0 }" :is-visible="gameOver"
:is-logging-out="false" @play-again="handleCloseModal" @close="handleCloseModal" /> :winner="winnerResult"
:player-score="playerScore"
:opponent-score="opponentScore"
:player-name="myDisplayName"
:opponent-name="opponentDisplayName"
:stats="{ playerTricks: 0, opponentTricks: 0 }"
:is-logging-out="false"
@play-again="handleCloseModal"
@close="handleCloseModal"
/>
</div> </div>
</template> </template>
@@ -94,29 +136,26 @@ const timeLeft = ref(20)
const timerInterval = ref(null) const timerInterval = ref(null)
const hasConnectedOnce = ref(false) const hasConnectedOnce = ref(false)
// --- STATE ---
const matchState = computed(() => wsStore.gameState || { id: gameId.value }) const matchState = computed(() => wsStore.gameState || { id: gameId.value })
const gameMetadata = ref({ const gameMetadata = ref({
p1_id: null, p1_id: null,
p1_name: 'Player 1', p1_name: 'Player 1',
p2_id: null, p2_id: null,
p2_name: 'Opponent' p2_name: 'Opponent',
}) })
const displayedTrick = ref({ playerCard: null, opponentCard: null }) const displayedTrick = ref({ playerCard: null, opponentCard: null })
const trickClearTimer = ref(null) const trickClearTimer = ref(null)
// --- COMPUTED ---
const myUserId = computed(() => authStore.currentUser?.id) const myUserId = computed(() => authStore.currentUser?.id)
const shouldShowBoard = computed(() => { const shouldShowBoard = computed(() => {
const s = wsStore.gameState; const s = wsStore.gameState
if (s && s.player2 && s.player2.id > 1) { if (s && s.player2 && s.player2.id > 1) {
return true; return true
} }
return false; return false
}) })
const amIHost = computed(() => { const amIHost = computed(() => {
@@ -127,8 +166,12 @@ const amIHost = computed(() => {
return String(p1Id) === String(myUserId.value) return String(p1Id) === String(myUserId.value)
}) })
const myDisplayName = computed(() => amIHost.value ? gameMetadata.value.p1_name : gameMetadata.value.p2_name) const myDisplayName = computed(() =>
const opponentDisplayName = computed(() => amIHost.value ? gameMetadata.value.p2_name : gameMetadata.value.p1_name) amIHost.value ? gameMetadata.value.p1_name : gameMetadata.value.p2_name,
)
const opponentDisplayName = computed(() =>
amIHost.value ? gameMetadata.value.p2_name : gameMetadata.value.p1_name,
)
const mappedTrick = computed(() => { const mappedTrick = computed(() => {
const table = wsStore.gameState?.table const table = wsStore.gameState?.table
@@ -157,54 +200,63 @@ const opponentHand = computed(() => {
return Array.from({ length: count }, (_, i) => ({ id: `opp-${i}`, suit: 'back', rank: 0 })) return Array.from({ length: count }, (_, i) => ({ id: `opp-${i}`, suit: 'back', rank: 0 }))
}) })
// --- WATCHERS --- watch(
() => wsStore.connected,
watch(() => wsStore.connected, (isConnected) => { (isConnected) => {
if (isConnected) { if (isConnected) {
hasConnectedOnce.value = true hasConnectedOnce.value = true
wsStore.joinGame(gameId.value) wsStore.joinGame(gameId.value)
}
}, { immediate: true })
watch(() => wsStore.gameState, async (newState, oldState) => {
const oldP2 = oldState?.player2?.id;
const newP2 = newState?.player2?.id;
if (newP2 && newP2 > 1 && newP2 !== oldP2) {
await fetchGameMetadata();
}
}, { deep: true })
watch(mappedTrick, (newVal) => {
if (newVal.playerCard || newVal.opponentCard) {
if (trickClearTimer.value) clearTimeout(trickClearTimer.value)
displayedTrick.value = { ...newVal }
} else {
const currentlyShowing = displayedTrick.value.playerCard || displayedTrick.value.opponentCard
if (currentlyShowing) {
trickClearTimer.value = setTimeout(() => {
displayedTrick.value = { playerCard: null, opponentCard: null }
}, 1500)
} else {
displayedTrick.value = { playerCard: null, opponentCard: null }
} }
} },
}, { deep: true }) { immediate: true },
)
watch(
() => wsStore.gameState,
async (newState, oldState) => {
const oldP2 = oldState?.player2?.id
const newP2 = newState?.player2?.id
if (newP2 && newP2 > 1 && newP2 !== oldP2) {
await fetchGameMetadata()
}
},
{ deep: true },
)
watch(
mappedTrick,
(newVal) => {
if (newVal.playerCard || newVal.opponentCard) {
if (trickClearTimer.value) clearTimeout(trickClearTimer.value)
displayedTrick.value = { ...newVal }
} else {
const currentlyShowing = displayedTrick.value.playerCard || displayedTrick.value.opponentCard
if (currentlyShowing) {
trickClearTimer.value = setTimeout(() => {
displayedTrick.value = { playerCard: null, opponentCard: null }
}, 1500)
} else {
displayedTrick.value = { playerCard: null, opponentCard: null }
}
}
},
{ deep: true },
)
// --- FIX: TIMER GHOSTING ---
const startTimer = () => { const startTimer = () => {
if (timerInterval.value) clearInterval(timerInterval.value) if (timerInterval.value) clearInterval(timerInterval.value)
if (!shouldShowBoard.value && wsStore.gameState?.status !== 'playing') { if (!shouldShowBoard.value && wsStore.gameState?.status !== 'playing') {
timeLeft.value = 20; timeLeft.value = 20
return; return
} }
// FORCE RESET to avoid showing old time for 1 frame timeLeft.value = 20
timeLeft.value = 20;
const startAt = wsStore.gameState?.turnStartedAt ? new Date(wsStore.gameState.turnStartedAt).getTime() : Date.now() const startAt = wsStore.gameState?.turnStartedAt
? new Date(wsStore.gameState.turnStartedAt).getTime()
: Date.now()
// Define update function
const update = () => { const update = () => {
const now = Date.now() const now = Date.now()
const elapsed = Math.floor((now - startAt) / 1000) const elapsed = Math.floor((now - startAt) / 1000)
@@ -212,16 +264,18 @@ const startTimer = () => {
if (timeLeft.value <= 0) clearInterval(timerInterval.value) if (timeLeft.value <= 0) clearInterval(timerInterval.value)
} }
// EXECUTE IMMEDIATELY update()
update();
// Then set interval
timerInterval.value = setInterval(update, 1000) timerInterval.value = setInterval(update, 1000)
} }
watch(() => wsStore.gameState?.currentTurn, (newTurn) => { watch(
if (newTurn) startTimer() () => wsStore.gameState?.currentTurn,
}, { immediate: true }) (newTurn) => {
if (newTurn) startTimer()
},
{ immediate: true },
)
const animateDeal = async (cards) => { const animateDeal = async (cards) => {
if (isDealing.value) return if (isDealing.value) return
@@ -229,21 +283,23 @@ const animateDeal = async (cards) => {
visibleHand.value = [] visibleHand.value = []
for (const card of cards) { for (const card of cards) {
visibleHand.value.push(card) visibleHand.value.push(card)
await new Promise(r => setTimeout(r, 150)) await new Promise((r) => setTimeout(r, 150))
} }
isDealing.value = false isDealing.value = false
} }
watch(serverHand, (newHand) => { watch(
if (visibleHand.value.length === 0 && newHand.length > 0 && !hasInitialDealHappened.value) { serverHand,
animateDeal(newHand) (newHand) => {
hasInitialDealHappened.value = true if (visibleHand.value.length === 0 && newHand.length > 0 && !hasInitialDealHappened.value) {
} else if (!isDealing.value) { animateDeal(newHand)
visibleHand.value = [...newHand] hasInitialDealHappened.value = true
} } else if (!isDealing.value) {
}, { deep: true }) visibleHand.value = [...newHand]
}
// --- METHODS --- },
{ deep: true },
)
const fetchGameMetadata = async () => { const fetchGameMetadata = async () => {
try { try {
@@ -253,27 +309,26 @@ const fetchGameMetadata = async () => {
p1_id: game.player1_user_id, p1_id: game.player1_user_id,
p1_name: game.player1?.nickname || 'Host', p1_name: game.player1?.nickname || 'Host',
p2_id: game.player2_user_id, p2_id: game.player2_user_id,
p2_name: game.player2?.nickname || 'Opponent' p2_name: game.player2?.nickname || 'Opponent',
} }
} catch (e) { } catch (e) {
console.error("Failed to load metadata", e) console.error('Failed to load metadata', e)
} }
} }
const handlePlayCard = (card) => { const handlePlayCard = (card) => {
if (!isMyTurn.value || gameOver.value) return if (!isMyTurn.value || gameOver.value) return
visibleHand.value = visibleHand.value.filter(c => c.id !== card.id) visibleHand.value = visibleHand.value.filter((c) => c.id !== card.id)
wsStore.playCard(gameId.value, card.id) wsStore.playCard(gameId.value, card.id)
} }
const handleBeforeUnload = (event) => { const handleBeforeUnload = (event) => {
if (shouldShowBoard.value && !gameOver.value) { if (shouldShowBoard.value && !gameOver.value) {
event.preventDefault(); event.preventDefault()
event.returnValue = ''; event.returnValue = ''
} }
}; }
// --- FIX: SURRENDER LOGIC ---
const handleSurrender = () => { const handleSurrender = () => {
if (confirm('Are you sure you want to surrender? You will lose.')) { if (confirm('Are you sure you want to surrender? You will lose.')) {
wsStore.surrender(gameId.value) wsStore.surrender(gameId.value)
@@ -281,93 +336,90 @@ const handleSurrender = () => {
} }
const handleCloseModal = async () => { 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) { if (!shouldShowBoard.value && amIHost.value && !gameOver.value) {
try { try {
// This calls Route::delete('/{game}', ...) in your api.php await axios.delete(`${API_BASE_URL}/games/${gameId.value}`)
await axios.delete(`${API_BASE_URL}/games/${gameId.value}`); console.log('Lobby cancelled and deleted.')
console.log("Lobby cancelled and deleted.");
} catch (e) { } catch (e) {
console.error("Failed to delete pending game:", e); console.error('Failed to delete pending game:', e)
} }
} }
// 2. Notify server of voluntary exit (to skip the 10s reconnect timer)
if (wsStore.socket) { if (wsStore.socket) {
wsStore.socket.emit('notify_disconnect'); wsStore.socket.emit('notify_disconnect')
} }
// 3. Disconnect and go home wsStore.disconnect()
wsStore.disconnect(); router.push({ name: 'home' })
router.push({ name: 'home' });
} }
// --- LIFECYCLE ---
onBeforeRouteLeave(async (to, from, next) => { onBeforeRouteLeave(async (to, from, next) => {
// CASE A: Game is Pending (Waiting Room)
if (!shouldShowBoard.value && !gameOver.value) { if (!shouldShowBoard.value && !gameOver.value) {
if (amIHost.value) { if (amIHost.value) {
// Ask for confirmation to close the lobby const confirmClose = window.confirm('This will cancel the lobby. Are you sure?')
const confirmClose = window.confirm("This will cancel the lobby. Are you sure?");
if (confirmClose) { if (confirmClose) {
try { try {
// Delete the game from DB so it doesn't get stuck as "Pending" await axios.delete(`${API_BASE_URL}/games/${gameId.value}`)
await axios.delete(`${API_BASE_URL}/games/${gameId.value}`); } catch (e) {
} catch (e) { console.error(e) } console.error(e)
}
if (wsStore.socket) wsStore.socket.emit('notify_disconnect'); if (wsStore.socket) wsStore.socket.emit('notify_disconnect')
wsStore.disconnect(); wsStore.disconnect()
next(); next()
} else { } else {
next(false); // Stay on page next(false)
} }
return; return
} else { } else {
// If I am NOT host (just a guest waiting), just leave if (wsStore.socket) wsStore.socket.emit('notify_disconnect')
if (wsStore.socket) wsStore.socket.emit('notify_disconnect'); wsStore.disconnect()
wsStore.disconnect(); next()
next(); return
return;
} }
} }
// CASE B: Game is Over (Standard exit)
if (gameOver.value) { if (gameOver.value) {
next(); next()
return; return
} }
// CASE C: Game is Active/Playing (The Surrender Logic we built before)
const answer = window.confirm( const answer = window.confirm(
"If you leave, you will be register has 'losing' or 'surrendered', Are you sure you want to leave?" "If you leave, you will be register has 'losing' or 'surrendered', Are you sure you want to leave?",
); )
if (answer) { if (answer) {
// ... (Your existing surrender logic) ... try {
try { await wsStore.surrender(gameId.value); } catch (e) { } await wsStore.surrender(gameId.value)
} catch (e) {}
if (wsStore.socket) { if (wsStore.socket) {
wsStore.socket.emit('notify_disconnect', () => { wsStore.socket.emit('notify_disconnect', () => {
wsStore.disconnect(); wsStore.disconnect()
next(); next()
}); })
setTimeout(() => { if (wsStore.connected) { wsStore.disconnect(); next(); } }, 500); setTimeout(() => {
return; if (wsStore.connected) {
wsStore.disconnect()
next()
}
}, 500)
return
} }
wsStore.disconnect(); wsStore.disconnect()
next(); next()
} else { } else {
next(false); next(false)
} }
}); })
onMounted(async () => { onMounted(async () => {
await fetchGameMetadata() await fetchGameMetadata()
wsStore.connect() wsStore.connect()
wsStore.onTrickEnd((result) => { wsStore.onTrickEnd((result) => {
const winnerName = result.winnerId == authStore.currentUser?.id ? "You" : "Opponent" const winnerName = result.winnerId == authStore.currentUser?.id ? 'You' : 'Opponent'
toast.success(`${winnerName} won the trick!`, { position: 'top-center', duration: 1500 }) toast.success(`${winnerName} won the trick!`, { position: 'top-center', duration: 1500 })
}) })
+119 -108
View File
@@ -15,7 +15,6 @@ const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const wsStore = useSocketStore() const wsStore = useSocketStore()
// --- FIX: Flag to prevent double cancellation ---
const isCancelling = ref(false) const isCancelling = ref(false)
const matchState = ref({ const matchState = ref({
@@ -53,16 +52,15 @@ const opponentHand = computed(() => {
return Array.from({ length: count }, (_, i) => ({ id: `opp-${i}`, suit: 'back', rank: 0 })) return Array.from({ length: count }, (_, i) => ({ id: `opp-${i}`, suit: 'back', rank: 0 }))
}) })
// Timer
watch(() => wsStore.gameState?.currentTurn, () => { startTimer() }, { deep: true }) watch(() => wsStore.gameState?.currentTurn, () => { startTimer() }, { deep: true })
const startTimer = () => { const startTimer = () => {
if (timerInterval.value) clearInterval(timerInterval.value); if (timerInterval.value) clearInterval(timerInterval.value);
timeLeft.value = 20; timeLeft.value = 20;
if(!wsStore.gameState) return; if (!wsStore.gameState) return;
timerInterval.value = setInterval(() => { timerInterval.value = setInterval(() => {
if(timeLeft.value > 0) timeLeft.value--; if (timeLeft.value > 0) timeLeft.value--;
else clearInterval(timerInterval.value); else clearInterval(timerInterval.value);
}, 1000); }, 1000);
} }
const handlePlayCard = (card) => { const handlePlayCard = (card) => {
@@ -71,12 +69,12 @@ const handlePlayCard = (card) => {
const openSurrenderModal = () => { showSurrenderModal.value = true } const openSurrenderModal = () => { showSurrenderModal.value = true }
const confirmSurrenderRound = () => { const confirmSurrenderRound = () => {
showSurrenderModal.value = false; showSurrenderModal.value = false;
if (wsStore.socket) wsStore.socket.emit('game:surrender', { matchId: matchState.value.id }); if (wsStore.socket) wsStore.socket.emit('game:surrender', { matchId: matchState.value.id });
} }
const confirmSurrenderMatch = () => { const confirmSurrenderMatch = () => {
showSurrenderModal.value = false; showSurrenderModal.value = false;
if (wsStore.socket) wsStore.socket.emit('match:surrender', { matchId: matchState.value.id }); if (wsStore.socket) wsStore.socket.emit('match:surrender', { matchId: matchState.value.id });
} }
const closeRoundModal = () => { showRoundModal.value = false; } const closeRoundModal = () => { showRoundModal.value = false; }
@@ -103,7 +101,6 @@ const updateLocalState = (data) => {
} }
} }
// --- FIX: Cancel Match with Flag ---
const cancelMatch = async () => { const cancelMatch = async () => {
if (isCancelling.value) return; if (isCancelling.value) return;
isCancelling.value = true; isCancelling.value = true;
@@ -112,12 +109,11 @@ const cancelMatch = async () => {
await axios.delete(`${API_BASE_URL}/matches/${matchState.value.id}`) await axios.delete(`${API_BASE_URL}/matches/${matchState.value.id}`)
router.push({ name: 'home' }) router.push({ name: 'home' })
} catch (e) { } catch (e) {
// Ignore 404s if we just deleted it
if (e.response && e.response.status === 404) { if (e.response && e.response.status === 404) {
router.push({ name: 'home' }) router.push({ name: 'home' })
} else { } else {
isCancelling.value = false; // Reset if legitimate error isCancelling.value = false;
alert(e.message) alert(e.message)
} }
} }
} }
@@ -131,28 +127,25 @@ const handleBeforeUnload = (e) => {
} }
} }
// --- FIX: Router Guard checks Flag ---
onBeforeRouteLeave((to, from, next) => { onBeforeRouteLeave((to, from, next) => {
// If we already clicked cancel, just let it go
if (isCancelling.value) { if (isCancelling.value) {
return next(); return next();
} }
if (matchState.value.status === 'Playing' && !matchOver.value) { if (matchState.value.status === 'Playing' && !matchOver.value) {
if (confirm("⚠️ WARNING: Leaving now triggers an automatic match surrender (Loss).\n\nAre you sure?")) { if (confirm("⚠️ WARNING: Leaving now triggers an automatic match surrender (Loss).\n\nAre you sure?")) {
if(wsStore.socket) wsStore.socket.emit('match:surrender', { matchId: matchState.value.id }); if (wsStore.socket) wsStore.socket.emit('match:surrender', { matchId: matchState.value.id });
next(); next();
} else { } else {
next(false); next(false);
} }
} else if (isPending.value && amIHost.value) { } else if (isPending.value && amIHost.value) {
if(confirm("Cancel this lobby?")) { if (confirm("Cancel this lobby?")) {
// Mark as cancelling so we don't trigger double requests if cancelMatch is weird isCancelling.value = true;
isCancelling.value = true; cancelMatch().then(() => next());
cancelMatch().then(() => next()); } else {
} else { next(false);
next(false); }
}
} else { } else {
next(); next();
} }
@@ -179,22 +172,22 @@ onMounted(async () => {
wsStore.socket.on('match:ended', updateLocalState) wsStore.socket.on('match:ended', updateLocalState)
wsStore.socket.on('game:update', (state) => { wsStore.gameState = state }) wsStore.socket.on('game:update', (state) => { wsStore.gameState = state })
wsStore.socket.on('game:trick-end', (result) => { wsStore.socket.on('game:trick-end', (result) => {
lastTrickWinner.value = result.winnerId; lastTrickWinner.value = result.winnerId;
showTrickResult.value = true; showTrickResult.value = true;
setTimeout(() => { showTrickResult.value = false }, 2000); setTimeout(() => { showTrickResult.value = false }, 2000);
}); });
wsStore.socket.on('match:round-ended', (data) => { wsStore.socket.on('match:round-ended', (data) => {
const myPoints = data.winnerId == myUserId.value ? (data.winnerId == matchState.value.player1.id ? data.p1Points : data.p2Points) : (data.winnerId == matchState.value.player1.id ? data.p2Points : data.p1Points); const myPoints = data.winnerId == myUserId.value ? (data.winnerId == matchState.value.player1.id ? data.p1Points : data.p2Points) : (data.winnerId == matchState.value.player1.id ? data.p2Points : data.p1Points);
const oppPoints = data.winnerId != myUserId.value ? (data.winnerId == matchState.value.player1.id ? data.p1Points : data.p2Points) : (data.winnerId == matchState.value.player1.id ? data.p2Points : data.p1Points); const oppPoints = data.winnerId != myUserId.value ? (data.winnerId == matchState.value.player1.id ? data.p1Points : data.p2Points) : (data.winnerId == matchState.value.player1.id ? data.p2Points : data.p1Points);
roundData.value = { roundData.value = {
winnerId: data.winnerId, winnerId: data.winnerId,
marksAdded: data.marksAdded, marksAdded: data.marksAdded,
myPoints: myPoints, myPoints: myPoints,
oppPoints: oppPoints, oppPoints: oppPoints,
reason: data.reason reason: data.reason
}; };
showRoundModal.value = true; showRoundModal.value = true;
}); });
} }
}) })
@@ -218,7 +211,8 @@ onUnmounted(() => {
<div class="flex flex-col items-start"> <div class="flex flex-col items-start">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="font-bold text-lg text-white drop-shadow-md">{{ myDisplayName }}</span> <span class="font-bold text-lg text-white drop-shadow-md">{{ myDisplayName }}</span>
<span v-if="matchState.wager > 0" class="flex items-center text-xs text-yellow-300 bg-yellow-900/50 px-2 rounded border border-yellow-700/50"> <span v-if="matchState.wager > 0"
class="flex items-center text-xs text-yellow-300 bg-yellow-900/50 px-2 rounded border border-yellow-700/50">
<Coins class="w-3 h-3 mr-1" /> {{ matchState.wager }} <Coins class="w-3 h-3 mr-1" /> {{ matchState.wager }}
</span> </span>
</div> </div>
@@ -229,7 +223,8 @@ onUnmounted(() => {
</div> </div>
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<span class="text-[10px] uppercase tracking-widest text-green-400 font-bold">Match #{{ matchState.id }}</span> <span class="text-[10px] uppercase tracking-widest text-green-400 font-bold">Match #{{ matchState.id }}</span>
<span class="text-2xl font-black font-mono text-white drop-shadow-md">{{ matchState.myMarks }} - {{ matchState.oppMarks }}</span> <span class="text-2xl font-black font-mono text-white drop-shadow-md">{{ matchState.myMarks }} - {{
matchState.oppMarks }}</span>
</div> </div>
<div class="flex flex-col items-end"> <div class="flex flex-col items-end">
<span class="font-bold text-lg text-white drop-shadow-md">{{ opponentDisplayName }}</span> <span class="font-bold text-lg text-white drop-shadow-md">{{ opponentDisplayName }}</span>
@@ -242,119 +237,135 @@ onUnmounted(() => {
</div> </div>
<div v-if="wsStore.gameState && !matchOver" class="absolute top-24 right-4 z-50"> <div v-if="wsStore.gameState && !matchOver" class="absolute top-24 right-4 z-50">
<button @click="openSurrenderModal" class="bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-lg shadow-lg border-2 border-red-800 transition-all hover:scale-105 flex items-center gap-2 text-sm cursor-pointer"> <button @click="openSurrenderModal"
class="bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-lg shadow-lg border-2 border-red-800 transition-all hover:scale-105 flex items-center gap-2 text-sm cursor-pointer">
<span>🏳</span> Surrender <span>🏳</span> Surrender
</button> </button>
</div> </div>
<div v-if="wsStore.gameState && !matchOver && isMyTurn" class="absolute top-24 left-1/2 -translate-x-1/2 z-50"> <div v-if="wsStore.gameState && !matchOver && isMyTurn" class="absolute top-24 left-1/2 -translate-x-1/2 z-50">
<div class="bg-gray-800 px-6 py-2 rounded-lg border-2 shadow-xl transition-colors duration-300" :class="timeLeft <= 5 ? 'border-red-500 bg-red-900/20' : 'border-gray-700'"> <div class="bg-gray-800 px-6 py-2 rounded-lg border-2 shadow-xl transition-colors duration-300"
:class="timeLeft <= 5 ? 'border-red-500 bg-red-900/20' : 'border-gray-700'">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<svg class="w-5 h-5" :class="timeLeft <= 5 ? 'text-red-500 animate-bounce' : 'text-white'" viewBox="0 0 24 24" fill="none" stroke="currentColor"> <svg class="w-5 h-5" :class="timeLeft <= 5 ? 'text-red-500 animate-bounce' : 'text-white'" viewBox="0 0 24 24"
fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="10" stroke-width="2" /> <circle cx="12" cy="12" r="10" stroke-width="2" />
<polyline points="12 6 12 12 16 14" stroke-width="2" /> <polyline points="12 6 12 12 16 14" stroke-width="2" />
</svg> </svg>
<span :class="['text-2xl font-mono font-bold', timeLeft <= 5 ? 'text-red-500' : 'text-white']">{{ timeLeft }}</span> <span :class="['text-2xl font-mono font-bold', timeLeft <= 5 ? 'text-red-500' : 'text-white']">{{ timeLeft
}}</span>
</div> </div>
</div> </div>
</div> </div>
<div class="pt-20 h-screen"> <div class="pt-20 h-screen">
<GameBoard <GameBoard v-if="wsStore.gameState && !matchOver" :game-id="matchState.currentGameId" @play-card="handlePlayCard"
v-if="wsStore.gameState && !matchOver" :trump-card="wsStore.gameState?.trumpCard" :cards-remaining="wsStore.gameState?.deckCount"
:game-id="matchState.currentGameId" :player-hand="wsStore.gameState?.hand" :opponent-hand="opponentHand" :player-score="wsStore.gameState?.points"
@play-card="handlePlayCard" :opponent-score="wsStore.gameState?.opponent?.points" :current-turn="currentTurnLabel"
:trump-card="wsStore.gameState?.trumpCard" :current-trick="wsStore.gameState?.table" :is-my-turn="isMyTurn" :player-name="myDisplayName"
:cards-remaining="wsStore.gameState?.deckCount" :opponent-name="opponentDisplayName" />
:player-hand="wsStore.gameState?.hand"
:opponent-hand="opponentHand"
:player-score="wsStore.gameState?.points"
:opponent-score="wsStore.gameState?.opponent?.points"
:current-turn="currentTurnLabel"
:current-trick="wsStore.gameState?.table"
:is-my-turn="isMyTurn"
:player-name="myDisplayName"
:opponent-name="opponentDisplayName"
/>
<div v-else-if="isPending" class="flex h-full items-center justify-center flex-col gap-6"> <div v-else-if="isPending" class="flex h-full items-center justify-center flex-col gap-6">
<div class="text-center p-8 bg-green-800/60 rounded-2xl border border-green-600 shadow-2xl backdrop-blur-md max-w-sm w-full mx-4"> <div
class="text-center p-8 bg-green-800/60 rounded-2xl border border-green-600 shadow-2xl backdrop-blur-md max-w-sm w-full mx-4">
<div class="relative mx-auto mb-6 h-16 w-16"> <div class="relative mx-auto mb-6 h-16 w-16">
<div class="absolute inset-0 rounded-full border-4 border-green-700/50"></div> <div class="absolute inset-0 rounded-full border-4 border-green-700/50"></div>
<div class="absolute inset-0 rounded-full border-4 border-t-white border-r-transparent border-b-transparent border-l-transparent animate-spin"></div> <div
class="absolute inset-0 rounded-full border-4 border-t-white border-r-transparent border-b-transparent border-l-transparent animate-spin">
</div>
</div> </div>
<h2 class="text-2xl font-bold mb-2 text-white">Waiting for Opponent</h2> <h2 class="text-2xl font-bold mb-2 text-white">Waiting for Opponent</h2>
<button v-if="amIHost" @click="cancelMatch" class="bg-red-600 hover:bg-red-700 text-white py-2 px-4 rounded shadow mt-4">Cancel Match</button> <button v-if="amIHost" @click="cancelMatch"
class="bg-red-600 hover:bg-red-700 text-white py-2 px-4 rounded shadow mt-4">Cancel Match</button>
</div> </div>
</div> </div>
<div v-else-if="!matchOver && !wsStore.gameState" class="flex h-full items-center justify-center flex-col gap-4"> <div v-else-if="!matchOver && !wsStore.gameState" class="flex h-full items-center justify-center flex-col gap-4">
<div class="text-3xl font-bold animate-pulse text-white drop-shadow">Preparing next round...</div> <div class="text-3xl font-bold animate-pulse text-white drop-shadow">Preparing next round...</div>
</div> </div>
<div v-if="matchOver" class="absolute inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md"> <div v-if="matchOver" class="absolute inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md">
<div class="text-center space-y-6 p-8 bg-green-900 border-2 border-green-500 rounded-2xl shadow-2xl max-w-md w-full"> <div
class="text-center space-y-6 p-8 bg-green-900 border-2 border-green-500 rounded-2xl shadow-2xl max-w-md w-full">
<Trophy v-if="iWonMatch" class="w-24 h-24 text-yellow-400 mx-auto animate-bounce" /> <Trophy v-if="iWonMatch" class="w-24 h-24 text-yellow-400 mx-auto animate-bounce" />
<Frown v-else class="w-24 h-24 text-green-300 mx-auto opacity-75" /> <Frown v-else class="w-24 h-24 text-green-300 mx-auto opacity-75" />
<h1 class="text-5xl font-black uppercase mb-2" :class="iWonMatch ? 'text-yellow-400' : 'text-gray-300'">{{ iWonMatch ? 'Victory!' : 'Defeat' }}</h1> <h1 class="text-5xl font-black uppercase mb-2" :class="iWonMatch ? 'text-yellow-400' : 'text-gray-300'">{{
<p class="text-green-100 text-lg">Marks: <span class="font-bold">{{ matchState.myMarks }} - {{ matchState.oppMarks }}</span></p> iWonMatch ? 'Victory!' : 'Defeat' }}</h1>
<Button @click="leaveMatch" class="w-full py-6 text-lg bg-purple-600 hover:bg-purple-700">Return to Lobby</Button> <p class="text-green-100 text-lg">Marks: <span class="font-bold">{{ matchState.myMarks }} - {{
matchState.oppMarks }}</span></p>
<Button @click="leaveMatch" class="w-full py-6 text-lg bg-purple-600 hover:bg-purple-700">Return to
Lobby</Button>
</div> </div>
</div> </div>
</div> </div>
<div v-if="showRoundModal" class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md"> <div v-if="showRoundModal"
<div class="bg-gray-800 p-8 rounded-2xl border-2 border-yellow-500/50 shadow-2xl max-w-md w-full text-center relative overflow-hidden"> class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md">
<div class="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-transparent via-yellow-500 to-transparent"></div> <div
<h2 class="text-3xl font-black text-white mb-2 uppercase">Round Over</h2> class="bg-gray-800 p-8 rounded-2xl border-2 border-yellow-500/50 shadow-2xl max-w-md w-full text-center relative overflow-hidden">
<p class="text-gray-400 mb-6 uppercase text-xs tracking-widest">{{ roundData.reason === 'surrender' ? 'Opponent Surrendered' : 'Points Limit Reached' }}</p> <div class="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-transparent via-yellow-500 to-transparent">
</div>
<h2 class="text-3xl font-black text-white mb-2 uppercase">Round Over</h2>
<p class="text-gray-400 mb-6 uppercase text-xs tracking-widest">
{{ roundData.reason === 'surrender' ? 'Opponent Surrendered' : 'Points Limit Reached' }}</p>
<div class="flex justify-between items-center bg-black/30 p-4 rounded-lg mb-6">
<div class="text-left">
<p class="text-xs text-gray-500 uppercase">You</p>
<p class="text-2xl font-bold text-white">{{ roundData.myPoints }} pts</p>
</div>
<div class="text-xl font-bold text-yellow-500">VS</div>
<div class="text-right">
<p class="text-xs text-gray-500 uppercase">Opponent</p>
<p class="text-2xl font-bold text-white">{{ roundData.oppPoints }} pts</p>
</div>
</div>
<div class="flex justify-between items-center bg-black/30 p-4 rounded-lg mb-6"> <div class="mb-8">
<div class="text-left"> <p class="text-lg text-white" v-if="roundData.winnerId == myUserId">
<p class="text-xs text-gray-500 uppercase">You</p> You won <span class="text-yellow-400 font-bold">+{{ roundData.marksAdded }} marks</span>!
<p class="text-2xl font-bold text-white">{{ roundData.myPoints }} pts</p> </p>
</div> <p class="text-lg text-white" v-else>
<div class="text-xl font-bold text-yellow-500">VS</div> Opponent won <span class="text-red-400 font-bold">+{{ roundData.marksAdded }} marks</span>.
<div class="text-right"> </p>
<p class="text-xs text-gray-500 uppercase">Opponent</p> </div>
<p class="text-2xl font-bold text-white">{{ roundData.oppPoints }} pts</p>
</div>
</div>
<div class="mb-8"> <button @click="closeRoundModal"
<p class="text-lg text-white" v-if="roundData.winnerId == myUserId"> class="w-full py-4 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded-xl shadow-lg transition-transform hover:scale-105">
You won <span class="text-yellow-400 font-bold">+{{ roundData.marksAdded }} marks</span>! Ready for Next Round
</p> </button>
<p class="text-lg text-white" v-else>
Opponent won <span class="text-red-400 font-bold">+{{ roundData.marksAdded }} marks</span>.
</p>
</div>
<button @click="closeRoundModal" class="w-full py-4 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded-xl shadow-lg transition-transform hover:scale-105">
Ready for Next Round
</button>
</div> </div>
</div> </div>
<div v-if="showSurrenderModal" class="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm"> <div v-if="showSurrenderModal"
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div class="bg-gray-800 p-6 rounded-lg border border-gray-600 shadow-2xl max-w-sm w-full text-center"> <div class="bg-gray-800 p-6 rounded-lg border border-gray-600 shadow-2xl max-w-sm w-full text-center">
<h3 class="text-xl font-bold text-white mb-6">Surrender Options</h3> <h3 class="text-xl font-bold text-white mb-6">Surrender Options</h3>
<div class="space-y-3"> <div class="space-y-3">
<button @click="confirmSurrenderRound" class="w-full px-4 py-3 bg-gray-700 hover:bg-gray-600 rounded text-white font-bold border border-gray-500 flex justify-between items-center group"> <button @click="confirmSurrenderRound"
class="w-full px-4 py-3 bg-gray-700 hover:bg-gray-600 rounded text-white font-bold border border-gray-500 flex justify-between items-center group">
<span>Surrender Round</span> <span>Surrender Round</span>
<span class="text-xs bg-black/40 px-2 py-1 rounded text-gray-300 group-hover:bg-red-500 group-hover:text-white">-2 Marks</span> <span
class="text-xs bg-black/40 px-2 py-1 rounded text-gray-300 group-hover:bg-red-500 group-hover:text-white">-2
Marks</span>
</button> </button>
<button @click="confirmSurrenderMatch" class="w-full px-4 py-3 bg-red-900/50 hover:bg-red-600 rounded text-red-200 hover:text-white font-bold border border-red-800 flex justify-between items-center group"> <button @click="confirmSurrenderMatch"
class="w-full px-4 py-3 bg-red-900/50 hover:bg-red-600 rounded text-red-200 hover:text-white font-bold border border-red-800 flex justify-between items-center group">
<span>Surrender Match</span> <span>Surrender Match</span>
<span class="text-xs bg-black/40 px-2 py-1 rounded text-red-300 group-hover:bg-red-800 group-hover:text-white">Defeat</span> <span
class="text-xs bg-black/40 px-2 py-1 rounded text-red-300 group-hover:bg-red-800 group-hover:text-white">Defeat</span>
</button> </button>
</div> </div>
<button @click="showSurrenderModal = false" class="mt-6 text-gray-400 hover:text-white text-sm underline">Cancel</button> <button @click="showSurrenderModal = false"
class="mt-6 text-gray-400 hover:text-white text-sm underline">Cancel</button>
</div> </div>
</div> </div>
<Transition enter-active-class="transition-all duration-300 ease-out" enter-from-class="opacity-0 scale-50" enter-to-class="opacity-100 scale-100" leave-active-class="transition-all duration-200 ease-in" leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-75"> <Transition enter-active-class="transition-all duration-300 ease-out" enter-from-class="opacity-0 scale-50"
enter-to-class="opacity-100 scale-100" leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-75">
<div v-if="showTrickResult" class="fixed top-1/3 left-1/2 -translate-x-1/2 z-[70] pointer-events-none"> <div v-if="showTrickResult" class="fixed top-1/3 left-1/2 -translate-x-1/2 z-[70] pointer-events-none">
<div class="bg-black/70 backdrop-blur-md px-8 py-4 rounded-2xl border border-white/10 shadow-2xl text-center"> <div class="bg-black/70 backdrop-blur-md px-8 py-4 rounded-2xl border border-white/10 shadow-2xl text-center">
<p class="text-sm uppercase tracking-widest text-gray-300 font-bold mb-1">Trick Winner</p> <p class="text-sm uppercase tracking-widest text-gray-300 font-bold mb-1">Trick Winner</p>
<h2 class="text-3xl font-black" :class="lastTrickWinner === myUserId ? 'text-green-400' : 'text-red-400'">{{ lastTrickWinner === myUserId ? 'YOU' : 'OPPONENT' }}</h2> <h2 class="text-3xl font-black" :class="lastTrickWinner === myUserId ? 'text-green-400' : 'text-red-400'">{{
lastTrickWinner === myUserId ? 'YOU' : 'OPPONENT' }}</h2>
</div> </div>
</div> </div>
</Transition> </Transition>
@@ -17,8 +17,11 @@
:opponent-hand="store.opponentHand" :opponent-hand="store.opponentHand"
:player-score="store.playerScore" :player-score="store.playerScore"
:opponent-score="store.opponentScore" :opponent-score="store.opponentScore"
:current-turn="store.currentTurn" :current-turn="'player'"
:is-my-turn="true"
:current-trick="store.table" :current-trick="store.table"
:player-name="'You'"
:opponent-name="'CPU'"
@play-card="handlePlayCard" @play-card="handlePlayCard"
/> />
</div> </div>
@@ -41,9 +44,9 @@
</template> </template>
<script setup> <script setup>
import { onMounted, onUnmounted, onBeforeMount } from 'vue' import { onMounted, onUnmounted, onBeforeMount, watch } from 'vue'
import { useRouter, onBeforeRouteLeave } from 'vue-router' import { useRouter, onBeforeRouteLeave } from 'vue-router'
import { toast } from 'vue-sonner' // Import Toast for feedback import { toast } from 'vue-sonner'
import { useBiscaStore } from '@/stores/bisca' import { useBiscaStore } from '@/stores/bisca'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import GameBoard from '@/components/game/GameBoard.vue' import GameBoard from '@/components/game/GameBoard.vue'
@@ -60,6 +63,14 @@ const store = useBiscaStore()
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
watch(
() => store.playerHand,
(newHand) => {
console.log('Player hand updated:', newHand)
},
{ deep: true },
)
onBeforeMount(() => { onBeforeMount(() => {
store.isGameOver = false store.isGameOver = false
}) })
@@ -67,6 +78,14 @@ onBeforeMount(() => {
onMounted(() => { onMounted(() => {
store.startGame(props.gameType) store.startGame(props.gameType)
window.addEventListener('beforeunload', handleBrowserUnload) window.addEventListener('beforeunload', handleBrowserUnload)
setTimeout(() => {
console.log('Game state after init:', {
playerHand: store.playerHand,
currentTurn: store.currentTurn,
isGameRunning: store.isGameRunning,
})
}, 500)
}) })
onUnmounted(() => { onUnmounted(() => {
+136 -17
View File
@@ -4,7 +4,6 @@ import { ref, onMounted, nextTick, inject, computed } from 'vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { watch } from 'vue'; import { watch } from 'vue';
import { import {
Card, Card,
@@ -35,9 +34,6 @@ const mObserver = ref(null);
const mLoading = ref(false); const mLoading = ref(false);
const mPage = ref(1); const mPage = ref(1);
const showHostModal = ref(false) const showHostModal = ref(false)
const showJoinModal = ref(false)
const selectedGame = ref(null)
const isReady = ref(false)
const showLeaderboardModal = ref(false) const showLeaderboardModal = ref(false)
const showStatsModal = ref(false) const showStatsModal = ref(false)
const userStats = ref(null) const userStats = ref(null)
@@ -211,9 +207,7 @@ const startSingle = () => {
router.push({ name: 'bisca' + selectedMode.value }); router.push({ name: 'bisca' + selectedMode.value });
} }
// --- FIX: Correct Wager Logic ---
const joinMatch = async (match) => { const joinMatch = async (match) => {
// Use 'stake' because that's what the API returns
const cost = match.stake || match.wager || 0; const cost = match.stake || match.wager || 0;
if (cost > userCoins.value) { if (cost > userCoins.value) {
@@ -365,7 +359,8 @@ onMounted(async () => {
</CardHeader> </CardHeader>
<CardContent class="space-y-6"> <CardContent class="space-y-6">
<div class="flex justify-center"> <div class="flex justify-center">
<Button @click="startSingle()" size="lg" variant="secondary" class="hover:bg-purple-500 hover:text-slate-200"> <Button @click="startSingle()" size="lg" variant="secondary"
class="hover:bg-purple-500 hover:text-slate-200">
Start Game Start Game
</Button> </Button>
</div> </div>
@@ -381,8 +376,10 @@ onMounted(async () => {
<div class="flex flex-col flex-1"> <div class="flex flex-col flex-1">
<div class="flex justify-between items-end mb-2"> <div class="flex justify-between items-end mb-2">
<label class="text-sm font-bold uppercase tracking-wider text-muted-foreground">Open Matches (Best of 3)</label> <label class="text-sm font-bold uppercase tracking-wider text-muted-foreground">Open Matches (Best of
<Button v-if="isLoggedIn" @click="openHostModal(true)" size="sm" variant="outline" class="border-purple-500 text-purple-500 hover:bg-purple-500 hover:text-white font-bold"> 3)</label>
<Button v-if="isLoggedIn" @click="openHostModal(true)" size="sm" variant="outline"
class="border-purple-500 text-purple-500 hover:bg-purple-500 hover:text-white font-bold">
Host Host
</Button> </Button>
</div> </div>
@@ -394,17 +391,20 @@ onMounted(async () => {
<div v-for="(match, index) in openMatches" :key="match.id" @click="joinMatch(match)" <div v-for="(match, index) in openMatches" :key="match.id" @click="joinMatch(match)"
class="flex items-center justify-between p-3 mb-2 border border-transparent rounded-lg cursor-pointer transition-all hover:bg-muted/50 hover:border-purple-500"> class="flex items-center justify-between p-3 mb-2 border border-transparent rounded-lg cursor-pointer transition-all hover:bg-muted/50 hover:border-purple-500">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold bg-red-100 text-red-700"> <div
class="flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold bg-red-100 text-red-700">
{{ index + 1 }} {{ index + 1 }}
</div> </div>
<div> <div>
<div class="font-medium text-sm flex items-center gap-2"> <div class="font-medium text-sm flex items-center gap-2">
Match #{{ match.id }} Match #{{ match.id }}
<span class="flex items-center text-xs bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full"> <span
class="flex items-center text-xs bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full">
<Coins class="w-3 h-3 mr-1" /> {{ match.stake || match.wager }} <Coins class="w-3 h-3 mr-1" /> {{ match.stake || match.wager }}
</span> </span>
</div> </div>
<div class="text-[10px] text-muted-foreground">Host: {{ match.player1?.nickname || 'Unknown' }}</div> <div class="text-[10px] text-muted-foreground">Host: {{ match.player1?.nickname || 'Unknown' }}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -417,8 +417,10 @@ onMounted(async () => {
<div class="flex flex-col flex-1"> <div class="flex flex-col flex-1">
<div class="flex justify-between items-end mb-2"> <div class="flex justify-between items-end mb-2">
<label class="text-sm font-medium uppercase tracking-wider text-muted-foreground">Open Games (Single)</label> <label class="text-sm font-medium uppercase tracking-wider text-muted-foreground">Open Games
<Button v-if="isLoggedIn" @click="openHostModal(false)" size="sm" variant="outline" class="border-purple-500 text-purple-500 hover:bg-purple-500 hover:text-white font-bold"> (Single)</label>
<Button v-if="isLoggedIn" @click="openHostModal(false)" size="sm" variant="outline"
class="border-purple-500 text-purple-500 hover:bg-purple-500 hover:text-white font-bold">
Host Host
</Button> </Button>
</div> </div>
@@ -430,7 +432,8 @@ onMounted(async () => {
<div v-for="(game, index) in openGames" :key="game.id" @click="joinGame(game)" <div v-for="(game, index) in openGames" :key="game.id" @click="joinGame(game)"
class="flex items-center justify-between p-3 mb-2 border border-transparent rounded-lg cursor-pointer transition-all hover:bg-muted/50 hover:border-purple-500"> class="flex items-center justify-between p-3 mb-2 border border-transparent rounded-lg cursor-pointer transition-all hover:bg-muted/50 hover:border-purple-500">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold bg-green-100 text-green-700"> <div
class="flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold bg-green-100 text-green-700">
{{ index + 1 }} {{ index + 1 }}
</div> </div>
<div> <div>
@@ -450,9 +453,113 @@ onMounted(async () => {
</div> </div>
</div> </div>
<div v-if="showStatsModal"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<Card class="w-full max-w-sm max-h-2/3 border-purple-500 shadow-2xl">
<CardHeader class="border-b bg-muted/20">
<div class="flex justify-between items-center">
<CardTitle class="flex items-center gap-2">
<User class="text-yellow-500" /> My Statistics
</CardTitle>
<Button variant="ghost" size="sm" @click="showStatsModal = false">X</Button>
</div>
</CardHeader>
<CardContent class="py-6">
<div v-if="statsLoading" class="flex flex-col items-center py-10 gap-4">
<div class="h-8 w-8 animate-spin rounded-full border-4 border-purple-500 border-t-transparent">
</div>
<p class="text-sm text-muted-foreground">Calculating your glory...</p>
</div>
<div v-else-if="userStats" class="space-y-6">
<div class="text-center">
<h3 class="text-2xl font-bold text-purple-600">{{ userStats.nickname }}</h3>
<p class="text-sm text-muted-foreground">Performance Overview</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div
class="rounded-lg bg-muted/50 p-4 text-center border border-transparent hover:border-purple-500/30 transition-all">
<p class="text-xs uppercase text-muted-foreground font-semibold">Total Games</p>
<p class="text-2xl font-bold">{{ userStats.total_games }}</p>
</div>
<div class="rounded-lg bg-purple-500/10 p-4 text-center border border-purple-500/20">
<p class="text-xs uppercase text-purple-600 font-semibold">Win Rate</p>
<p class="text-2xl font-bold text-purple-600">{{ userStats.win_rate }}</p>
</div>
<div class="rounded-lg bg-green-500/10 p-4 text-center border border-green-500/20">
<p class="text-xs uppercase text-green-600 font-semibold">Wins</p>
<p class="text-2xl font-bold text-green-600">{{ userStats.total_wins }}</p>
</div>
<div class="rounded-lg bg-red-500/10 p-4 text-center border border-red-500/20">
<p class="text-xs uppercase text-red-600 font-semibold">Losses</p>
<p class="text-2xl font-bold text-red-600">{{ userStats.total_losses }}</p>
</div>
</div>
<div class="space-y-2">
<div class="flex justify-between text-xs font-medium">
<span>Win/Loss Ratio</span>
<span>{{ userStats.total_wins }}W - {{ userStats.total_losses }}L</span>
</div>
<div class="h-2 w-full bg-red-500/20 rounded-full overflow-hidden flex">
<div class="bg-green-500 h-full" :style="{ width: userStats.win_rate }"></div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<div v-if="showLeaderboardModal"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<Card class="w-full max-w-lg max-h-1/2 h-[80vh] flex flex-col border-purple-500 shadow-2xl">
<CardHeader class="border-b">
<div class="flex justify-between items-center">
<CardTitle class="flex items-center gap-2">
<Trophy class="text-purple-500" /> Leaderboard
</CardTitle>
<Button variant="ghost" size="sm" @click="showLeaderboardModal = false">X</Button>
</div>
</CardHeader>
<CardContent class="flex-1 overflow-y-auto custom-scrollbar p-0">
<div v-for="(user, index) in leaderboardEntries" :key="user.id"
class="flex items-center justify-between p-4 border-b last:border-0 hover:bg-muted/30 transition-colors">
<div class="flex items-center gap-4">
<span class="font-bold text-muted-foreground w-6 text-center"
:class="{ 'text-yellow-500': index === 0, 'text-slate-400': index === 1, 'text-orange-400': index === 2 }">
#{{ index + 1 }}
</span>
<img :src="getAvatarUrl(user.photo_avatar_filename)"
class="h-10 w-10 rounded-full border border-purple-200 object-cover" alt="Avatar" />
<div>
<p class="font-semibold text-sm">{{ user.nickname }}</p>
<p class="text-[10px] text-muted-foreground uppercase tracking-wider">Player</p>
</div>
</div>
<div class="text-right">
<p class="text-lg font-bold text-purple-600">{{ user.won_games_count }}</p>
<p class="text-[10px] text-muted-foreground uppercase">Wins</p>
</div>
</div>
<div ref="lbObserverTarget" class="h-20 flex items-center justify-center">
<span v-if="lbLoading" class="text-purple-500 animate-bounce">Loading rankings...</span>
</div>
</CardContent>
</Card>
</div>
<div v-if="showHostModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"> <div v-if="showHostModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<Card class="w-full max-w-sm border-purple-500 shadow-2xl"> <Card class="w-full max-w-sm border-purple-500 shadow-2xl">
<CardHeader><CardTitle class="text-center">{{ isBestOfThree ? 'Host Match (Best of 3)' : 'Host Game' }}</CardTitle></CardHeader> <CardHeader>
<CardTitle class="text-center">{{ isBestOfThree ? 'Host Match (Best of 3)' : 'Host Game' }}</CardTitle>
</CardHeader>
<CardContent class="flex flex-col gap-6 py-4"> <CardContent class="flex flex-col gap-6 py-4">
<div v-if="isBestOfThree" class="space-y-2"> <div v-if="isBestOfThree" class="space-y-2">
<Label>Wager (Coins)</Label> <Label>Wager (Coins)</Label>
@@ -463,7 +570,8 @@ onMounted(async () => {
<p class="text-xs text-muted-foreground text-right">Balance: {{ userCoins }}</p> <p class="text-xs text-muted-foreground text-right">Balance: {{ userCoins }}</p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<Button variant="outline" class="flex-1" @click="showHostModal = false" :disabled="hostLoading">Cancel</Button> <Button variant="outline" class="flex-1" @click="showHostModal = false"
:disabled="hostLoading">Cancel</Button>
<Button class="flex-1 bg-purple-600 hover:bg-purple-700" @click="hostGame" :disabled="hostLoading"> <Button class="flex-1 bg-purple-600 hover:bg-purple-700" @click="hostGame" :disabled="hostLoading">
{{ hostLoading ? 'Creating...' : 'Create' }} {{ hostLoading ? 'Creating...' : 'Create' }}
</Button> </Button>
@@ -472,5 +580,16 @@ onMounted(async () => {
</Card> </Card>
</div> </div>
<div class="fixed right-4 top-1/2 -translate-y-1/2 flex flex-col gap-4 z-40">
<Button @click="openLeaderboard" variant="outline" size="icon"
class="h-12 w-12 rounded-full shadow-lg border-purple-500 bg-card hover:bg-purple-500 hover:text-white transition-all">
<Trophy class="h-6 w-6" />
</Button>
<Button v-if="useAuthStore().isLoggedIn" @click="openStats" variant="outline" size="icon"
class="h-12 w-12 rounded-full shadow-lg border-purple-500 bg-card hover:bg-purple-500 hover:text-white transition-all">
<User class="h-6 w-6" />
</Button>
</div> </div>
</div>
</template> </template>
+1 -1
View File
@@ -101,7 +101,7 @@ const handleFileChange = (event) => {
} }
const validateForm = () => { const validateForm = () => {
errors.value = {} // Reset errors errors.value = {}
let isValid = true let isValid = true
if (!formData.value.email.match(/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/)) { if (!formData.value.email.match(/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/)) {
File diff suppressed because it is too large Load Diff
-2
View File
@@ -76,7 +76,6 @@ export const useAPIStore = defineStore('api', () => {
return axios.get(`${API_BASE_URL}/games?${queryParams}`) return axios.get(`${API_BASE_URL}/games?${queryParams}`)
} }
// --- ADICIONADO: Função que faltava ---
const getCurrentUserGames = (params = {}) => { const getCurrentUserGames = (params = {}) => {
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
page: params.page || 1, page: params.page || 1,
@@ -91,7 +90,6 @@ export const useAPIStore = defineStore('api', () => {
return axios.get(`${API_BASE_URL}/users/${authStore.currentUserID}/games?${queryParams}`) return axios.get(`${API_BASE_URL}/users/${authStore.currentUserID}/games?${queryParams}`)
} }
// --------------------------------------
const getCurrentUserMatches = (params = {}) => { const getCurrentUserMatches = (params = {}) => {
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
+7 -7
View File
@@ -3,13 +3,14 @@ import { ref, computed } from 'vue'
import { useAPIStore } from './api' import { useAPIStore } from './api'
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const DEFAULT_TIMEOUT = 30 * 60 * 1000 const DEFAULT_TIMEOUT = 30 * 60 * 1000
const REMEMBER_ME_TIMEOUT = 365 * 24 * 60 * 60 * 1000 const REMEMBER_ME_TIMEOUT = 365 * 24 * 60 * 60 * 1000
const currentUser = ref(undefined) const currentUser = ref(undefined)
const token = ref(localStorage.getItem('token') || null) const token = ref(localStorage.getItem('token') || null)
const tokenExpiry = ref(localStorage.getItem('tokenExpiry') ? parseInt(localStorage.getItem('tokenExpiry')) : null) const tokenExpiry = ref(
localStorage.getItem('tokenExpiry') ? parseInt(localStorage.getItem('tokenExpiry')) : null,
)
const rememberMe = ref(false) const rememberMe = ref(false)
const isLoggedIn = computed(() => { const isLoggedIn = computed(() => {
@@ -25,9 +26,9 @@ export const useAuthStore = defineStore('auth', () => {
}) })
const isTokenAlmostExpired = () => { const isTokenAlmostExpired = () => {
if (!tokenExpiry.value) return false; if (!tokenExpiry.value) return false
return Date.now() > (tokenExpiry.value - 60 * 1000); return Date.now() > tokenExpiry.value - 60 * 1000
}; }
const resetTokenExpiry = () => { const resetTokenExpiry = () => {
const timeout = rememberMe.value ? REMEMBER_ME_TIMEOUT : DEFAULT_TIMEOUT const timeout = rememberMe.value ? REMEMBER_ME_TIMEOUT : DEFAULT_TIMEOUT
@@ -106,7 +107,6 @@ export const useAuthStore = defineStore('auth', () => {
currentUser.value = userResp.data currentUser.value = userResp.data
localStorage.setItem('currentUser', JSON.stringify(userResp.data)) localStorage.setItem('currentUser', JSON.stringify(userResp.data))
} catch (apiError) { } catch (apiError) {
// API call failed, likely token is invalid
logout() logout()
return false return false
} }
@@ -117,7 +117,7 @@ export const useAuthStore = defineStore('auth', () => {
} }
const updateCurrentUserCoins = (coins) => { const updateCurrentUserCoins = (coins) => {
if (!currentUser.value) return; if (!currentUser.value) return
currentUser.value.coins_balance = coins currentUser.value.coins_balance = coins
} }
-1
View File
@@ -273,7 +273,6 @@ export const useBiscaStore = defineStore('bisca', () => {
playerScore.value = 0 playerScore.value = 0
winner.value = 'opponent' winner.value = 'opponent'
// Parar o jogo imediatamente
isGameRunning.value = false isGameRunning.value = false
isGameOver.value = true isGameOver.value = true
} }
+1 -1
View File
@@ -122,7 +122,7 @@ export const useSocketStore = defineStore('websocket', () => {
disconnect, disconnect,
joinGame, joinGame,
playCard, playCard,
surrender, // Exported surrender,
onTrickEnd, onTrickEnd,
onGameOver onGameOver
} }
+5 -6
View File
@@ -48,8 +48,9 @@ class BiscaGame {
this.dealInitialCards(); this.dealInitialCards();
const playerIds = Object.keys(this.players); const playerKeys = Object.keys(this.players);
this.currentTurn = playerIds.find((id) => id != 1) || playerIds[0]; const startKey = playerKeys.find((key) => key != 1) || playerKeys[0];
this.currentTurn = this.players[startKey].id;
this.startTime = Date.now(); this.startTime = Date.now();
this.status = "playing"; this.status = "playing";
@@ -189,8 +190,6 @@ class BiscaGame {
this.players[winnerId].tricks += 1; this.players[winnerId].tricks += 1;
this.currentTurn = winnerId; this.currentTurn = winnerId;
// --- CRITICAL FIX: Always draw if cards remain ---
// Removed "if (this.type == 3)" check
if (this.deck.length > 0 || this.trumpCard) { if (this.deck.length > 0 || this.trumpCard) {
this.drawCard(winnerId); this.drawCard(winnerId);
this.drawCard(this.getOpponentId(winnerId)); this.drawCard(this.getOpponentId(winnerId));
@@ -220,8 +219,8 @@ class BiscaGame {
const finalLoser = isDraw const finalLoser = isDraw
? null ? null
: gameWinnerId == p1Obj.id : gameWinnerId == p1Obj.id
? p2Obj.id ? p2Obj.id
: p1Obj.id; : p1Obj.id;
const result = { const result = {
action: "game_ended", action: "game_ended",
-5
View File
@@ -4,7 +4,6 @@ import axios from "axios";
const API_URL = "http://localhost:8000/api/v1"; const API_URL = "http://localhost:8000/api/v1";
class BiscaMatch { class BiscaMatch {
// FIX: Added autoPlayCallback parameter
constructor(apiData, emitStateCallback, token, autoPlayCallback) { constructor(apiData, emitStateCallback, token, autoPlayCallback) {
this.id = apiData.id; this.id = apiData.id;
this.emitStateCallback = emitStateCallback; this.emitStateCallback = emitStateCallback;
@@ -92,7 +91,6 @@ class BiscaMatch {
this.currentGame.init(); this.currentGame.init();
// FIX: Start Timer & Connect Callback
this.currentGame.startTurnTimer((uid, cid) => { this.currentGame.startTurnTimer((uid, cid) => {
if (this.autoPlayCallback) this.autoPlayCallback(uid, cid); if (this.autoPlayCallback) this.autoPlayCallback(uid, cid);
}); });
@@ -105,12 +103,10 @@ class BiscaMatch {
} }
} }
// --- FIX: Close DB Game row on surrender ---
async abortCurrentGame(loserId) { async abortCurrentGame(loserId) {
if (this.currentGame && this.currentGame.dbId) { if (this.currentGame && this.currentGame.dbId) {
console.log(`[Match ${this.id}] Aborting Game #${this.currentGame.dbId}`); console.log(`[Match ${this.id}] Aborting Game #${this.currentGame.dbId}`);
try { try {
// Determine winner for the DB record based on who DIDN'T lose
const winnerId = loserId == this.player1Id ? this.player2Id : this.player1Id; const winnerId = loserId == this.player1Id ? this.player2Id : this.player1Id;
const payload = { const payload = {
@@ -208,7 +204,6 @@ class BiscaMatch {
const result = 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')) { if (result && (result.action === 'next_turn' || result.action === 'trick_resolved')) {
this.currentGame.startTurnTimer((uid, cid) => { this.currentGame.startTurnTimer((uid, cid) => {
if (this.autoPlayCallback) this.autoPlayCallback(uid, cid); if (this.autoPlayCallback) this.autoPlayCallback(uid, cid);
+2 -2
View File
@@ -1,7 +1,7 @@
import { addUser, removeUser } from "../state/connection.js"; import { addUser, removeUser } from "../state/connection.js";
const HEARTBEAT_INTERVAL = 30000; // 30 seconds const HEARTBEAT_INTERVAL = 30000;
const HEARTBEAT_TIMEOUT = 10000; // 10 seconds const HEARTBEAT_TIMEOUT = 10000;
export const handleConnectionEvents = (io, socket) => { export const handleConnectionEvents = (io, socket) => {
let heartbeatInterval; let heartbeatInterval;
-5
View File
@@ -4,13 +4,11 @@ import { getUser } from "../state/connection.js";
const API_URL = "http://localhost:8000/api/v1"; const API_URL = "http://localhost:8000/api/v1";
// --- HELPER: Card Strength for Auto-Play ---
const getStrength = (rank) => { const getStrength = (rank) => {
const strengthMap = { 1: 10, 7: 9, 13: 8, 11: 7, 12: 6, 6: 5, 5: 4, 4: 3, 3: 2, 2: 1 }; const strengthMap = { 1: 10, 7: 9, 13: 8, 11: 7, 12: 6, 6: 5, 5: 4, 4: 3, 3: 2, 2: 1 };
return strengthMap[rank] || 0; return strengthMap[rank] || 0;
}; };
// --- HELPER: Draw Card Logic ---
const drawCard = (game, playerId) => { const drawCard = (game, playerId) => {
const player = game.players[playerId]; const player = game.players[playerId];
if (!player) return; if (!player) return;
@@ -24,7 +22,6 @@ const drawCard = (game, playerId) => {
} }
}; };
// --- HELPER: Sanitize Data ---
const sanitizeState = (state, game) => { const sanitizeState = (state, game) => {
if (!state) return null; if (!state) return null;
const p1 = game.player1 || state.player1 || {}; const p1 = game.player1 || state.player1 || {};
@@ -90,7 +87,6 @@ async function saveGameResult(gameId, result, token) {
} }
} }
// --- SHARED END GAME LOGIC ---
const performGameEnd = (io, game, gameId, winnerId, loserId, reason) => { const performGameEnd = (io, game, gameId, winnerId, loserId, reason) => {
const result = { const result = {
action: "game_ended", action: "game_ended",
@@ -267,7 +263,6 @@ export default (io, socket) => {
if (user) handleGameMove(io, gameId, user.id, cardId); if (user) handleGameMove(io, gameId, user.id, cardId);
}); });
// --- NEW: SURRENDER HANDLER ---
socket.on("surrender", ({ gameId }) => { socket.on("surrender", ({ gameId }) => {
const user = getUser(socket.id); const user = getUser(socket.id);
const game = getGame(gameId); const game = getGame(gameId);
-4
View File
@@ -34,7 +34,6 @@ export default (io, socket) => {
} }
}; };
// --- FIX: Timer Callback ---
const handleAutoPlay = (matchId) => (userId, cardId) => { const handleAutoPlay = (matchId) => (userId, cardId) => {
const match = getMatch(matchId); const match = getMatch(matchId);
if (!match || match.status !== "Playing") return; if (!match || match.status !== "Playing") return;
@@ -66,7 +65,6 @@ export default (io, socket) => {
}); });
const matchData = res.data.data || res.data; const matchData = res.data.data || res.data;
// Pass handleAutoPlay to constructor
match = new BiscaMatch( match = new BiscaMatch(
matchData, matchData,
createMatchEmitter(matchId), createMatchEmitter(matchId),
@@ -126,7 +124,6 @@ export default (io, socket) => {
if(!match || match.status !== "Playing") return; if(!match || match.status !== "Playing") return;
const userId = socket.user.id; const userId = socket.user.id;
// FIX: Abort current game to remove ghost
await match.abortCurrentGame(userId); await match.abortCurrentGame(userId);
console.log(`[Match ${matchId}] User ${userId} surrendered MATCH.`); console.log(`[Match ${matchId}] User ${userId} surrendered MATCH.`);
@@ -144,7 +141,6 @@ export default (io, socket) => {
if (userId === match.player1Id || userId === match.player2Id) { if (userId === match.player1Id || userId === match.player2Id) {
console.log(`[Match ${match.id}] Player ${userId} disconnected.`); console.log(`[Match ${match.id}] Player ${userId} disconnected.`);
// FIX: Abort current game
await match.abortCurrentGame(userId); await match.abortCurrentGame(userId);
const opponentId = match.player1Id == userId ? match.player2Id : match.player1Id; const opponentId = match.player1Id == userId ? match.player2Id : match.player1Id;
+2 -14
View File
@@ -12,7 +12,7 @@ export const server = {
const API_URL = "http://localhost:8000/api/v1"; const API_URL = "http://localhost:8000/api/v1";
const disconnectTimers = new Map(); const disconnectTimers = new Map();
const RECONNECT_GRACE_PERIOD = 1 * 60 * 1000; // 1 minutes const RECONNECT_GRACE_PERIOD = 1 * 60 * 1000;
const userId = (socket) => socket.user && socket.user.id; const userId = (socket) => socket.user && socket.user.id;
@@ -86,7 +86,6 @@ export const serverStart = (port) => {
}); });
socket.on("disconnect", () => { socket.on("disconnect", () => {
// Remove from active socket list
removeUser(socket.id); removeUser(socket.id);
if (socket.isVoluntaryDisconnect) { if (socket.isVoluntaryDisconnect) {
@@ -94,24 +93,15 @@ export const serverStart = (port) => {
return; return;
} }
if (userId) { if (userId) {
console.log(`[Disconnect] User ${userId} disconnected. Waiting ${RECONNECT_GRACE_PERIOD / 1000}s...`); console.log(`[Disconnect] User ${userId} disconnected. Waiting ${RECONNECT_GRACE_PERIOD / 1000}s...`);
// -----------------------------------------------------------
// 2. START SURRENDER TIMER
// -----------------------------------------------------------
const timer = setTimeout(async () => { const timer = setTimeout(async () => {
console.log(`[Timeout] User ${userId} did not return. Forcing surrender.`); 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; const gameId = socket.activeGameId;
if (gameId) { if (gameId) {
try { 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`, {}, { await axios.post(`${API_URL}/games/${gameId}/resign`, {}, {
headers: { headers: {
Authorization: socket.handshake.auth.token, Authorization: socket.handshake.auth.token,
@@ -119,8 +109,6 @@ export const serverStart = (port) => {
} }
}); });
// 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", { server.io.to(gameId).emit("game_over", {
winner: "opponent", winner: "opponent",
reason: "disconnect" reason: "disconnect"