Compare commits

..
Author SHA1 Message Date
FernandoJVideira 13205c6e80 fix: rm .env files 2026-01-03 17:57:04 +00:00
FernandoJVideira 2cd1d53616 fix: prod ready websockets 2026-01-03 17:55:45 +00:00
FernandoJVideira 5b504d10bd merge 2026-01-03 17:22:09 +00:00
FernandoJVideira 5ea8c0df23 Merge pull request 'fixing game history, leaderboard, stats and removing more comments' (#103) from hot-fix/fixing-games-history into master
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/103
Reviewed-by: KZix <[email protected]>
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
Reviewed-by: FernandoJVideira <[email protected]>
2026-01-03 17:08:27 +00:00
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
44 changed files with 1301 additions and 3849 deletions
+2 -1
View File
@@ -44,7 +44,8 @@ dist
frontend/.env frontend/.env
frontend/.env.local frontend/.env.local
frontend/.env.*.local frontend/.env.*.local
frontend/.env.production
websockets/.env.production
# Misc frontend files that should be ignored # Misc frontend files that should be ignored
/frontend/public/hot /frontend/public/hot
/frontend/public/storage /frontend/public/storage
+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([
@@ -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 () {
@@ -3,7 +3,7 @@ apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: mysql name: mysql
namespace: dad-group-x namespace: dad-group-46
spec: spec:
selector: selector:
matchLabels: matchLabels:
@@ -37,7 +37,7 @@ apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: mysql name: mysql
namespace: dad-group-x namespace: dad-group-46
spec: spec:
ports: ports:
- port: 3306 - port: 3306
+4 -4
View File
@@ -3,10 +3,10 @@ apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
name: app-ingress name: app-ingress
namespace: dad-group-x namespace: dad-group-46
spec: spec:
rules: rules:
- host: web-dad-group-x-172.22.21.253.sslip.io - host: web-dad-group-46-172.22.21.253.sslip.io
http: http:
paths: paths:
- path: / - path: /
@@ -16,7 +16,7 @@ spec:
name: vue-app name: vue-app
port: port:
number: 80 number: 80
- host: api-dad-group-x-172.22.21.253.sslip.io - host: api-dad-group-46-172.22.21.253.sslip.io
http: http:
paths: paths:
- path: / - path: /
@@ -26,7 +26,7 @@ spec:
name: laravel-app name: laravel-app
port: port:
number: 80 number: 80
- host: ws-dad-group-x-172.22.21.253.sslip.io - host: ws-dad-group-46-172.22.21.253.sslip.io
http: http:
paths: paths:
- path: / - path: /
+3 -3
View File
@@ -3,7 +3,7 @@ apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: laravel-app name: laravel-app
namespace: dad-group-x namespace: dad-group-46
spec: spec:
replicas: 1 replicas: 1
selector: selector:
@@ -17,7 +17,7 @@ spec:
priorityClassName: high-priority priorityClassName: high-priority
containers: containers:
- name: api - name: api
image: registry-172.22.21.115.sslip.io/dad-group-x/api:v1.0.0 image: registry-172.22.21.115.sslip.io/dad-group-46/api:v2.0.9
resources: resources:
requests: requests:
memory: "256Mi" memory: "256Mi"
@@ -31,7 +31,7 @@ apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: laravel-app name: laravel-app
namespace: dad-group-x namespace: dad-group-46
spec: spec:
ports: ports:
- port: 80 - port: 80
+3 -3
View File
@@ -3,7 +3,7 @@ apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: vue-app name: vue-app
namespace: dad-group-x namespace: dad-group-46
spec: spec:
replicas: 1 replicas: 1
selector: selector:
@@ -17,7 +17,7 @@ spec:
priorityClassName: low-priority priorityClassName: low-priority
containers: containers:
- name: web - name: web
image: registry-172.22.21.115.sslip.io/dad-group-46/web:v1.0.1 image: registry-172.22.21.115.sslip.io/dad-group-46/web:v2.0.9
resources: resources:
requests: requests:
memory: "64Mi" memory: "64Mi"
@@ -31,7 +31,7 @@ apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: vue-app name: vue-app
namespace: dad-group-x namespace: dad-group-46
spec: spec:
ports: ports:
- port: 80 - port: 80
+3 -3
View File
@@ -3,7 +3,7 @@ apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: websocket-server name: websocket-server
namespace: dad-group-x namespace: dad-group-46
spec: spec:
replicas: 1 replicas: 1
selector: selector:
@@ -17,7 +17,7 @@ spec:
priorityClassName: low-priority priorityClassName: low-priority
containers: containers:
- name: web - name: web
image: registry-172.22.21.115.sslip.io/dad-group-x/ws:v1.0.0 image: registry-172.22.21.115.sslip.io/dad-group-46/ws:v2.0.9
resources: resources:
requests: requests:
memory: "128Mi" memory: "128Mi"
@@ -31,7 +31,7 @@ apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: websocket-server name: websocket-server
namespace: dad-group-x namespace: dad-group-46
spec: spec:
ports: ports:
- port: 3000 - port: 3000
-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,
() => { () => {
-5
View File
@@ -1,19 +1,15 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import { io } from 'socket.io-client'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
const apiDomain = import.meta.env.VITE_API_DOMAIN const apiDomain = import.meta.env.VITE_API_DOMAIN
const wsConnection = import.meta.env.VITE_WS_CONNECTION
console.log('[main.js] api domain', apiDomain) console.log('[main.js] api domain', apiDomain)
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`)
@@ -21,4 +17,3 @@ app.use(createPinia())
app.use(router) app.use(router)
app.mount('#app') app.mount('#app')
+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>
@@ -1,35 +1,52 @@
<template> <template>
<div class="relative min-h-screen overflow-hidden"> <div class="relative min-h-screen overflow-hidden">
<div v-if="store.isGameRunning" class="absolute top-4 right-4 z-50"> <div v-if="store.isGameRunning" 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="store.isGameRunning"> <div v-if="store.isGameRunning">
<GameBoard :trump-card="store.trumpCard" :cards-remaining="store.deck.length + (store.trumpCard ? 1 : 0)" <GameBoard
:player-hand="store.playerHand" :opponent-hand="store.opponentHand" :player-score="store.playerScore" :trump-card="store.trumpCard"
:opponent-score="store.opponentScore" :current-turn="'player'" :is-my-turn="true" :current-trick="store.table" :cards-remaining="store.deck.length + (store.trumpCard ? 1 : 0)"
:player-name="'You'" :opponent-name="'CPU'" @play-card="handlePlayCard" /> :player-hand="store.playerHand"
:opponent-hand="store.opponentHand"
:player-score="store.playerScore"
:opponent-score="store.opponentScore"
:current-turn="'player'"
:is-my-turn="true"
:current-trick="store.table"
:player-name="'You'"
:opponent-name="'CPU'"
@play-card="handlePlayCard"
/>
</div> </div>
<div v-else class="flex h-screen items-center justify-center bg-green-900 text-white"> <div v-else class="flex h-screen items-center justify-center bg-green-900 text-white">
<div class="animate-pulse text-xl">Preparing game...</div> <div class="animate-pulse text-xl">Preparing game...</div>
</div> </div>
<GameOver
<GameOver :is-visible="store.isGameOver" :winner="store.winner || 'draw'" :player-score="store.playerScore" :is-visible="store.isGameOver"
:winner="store.winner || 'draw'"
:player-score="store.playerScore"
:opponent-score="store.opponentScore" :opponent-score="store.opponentScore"
:stats="{ playerTricks: store.playerTricks, opponentTricks: store.opponentTricks }" :stats="{ playerTricks: store.playerTricks, opponentTricks: store.opponentTricks }"
:is-logging-out="store.isLoggingOut" @play-again="handlePlayAgain" @close="handleCloseModal" /> :is-logging-out="store.isLoggingOut"
@play-again="handlePlayAgain"
@close="handleCloseModal"
/>
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, onUnmounted, onBeforeMount, watch } 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'
@@ -46,10 +63,13 @@ const store = useBiscaStore()
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
// DEBUG: Watch the store state watch(
watch(() => store.playerHand, (newHand) => { () => store.playerHand,
console.log('Player hand updated:', newHand) (newHand) => {
}, { deep: true }) console.log('Player hand updated:', newHand)
},
{ deep: true },
)
onBeforeMount(() => { onBeforeMount(() => {
store.isGameOver = false store.isGameOver = false
@@ -59,12 +79,11 @@ onMounted(() => {
store.startGame(props.gameType) store.startGame(props.gameType)
window.addEventListener('beforeunload', handleBrowserUnload) window.addEventListener('beforeunload', handleBrowserUnload)
// DEBUG: Log initial state
setTimeout(() => { setTimeout(() => {
console.log('Game state after init:', { console.log('Game state after init:', {
playerHand: store.playerHand, playerHand: store.playerHand,
currentTurn: store.currentTurn, currentTurn: store.currentTurn,
isGameRunning: store.isGameRunning isGameRunning: store.isGameRunning,
}) })
}, 500) }, 500)
}) })
+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
} }
+8 -6
View File
@@ -12,7 +12,7 @@ export const useSocketStore = defineStore('websocket', () => {
const lastError = ref(null) const lastError = ref(null)
const currentGameId = ref(null) const currentGameId = ref(null)
const WS_URL = 'http://localhost:3000' const WS_URL = import.meta.env.VITE_WS_CONNECTION || 'ws://localhost:3000'
const connect = () => { const connect = () => {
if (socket.value?.connected) return if (socket.value?.connected) return
@@ -27,13 +27,15 @@ export const useSocketStore = defineStore('websocket', () => {
auth: { token: `Bearer ${token}` }, auth: { token: `Bearer ${token}` },
reconnection: true, reconnection: true,
reconnectionAttempts: 5, reconnectionAttempts: 5,
reconnectionDelay: 1000 reconnectionDelay: 1000,
}) })
console.log('[WebSocket] Attempting to connect to', WS_URL)
socket.value.on('connect', () => { socket.value.on('connect', () => {
if (currentGameId.value) { if (currentGameId.value) {
console.log("Reconnected to socket. Rejoining game..."); console.log('Reconnected to socket. Rejoining game...')
socket.emit("join-game", { gameId: currentGameId.value }); socket.emit('join-game', { gameId: currentGameId.value })
} }
connected.value = true connected.value = true
@@ -122,8 +124,8 @@ export const useSocketStore = defineStore('websocket', () => {
disconnect, disconnect,
joinGame, joinGame,
playCard, playCard,
surrender, // Exported surrender,
onTrickEnd, onTrickEnd,
onGameOver onGameOver,
} }
}) })
+2 -2
View File
@@ -1,6 +1,6 @@
GROUP := "dad-group-x" GROUP := "dad-group-46"
VERSION := "1.0.0" VERSION := "2.0.9"
kubectl-pods: kubectl-pods:
-2
View File
@@ -1,2 +0,0 @@
PORT=
API_URL=
+4 -7
View File
@@ -48,11 +48,9 @@ class BiscaGame {
this.dealInitialCards(); this.dealInitialCards();
// --- FIX: Ensure currentTurn uses the original ID type (Number) ---
// Object.keys returns strings ("1"), which breaks strict checks against user.id (1)
const playerKeys = Object.keys(this.players); const playerKeys = Object.keys(this.players);
const startKey = playerKeys.find((key) => key != 1) || playerKeys[0]; const startKey = playerKeys.find((key) => key != 1) || playerKeys[0];
this.currentTurn = this.players[startKey].id; this.currentTurn = this.players[startKey].id;
this.startTime = Date.now(); this.startTime = Date.now();
this.status = "playing"; this.status = "playing";
@@ -192,7 +190,6 @@ class BiscaGame {
this.players[winnerId].tricks += 1; this.players[winnerId].tricks += 1;
this.currentTurn = winnerId; this.currentTurn = winnerId;
// --- FIX: Always draw if deck has cards (Removed type 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));
@@ -222,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",
@@ -237,7 +234,7 @@ class BiscaGame {
player2_points: p2Obj.points, player2_points: p2Obj.points,
totalTime: totalTime, totalTime: totalTime,
}; };
if (this.onGameEnd) { if (this.onGameEnd) {
this.onGameEnd(result); this.onGameEnd(result);
} }
+102 -71
View File
@@ -1,14 +1,13 @@
import BiscaGame from "../classes/BiscaGame.js"; import BiscaGame from "../classes/BiscaGame.js";
import axios from "axios"; import axios from "axios";
const API_URL = "http://localhost:8000/api/v1"; const API_URL = process.env.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;
this.autoPlayCallback = autoPlayCallback; this.autoPlayCallback = autoPlayCallback;
this.token = token; this.token = token;
this.stake = apiData.stake; this.stake = apiData.stake;
this.type = apiData.type; this.type = apiData.type;
@@ -53,10 +52,10 @@ class BiscaMatch {
getAuthHeaders() { getAuthHeaders() {
return { return {
headers: { headers: {
Authorization: this.token, Authorization: this.token,
"Content-Type": "application/json", "Content-Type": "application/json",
"Accept": "application/json" Accept: "application/json",
}, },
}; };
} }
@@ -73,8 +72,12 @@ class BiscaMatch {
status: "Playing", status: "Playing",
began_at: new Date().toISOString(), began_at: new Date().toISOString(),
}; };
const res = await axios.post(`${API_URL}/games`, payload, this.getAuthHeaders()); const res = await axios.post(
`${API_URL}/games`,
payload,
this.getAuthHeaders()
);
const gameData = res.data.game || res.data.data || res.data; const gameData = res.data.game || res.data.data || res.data;
newGameDbId = gameData.id; newGameDbId = gameData.id;
@@ -87,103 +90,124 @@ class BiscaMatch {
{ id: this.player1Id, name: p1Name }, { id: this.player1Id, name: p1Name },
{ id: this.player2Id, name: p2Name }, { id: this.player2Id, name: p2Name },
newGameDbId, newGameDbId,
(result) => { this.handleGameEnd(result); } (result) => {
this.handleGameEnd(result);
}
); );
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);
}); });
if (this.emitStateCallback) { if (this.emitStateCallback) {
this.emitStateCallback("match:new-round-start", null); this.emitStateCallback("match:new-round-start", null);
} }
} catch (e) { } catch (e) {
console.error(`❌ Start Game Error: ${e.message}`); console.error(`❌ Start Game Error: ${e.message}`);
} }
} }
// --- 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 =
const winnerId = loserId == this.player1Id ? this.player2Id : this.player1Id; loserId == this.player1Id ? this.player2Id : this.player1Id;
const payload = { const payload = {
status: "Ended", status: "Ended",
winner_user_id: winnerId, winner_user_id: winnerId,
loser_user_id: loserId, loser_user_id: loserId,
is_draw: false, is_draw: false,
ended_at: new Date().toISOString(), ended_at: new Date().toISOString(),
}; };
await axios.put(`${API_URL}/games/${this.currentGame.dbId}`, payload, this.getAuthHeaders()); await axios.put(
} catch(e) { console.error("Error aborting game:", e.message); } `${API_URL}/games/${this.currentGame.dbId}`,
payload,
this.getAuthHeaders()
);
} catch (e) {
console.error("Error aborting game:", e.message);
} }
}
} }
async handleGameEnd(result) { async handleGameEnd(result) {
console.log(`[Match ${this.id}] Round ended. Winner: ${result.winnerId}`); console.log(`[Match ${this.id}] Round ended. Winner: ${result.winnerId}`);
if (this.points[result.player1_id] !== undefined) this.points[result.player1_id] += result.player1_points; if (this.points[result.player1_id] !== undefined)
if (this.points[result.player2_id] !== undefined) this.points[result.player2_id] += result.player2_points; this.points[result.player1_id] += result.player1_points;
if (this.points[result.player2_id] !== undefined)
this.points[result.player2_id] += result.player2_points;
if (this.currentGame && this.currentGame.dbId) { if (this.currentGame && this.currentGame.dbId) {
try { try {
const payload = { const payload = {
status: "Ended", status: "Ended",
winner_user_id: result.winnerId, winner_user_id: result.winnerId,
loser_user_id: result.loserId, loser_user_id: result.loserId,
is_draw: result.isDraw, is_draw: result.isDraw,
player1_points: result.player1_points, player1_points: result.player1_points,
player2_points: result.player2_points, player2_points: result.player2_points,
ended_at: new Date().toISOString(), ended_at: new Date().toISOString(),
}; };
await axios.put(`${API_URL}/games/${this.currentGame.dbId}`, payload, this.getAuthHeaders()); await axios.put(
} catch (e) { console.error("DB Game Update Error:", e.message); } `${API_URL}/games/${this.currentGame.dbId}`,
payload,
this.getAuthHeaders()
);
} catch (e) {
console.error("DB Game Update Error:", e.message);
}
} }
let marksToAdd = 0; let marksToAdd = 0;
let roundWinnerId = result.winnerId; let roundWinnerId = result.winnerId;
if (roundWinnerId) { if (roundWinnerId) {
const winningScore = roundWinnerId == result.player1_id ? result.player1_points : result.player2_points; const winningScore =
roundWinnerId == result.player1_id
? result.player1_points
: result.player2_points;
if (winningScore === 120) marksToAdd = 4; if (winningScore === 120) marksToAdd = 4;
else if (winningScore > 90) marksToAdd = 2; else if (winningScore > 90) marksToAdd = 2;
else if (winningScore >= 60) marksToAdd = 1; else if (winningScore >= 60) marksToAdd = 1;
if (this.marks[roundWinnerId] !== undefined) this.marks[roundWinnerId] += marksToAdd; if (this.marks[roundWinnerId] !== undefined)
this.marks[roundWinnerId] += marksToAdd;
} }
if (this.emitStateCallback) { if (this.emitStateCallback) {
this.emitStateCallback("match:round-ended", { this.emitStateCallback("match:round-ended", {
winnerId: roundWinnerId, winnerId: roundWinnerId,
marksAdded: marksToAdd, marksAdded: marksToAdd,
p1Points: result.player1_points, p1Points: result.player1_points,
p2Points: result.player2_points, p2Points: result.player2_points,
p1Marks: this.marks[this.player1Id], p1Marks: this.marks[this.player1Id],
p2Marks: this.marks[this.player2Id], p2Marks: this.marks[this.player2Id],
reason: result.reason || 'points' reason: result.reason || "points",
}); });
} }
if (this.marks[this.player1Id] >= 4 || this.marks[this.player2Id] >= 4) { if (this.marks[this.player1Id] >= 4 || this.marks[this.player2Id] >= 4) {
await this.finishMatch(); await this.finishMatch();
if (this.emitStateCallback) this.emitStateCallback("match:ended-signal", null); if (this.emitStateCallback)
this.emitStateCallback("match:ended-signal", null);
} else { } else {
setTimeout(() => { setTimeout(() => {
this.startNewGame(); this.startNewGame();
}, 1000); }, 1000);
} }
} }
async finishMatch() { async finishMatch() {
this.status = "Ended"; this.status = "Ended";
const winnerId = this.marks[this.player1Id] >= 4 ? this.player1Id : this.player2Id; const winnerId =
const loserId = winnerId == this.player1Id ? this.player2Id : this.player1Id; this.marks[this.player1Id] >= 4 ? this.player1Id : this.player2Id;
const loserId =
winnerId == this.player1Id ? this.player2Id : this.player1Id;
const payload = { const payload = {
status: "Ended", status: "Ended",
@@ -196,7 +220,11 @@ class BiscaMatch {
}; };
try { try {
await axios.put(`${API_URL}/matches/${this.id}`, payload, this.getAuthHeaders()); await axios.put(
`${API_URL}/matches/${this.id}`,
payload,
this.getAuthHeaders()
);
console.log(`[Match ${this.id}] Match Closed in DB.`); console.log(`[Match ${this.id}] Match Closed in DB.`);
} catch (e) { } catch (e) {
console.error(`Match Close Error: ${e.message}`); console.error(`Match Close Error: ${e.message}`);
@@ -205,22 +233,25 @@ class BiscaMatch {
playCard(userId, cardId) { playCard(userId, cardId) {
if (!this.currentGame || this.status !== "Playing") return; if (!this.currentGame || this.status !== "Playing") return;
const result = this.currentGame.playCard(userId, cardId); const result = this.currentGame.playCard(userId, cardId);
// FIX: Restart timer if game continues if (
if (result && (result.action === 'next_turn' || result.action === 'trick_resolved')) { result &&
this.currentGame.startTurnTimer((uid, cid) => { (result.action === "next_turn" || result.action === "trick_resolved")
if (this.autoPlayCallback) this.autoPlayCallback(uid, cid); ) {
}); this.currentGame.startTurnTimer((uid, cid) => {
if (this.autoPlayCallback) this.autoPlayCallback(uid, cid);
});
} }
return result; return result;
} }
getGameState(userId) { getGameState(userId) {
if (!this.currentGame) return null; if (!this.currentGame) return null;
if (this.currentGame.players[userId]) return this.currentGame.getStateForPlayer(userId); if (this.currentGame.players[userId])
return this.currentGame.getStateForPlayer(userId);
return null; return null;
} }
@@ -231,7 +262,7 @@ class BiscaMatch {
marks: this.marks, marks: this.marks,
points: this.points, points: this.points,
player1: this.player1, player1: this.player1,
player2: this.player2 player2: this.player2,
}; };
} }
} }
+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;
+174 -154
View File
@@ -2,72 +2,80 @@ import axios from "axios";
import { getGame, createGame } from "../state/game.js"; import { getGame, createGame } from "../state/game.js";
import { getUser } from "../state/connection.js"; import { getUser } from "../state/connection.js";
const API_URL = "http://localhost:8000/api/v1"; const API_URL = process.env.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 = {
return strengthMap[rank] || 0; 1: 10,
7: 9,
13: 8,
11: 7,
12: 6,
6: 5,
5: 4,
4: 3,
3: 2,
2: 1,
};
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;
if (game.deck && game.deck.length > 0) { if (game.deck && game.deck.length > 0) {
const card = game.deck.pop(); const card = game.deck.pop();
player.hand.push(card); player.hand.push(card);
} else if (game.trumpCard) { } else if (game.trumpCard) {
player.hand.push(game.trumpCard); player.hand.push(game.trumpCard);
game.trumpCard = null; game.trumpCard = null;
} }
}; };
// --- 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 || {};
const p2 = game.player2 || state.player2 || {}; const p2 = game.player2 || state.player2 || {};
return { return {
id: state.id, id: state.id,
status: state.status || game.status, status: state.status || game.status,
player1: { id: p1.id, name: p1.name }, player1: { id: p1.id, name: p1.name },
player2: { id: p2.id, name: p2.name }, player2: { id: p2.id, name: p2.name },
players: state.players, players: state.players,
hand: state.hand, hand: state.hand,
points: state.points, points: state.points,
opponent: { opponent: {
id: state.opponent?.id, id: state.opponent?.id,
name: state.opponent?.name, name: state.opponent?.name,
points: state.opponent?.points, points: state.opponent?.points,
cardCount: state.opponent?.cardCount cardCount: state.opponent?.cardCount,
}, },
table: state.table, table: state.table,
trumpCard: state.trumpCard, trumpCard: state.trumpCard,
deckCount: game.deck ? game.deck.length : (state.deckCount || 0), deckCount: game.deck ? game.deck.length : state.deckCount || 0,
currentTurn: state.currentTurn, currentTurn: state.currentTurn,
turnStartedAt: state.turnStartedAt turnStartedAt: state.turnStartedAt,
}; };
}; };
const broadcastState = (io, gameId, game) => { const broadcastState = (io, gameId, game) => {
const roomName = `game_${gameId}`; const roomName = `game_${gameId}`;
const socketsInRoom = io.sockets.adapter.rooms.get(roomName); const socketsInRoom = io.sockets.adapter.rooms.get(roomName);
if (socketsInRoom) { if (socketsInRoom) {
for (const socketId of socketsInRoom) { for (const socketId of socketsInRoom) {
const s = io.sockets.sockets.get(socketId); const s = io.sockets.sockets.get(socketId);
const u = getUser(socketId); const u = getUser(socketId);
if (u && game.players[u.id]) { if (u && game.players[u.id]) {
const rawState = game.getStateForPlayer(u.id); const rawState = game.getStateForPlayer(u.id);
const cleanState = sanitizeState(rawState, game); const cleanState = sanitizeState(rawState, game);
s.emit("game-state", cleanState); s.emit("game-state", cleanState);
}
} }
} }
} }
};
async function saveGameResult(gameId, result, token) { async function saveGameResult(gameId, result, token) {
try { try {
@@ -82,63 +90,67 @@ async function saveGameResult(gameId, result, token) {
payload.winner_user_id = result.winnerId; payload.winner_user_id = result.winnerId;
payload.loser_user_id = result.loserId; payload.loser_user_id = result.loserId;
} }
const authHeader = token.startsWith("Bearer ") ? token : `Bearer ${token}`;
await axios.put(`${API_URL}/games/${gameId}`, payload, { await axios.put(`${API_URL}/games/${gameId}`, payload, {
headers: { Authorization: token }, headers: { Authorization: authHeader },
}); });
} catch (error) { } catch (error) {
console.error(`[DB Error] Game ${gameId}:`, error.message); console.error(`[DB Error] Game ${gameId}:`, error.message);
} }
} }
// --- 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",
winnerId: parseInt(winnerId), winnerId: parseInt(winnerId),
loserId: parseInt(loserId), loserId: parseInt(loserId),
totalTime: game.startTime ? Math.floor((Date.now() - game.startTime) / 1000) : 0, totalTime: game.startTime
isDraw: false, ? Math.floor((Date.now() - game.startTime) / 1000)
player1_points: game.players[Object.keys(game.players)[0]]?.points || 0, : 0,
player2_points: game.players[Object.keys(game.players)[1]]?.points || 0, isDraw: false,
reason: reason player1_points: game.players[Object.keys(game.players)[0]]?.points || 0,
}; player2_points: game.players[Object.keys(game.players)[1]]?.points || 0,
reason: reason,
};
game.status = "ended"; game.status = "ended";
if (game.timer) clearInterval(game.timer); if (game.timer) clearInterval(game.timer);
io.to(`game_${gameId}`).emit("game-over", { io.to(`game_${gameId}`).emit("game-over", {
winnerId: result.winnerId, winnerId: result.winnerId,
isDraw: result.isDraw, isDraw: result.isDraw,
p1Points: result.player1_points, p1Points: result.player1_points,
p2Points: result.player2_points, p2Points: result.player2_points,
message: reason === "surrender" ? "Opponent Surrendered!" : "Game Over" message: reason === "surrender" ? "Opponent Surrendered!" : "Game Over",
}); });
const anyId = Object.keys(game.players)[0]; const anyId = Object.keys(game.players)[0];
const token = game.players[anyId]?.token; const token = game.players[anyId]?.token;
if (token) saveGameResult(gameId, result, token); if (token) saveGameResult(gameId, result, token);
} };
const handleTimeout = (io, gameId, userId) => { const handleTimeout = (io, gameId, userId) => {
const game = getGame(gameId); const game = getGame(gameId);
if (!game || game.status === 'ended') return; if (!game || game.status === "ended") return;
console.log(`[Timeout] User ${userId} auto-playing lowest card.`); console.log(`[Timeout] User ${userId} auto-playing lowest card.`);
const player = game.players[userId]; const player = game.players[userId];
if (!player || !player.hand || player.hand.length === 0) return; if (!player || !player.hand || player.hand.length === 0) return;
const trumpSuit = game.trumpCard?.suit || ''; const trumpSuit = game.trumpCard?.suit || "";
const sortedHand = [...player.hand].sort((a, b) => { const sortedHand = [...player.hand].sort((a, b) => {
const strengthA = getStrength(a.rank) + (a.suit === trumpSuit ? 100 : 0); const strengthA = getStrength(a.rank) + (a.suit === trumpSuit ? 100 : 0);
const strengthB = getStrength(b.rank) + (b.suit === trumpSuit ? 100 : 0); const strengthB = getStrength(b.rank) + (b.suit === trumpSuit ? 100 : 0);
return strengthA - strengthB; return strengthA - strengthB;
}); });
const cardToPlay = sortedHand[0]; const cardToPlay = sortedHand[0];
if (cardToPlay) { if (cardToPlay) {
handleGameMove(io, gameId, userId, cardToPlay.id); handleGameMove(io, gameId, userId, cardToPlay.id);
} }
}; };
@@ -148,30 +160,33 @@ const handleGameMove = (io, gameId, userId, cardId) => {
const result = game.playCard(userId, cardId); const result = game.playCard(userId, cardId);
const roomName = `game_${gameId}`; const roomName = `game_${gameId}`;
broadcastState(io, gameId, game); broadcastState(io, gameId, game);
if (result.action === "trick_resolved") { if (result.action === "trick_resolved") {
io.to(roomName).emit("trick-end", result.trickResult); io.to(roomName).emit("trick-end", result.trickResult);
setTimeout(() => { setTimeout(() => {
if (game.status !== "ended") { if (game.status !== "ended") {
const canDraw = (game.deck && game.deck.length > 0) || game.trumpCard !== null; const canDraw =
(game.deck && game.deck.length > 0) || game.trumpCard !== null;
if (canDraw) { if (canDraw) {
const winnerId = result.trickResult.winnerId; const winnerId = result.trickResult.winnerId;
const loserId = Object.keys(game.players).find(id => String(id) !== String(winnerId)); const loserId = Object.keys(game.players).find(
(id) => String(id) !== String(winnerId)
);
if (winnerId) drawCard(game, winnerId); if (winnerId) drawCard(game, winnerId);
if (loserId) drawCard(game, loserId); if (loserId) drawCard(game, loserId);
}
broadcastState(io, gameId, game);
} }
}, 1500); broadcastState(io, gameId, game);
}
}, 1500);
} }
if (result.action === "game_ended") { if (result.action === "game_ended") {
performGameEnd(io, game, gameId, result.winnerId, result.loserId, "points"); performGameEnd(io, game, gameId, result.winnerId, result.loserId, "points");
} }
if (result.action === "next_turn" || result.action === "trick_resolved") { if (result.action === "next_turn" || result.action === "trick_resolved") {
@@ -193,72 +208,76 @@ export default (io, socket) => {
let gameData = null; let gameData = null;
try { try {
const token = socket.handshake.auth.token; const token = socket.handshake.auth.token;
const response = await axios.get(`${API_URL}/games/${gameId}`, { const response = await axios.get(`${API_URL}/games/${gameId}`, {
headers: { Authorization: token }, headers: { Authorization: token },
}); });
gameData = response.data.data || response.data; gameData = response.data.data || response.data;
} catch (e) { console.error("DB Sync Failed"); } } catch (e) {
console.error("DB Sync Failed");
}
if (!game && gameData) { if (!game && gameData) {
const p1Name = gameData.player1?.nickname || "Player 1"; const p1Name = gameData.player1?.nickname || "Player 1";
const p2Name = gameData.player2?.nickname || "Player 2"; const p2Name = gameData.player2?.nickname || "Player 2";
const type = gameData.type == "9" ? 9 : 3; const type = gameData.type == "9" ? 9 : 3;
game = createGame( game = createGame(
gameId, gameId,
type, type,
{ id: gameData.player1_user_id, name: p1Name }, { id: gameData.player1_user_id, name: p1Name },
{ id: gameData.player2_user_id, name: p2Name } { id: gameData.player2_user_id, name: p2Name }
); );
game.status = gameData.status === 'Playing' ? 'playing' : 'pending'; game.status = gameData.status === "Playing" ? "playing" : "pending";
} }
if (!game) { if (!game) {
socket.emit("error", { message: "Game not found." }); socket.emit("error", { message: "Game not found." });
return; return;
} }
const myId = user.id; const myId = user.id;
const ghostKey = Object.keys(game.players).find(key => key == 1); const ghostKey = Object.keys(game.players).find((key) => key == 1);
const amIDbPlayer2 = gameData && gameData.player2_user_id == myId; const amIDbPlayer2 = gameData && gameData.player2_user_id == myId;
if (!game.players[myId] && (ghostKey || amIDbPlayer2)) { if (!game.players[myId] && (ghostKey || amIDbPlayer2)) {
let ghostHand = []; let ghostHand = [];
let ghostPoints = 0; let ghostPoints = 0;
let ghostTricks = 0; let ghostTricks = 0;
if (ghostKey && game.players[ghostKey]) { if (ghostKey && game.players[ghostKey]) {
ghostHand = game.players[ghostKey].hand || []; ghostHand = game.players[ghostKey].hand || [];
ghostPoints = game.players[ghostKey].points || 0; ghostPoints = game.players[ghostKey].points || 0;
ghostTricks = game.players[ghostKey].tricks || 0; ghostTricks = game.players[ghostKey].tricks || 0;
delete game.players[ghostKey]; delete game.players[ghostKey];
} }
game.players[myId] = { game.players[myId] = {
id: myId, id: myId,
name: user.nickname, name: user.nickname,
hand: ghostHand, hand: ghostHand,
points: ghostPoints, points: ghostPoints,
tricks: ghostTricks, tricks: ghostTricks,
token: socket.handshake.auth.token, token: socket.handshake.auth.token,
}; };
game.player2 = { id: myId, name: user.nickname }; game.player2 = { id: myId, name: user.nickname };
game.status = 'playing'; game.status = "playing";
if(!game.timer) { if (!game.timer) {
game.startTurnTimer((timeoutUserId) => handleTimeout(io, gameId, timeoutUserId)); game.startTurnTimer((timeoutUserId) =>
} handleTimeout(io, gameId, timeoutUserId)
);
}
} }
socket.join(`game_${gameId}`); socket.join(`game_${gameId}`);
socket.activeGameId = gameId; socket.activeGameId = gameId;
if (game.players[myId]) { if (game.players[myId]) {
game.players[myId].token = socket.handshake.auth.token; game.players[myId].token = socket.handshake.auth.token;
const rawState = game.getStateForPlayer(myId); const rawState = game.getStateForPlayer(myId);
const cleanState = sanitizeState(rawState, game); const cleanState = sanitizeState(rawState, game);
socket.emit("game-state", cleanState); socket.emit("game-state", cleanState);
broadcastState(io, gameId, game); broadcastState(io, gameId, game);
} }
}); });
@@ -267,16 +286,17 @@ 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);
if (!game || !user || !game.players[user.id]) return;
console.log(`[Game] User ${user.nickname} surrendered.`); if (!game || !user || !game.players[user.id]) return;
const winnerId = Object.keys(game.players).find(id => String(id) !== String(user.id)); console.log(`[Game] User ${user.nickname} surrendered.`);
performGameEnd(io, game, gameId, winnerId, user.id, "surrender");
const winnerId = Object.keys(game.players).find(
(id) => String(id) !== String(user.id)
);
performGameEnd(io, game, gameId, winnerId, user.id, "surrender");
}); });
}; };
+151 -141
View File
@@ -2,157 +2,167 @@ import { getMatch, addMatch } from "../state/match.js";
import BiscaMatch from "../classes/BiscaMatch.js"; import BiscaMatch from "../classes/BiscaMatch.js";
import axios from "axios"; import axios from "axios";
const API_URL = "http://localhost:8000/api/v1"; const API_URL = process.env.API_URL || "http://localhost:8000/api/v1";
export default (io, socket) => { export default (io, socket) => {
const broadcastMatchUpdate = (matchId, match) => {
const broadcastMatchUpdate = (matchId, match) => { const roomName = `match_${matchId}`;
const roomName = `match_${matchId}`; const room = io.sockets.adapter.rooms.get(roomName);
const room = io.sockets.adapter.rooms.get(roomName); if (room) {
if (room) { for (const socketId of room) {
for (const socketId of room) { const clientSocket = io.sockets.sockets.get(socketId);
const clientSocket = io.sockets.sockets.get(socketId); if (!clientSocket || !clientSocket.user) continue;
if (!clientSocket || !clientSocket.user) continue; const userId = clientSocket.user.id;
const userId = clientSocket.user.id;
const publicState = match.getPublicState();
const gameState = match.getGameState(userId);
clientSocket.emit("match:update", { ...publicState, game: gameState });
}
}
};
const createMatchEmitter = (matchId) => (eventName, payload) => {
const match = getMatch(matchId);
if (!match) return;
if (eventName === 'match:new-round-start' || eventName === 'match:ended-signal') {
broadcastMatchUpdate(matchId, match);
} else if (eventName === 'match:round-ended') {
io.to(`match_${matchId}`).emit('match:round-ended', payload);
} else {
io.to(`match_${matchId}`).emit(eventName, payload);
}
};
// --- FIX: Timer Callback ---
const handleAutoPlay = (matchId) => (userId, cardId) => {
const match = getMatch(matchId);
if (!match || match.status !== "Playing") return;
console.log(`[Match ${matchId}] Auto-playing for User ${userId}`);
const result = match.playCard(userId, cardId);
if (result && result.action === "trick_resolved") {
io.to(`match_${matchId}`).emit("game:trick-end", result.trickResult);
setTimeout(() => { broadcastMatchUpdate(matchId, match); }, 1500);
} else {
broadcastMatchUpdate(matchId, match);
}
};
socket.on("match:enter", async (data) => {
const { matchId } = data;
if (!socket.user || !socket.user.id) return socket.emit("error", { message: "Auth failed" });
const userId = socket.user.id;
socket.activeMatchId = matchId;
const room = `match_${matchId}`;
let match = getMatch(matchId);
if (!match) {
try {
const res = await axios.get(`${API_URL}/matches/${matchId}`, {
headers: { Authorization: socket.token || socket.handshake.auth.token },
});
const matchData = res.data.data || res.data;
// Pass handleAutoPlay to constructor
match = new BiscaMatch(
matchData,
createMatchEmitter(matchId),
socket.token,
handleAutoPlay(matchId)
);
addMatch(match);
} catch (e) {
return socket.emit("error", "Match not found");
}
}
match.emitStateCallback = createMatchEmitter(matchId);
if (userId !== match.player1Id && match.player2Id === 1) await match.joinPlayer(userId, socket.user);
socket.join(room);
const publicState = match.getPublicState(); const publicState = match.getPublicState();
const gameState = match.getGameState(userId); const gameState = match.getGameState(userId);
socket.emit("match:update", { ...publicState, game: gameState }); clientSocket.emit("match:update", { ...publicState, game: gameState });
if (match.status === 'Playing') broadcastMatchUpdate(matchId, match); }
}); }
};
socket.on("game:play-card", (data) => { const createMatchEmitter = (matchId) => (eventName, payload) => {
const { matchId, cardId } = data; const match = getMatch(matchId);
const match = getMatch(matchId); if (!match) return;
if (!match || match.status !== "Playing") return;
const result = match.playCard(socket.user.id, cardId);
if (result && result.error) return socket.emit("error", { message: result.error });
if (result && result.action === "trick_resolved") {
io.to(`match_${matchId}`).emit("game:trick-end", result.trickResult);
setTimeout(() => { broadcastMatchUpdate(matchId, match); }, 1500);
} else {
broadcastMatchUpdate(matchId, match);
}
});
socket.on("game:surrender", async ({ matchId }) => { if (
const match = getMatch(matchId); eventName === "match:new-round-start" ||
if(!match || match.status !== "Playing") return; eventName === "match:ended-signal"
const userId = socket.user.id; ) {
console.log(`[Match ${matchId}] User ${userId} surrendered ROUND.`); broadcastMatchUpdate(matchId, match);
const opponentId = match.player1Id == userId ? match.player2Id : match.player1Id; } else if (eventName === "match:round-ended") {
const result = { io.to(`match_${matchId}`).emit("match:round-ended", payload);
winnerId: opponentId, } else {
loserId: userId, io.to(`match_${matchId}`).emit(eventName, payload);
isDraw: false, }
player1_id: match.player1Id, };
player2_id: match.player2Id,
player1_points: match.player1Id == opponentId ? 91 : 0,
player2_points: match.player2Id == opponentId ? 91 : 0,
reason: 'surrender'
};
await match.handleGameEnd(result);
});
socket.on("match:surrender", async ({ matchId }) => { 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;
const userId = socket.user.id;
// FIX: Abort current game to remove ghost
await match.abortCurrentGame(userId);
console.log(`[Match ${matchId}] User ${userId} surrendered MATCH.`); console.log(`[Match ${matchId}] Auto-playing for User ${userId}`);
const opponentId = match.player1Id == userId ? match.player2Id : match.player1Id; const result = match.playCard(userId, cardId);
match.marks[opponentId] = 4;
await match.finishMatch(); if (result && result.action === "trick_resolved") {
io.to(`match_${matchId}`).emit("game:trick-end", result.trickResult);
setTimeout(() => {
broadcastMatchUpdate(matchId, match); broadcastMatchUpdate(matchId, match);
}); }, 1500);
} else {
broadcastMatchUpdate(matchId, match);
}
};
socket.on("disconnect", async () => { socket.on("match:enter", async (data) => {
if (socket.activeMatchId) { const { matchId } = data;
const match = getMatch(socket.activeMatchId); if (!socket.user || !socket.user.id)
if (match && match.status === 'Playing') { return socket.emit("error", { message: "Auth failed" });
const userId = socket.user?.id; const userId = socket.user.id;
if (userId === match.player1Id || userId === match.player2Id) { socket.activeMatchId = matchId;
console.log(`[Match ${match.id}] Player ${userId} disconnected.`);
// FIX: Abort current game
await match.abortCurrentGame(userId);
const opponentId = match.player1Id == userId ? match.player2Id : match.player1Id; const room = `match_${matchId}`;
match.marks[opponentId] = 4; let match = getMatch(matchId);
await match.finishMatch();
broadcastMatchUpdate(match.id, match); if (!match) {
} try {
} const res = await axios.get(`${API_URL}/matches/${matchId}`, {
headers: {
Authorization: socket.token || socket.handshake.auth.token,
},
});
const matchData = res.data.data || res.data;
match = new BiscaMatch(
matchData,
createMatchEmitter(matchId),
socket.token,
handleAutoPlay(matchId)
);
addMatch(match);
} catch (e) {
return socket.emit("error", "Match not found");
}
}
match.emitStateCallback = createMatchEmitter(matchId);
if (userId !== match.player1Id && match.player2Id === 1)
await match.joinPlayer(userId, socket.user);
socket.join(room);
const publicState = match.getPublicState();
const gameState = match.getGameState(userId);
socket.emit("match:update", { ...publicState, game: gameState });
if (match.status === "Playing") broadcastMatchUpdate(matchId, match);
});
socket.on("game:play-card", (data) => {
const { matchId, cardId } = data;
const match = getMatch(matchId);
if (!match || match.status !== "Playing") return;
const result = match.playCard(socket.user.id, cardId);
if (result && result.error)
return socket.emit("error", { message: result.error });
if (result && result.action === "trick_resolved") {
io.to(`match_${matchId}`).emit("game:trick-end", result.trickResult);
setTimeout(() => {
broadcastMatchUpdate(matchId, match);
}, 1500);
} else {
broadcastMatchUpdate(matchId, match);
}
});
socket.on("game:surrender", async ({ matchId }) => {
const match = getMatch(matchId);
if (!match || match.status !== "Playing") return;
const userId = socket.user.id;
console.log(`[Match ${matchId}] User ${userId} surrendered ROUND.`);
const opponentId =
match.player1Id == userId ? match.player2Id : match.player1Id;
const result = {
winnerId: opponentId,
loserId: userId,
isDraw: false,
player1_id: match.player1Id,
player2_id: match.player2Id,
player1_points: match.player1Id == opponentId ? 91 : 0,
player2_points: match.player2Id == opponentId ? 91 : 0,
reason: "surrender",
};
await match.handleGameEnd(result);
});
socket.on("match:surrender", async ({ matchId }) => {
const match = getMatch(matchId);
if (!match || match.status !== "Playing") return;
const userId = socket.user.id;
await match.abortCurrentGame(userId);
console.log(`[Match ${matchId}] User ${userId} surrendered MATCH.`);
const opponentId =
match.player1Id == userId ? match.player2Id : match.player1Id;
match.marks[opponentId] = 4;
await match.finishMatch();
broadcastMatchUpdate(matchId, match);
});
socket.on("disconnect", async () => {
if (socket.activeMatchId) {
const match = getMatch(socket.activeMatchId);
if (match && match.status === "Playing") {
const userId = socket.user?.id;
if (userId === match.player1Id || userId === match.player2Id) {
console.log(`[Match ${match.id}] Player ${userId} disconnected.`);
await match.abortCurrentGame(userId);
const opponentId =
match.player1Id == userId ? match.player2Id : match.player1Id;
match.marks[opponentId] = 4;
await match.finishMatch();
broadcastMatchUpdate(match.id, match);
} }
}); }
}
});
}; };
+134 -113
View File
@@ -6,137 +6,158 @@ import gameEvents from "./events/game.js";
import matchEvents from "./events/match.js"; import matchEvents from "./events/match.js";
export const server = { export const server = {
io: null, io: null,
}; };
const API_URL = "http://localhost:8000/api/v1"; const API_URL = process.env.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;
export const serverStart = (port) => { export const serverStart = (port) => {
server.io = new Server(port, { server.io = new Server(port, {
cors: { cors: {
origin: "*", origin: "*",
},
});
console.log(`[WebSocket] API_URL is set to ${API_URL}`);
server.io.use(async (socket, next) => {
let token = socket.handshake.auth.token;
if (!token) {
return next(new Error("Authentication error: No token provided"));
}
if (!token.startsWith("Bearer ")) {
token = "Bearer " + token;
}
try {
const response = await axios.get(`${API_URL}/users/me`, {
headers: {
Authorization: token,
Accept: "application/json",
}, },
});
const user = response.data.data || response.data;
if (!user || !user.id) {
return next(new Error("Authentication error: User data not found"));
}
socket.user = user;
socket.token = token;
socket.handshake.auth.token = token;
if (disconnectTimers.has(user.id)) {
console.log(
`[Connection] User ${user.id} reconnected! Cancelling surrender timer.`
);
clearTimeout(disconnectTimers.get(user.id));
disconnectTimers.delete(user.id);
}
addUser(socket.id, user);
next();
} catch (error) {
if (error.response) {
console.error(
"❌ Erro Laravel:",
error.response.status,
error.response.data
);
} else {
console.error("❌ Erro Rede:", error.message);
}
next(new Error("Authentication error: Invalid token"));
}
});
server.io.on("connection", (socket) => {
gameEvents(server.io, socket);
matchEvents(server.io, socket);
handleConnectionEvents(server.io, socket);
socket.on("notify_disconnect", () => {
socket.isVoluntaryDisconnect = true;
}); });
server.io.use(async (socket, next) => { socket.on("disconnect", () => {
let token = socket.handshake.auth.token; // Remove from active socket list
removeUser(socket.id);
if (!token) { if (socket.isVoluntaryDisconnect) {
return next(new Error("Authentication error: No token provided")); console.log(
} `[Disconnect] User ${userId} disconnected voluntarily. No surrender needed.`
);
return;
}
if (!token.startsWith("Bearer ")) { if (userId) {
token = "Bearer " + token; console.log(
} `[Disconnect] User ${userId} disconnected. Waiting ${
RECONNECT_GRACE_PERIOD / 1000
}s...`
);
try { // -----------------------------------------------------------
const response = await axios.get(`${API_URL}/users/me`, { // 2. START SURRENDER TIMER
headers: { // -----------------------------------------------------------
Authorization: token, const timer = setTimeout(async () => {
Accept: "application/json" console.log(
}, `[Timeout] User ${userId} did not return. Forcing surrender.`
}); );
const user = response.data.data || response.data; // A. Find the Game ID this user is playing (You need a way to look this up)
// Ideally, your 'socket' object or a global map knows which gameID the user is in.
// For this example, let's assume you stored `socket.activeGameId` when they joined.
const gameId = socket.activeGameId;
if (!user || !user.id) { if (gameId) {
return next(new Error("Authentication error: User data not found")); try {
// B. Call Laravel API to resign on their behalf
// We use the token we saved during the handshake
await axios.post(
`${API_URL}/games/${gameId}/resign`,
{},
{
headers: {
Authorization: socket.handshake.auth.token,
Accept: "application/json",
},
}
);
// C. Notify the room (The API likely triggers a Pusher/Socket event,
// but we can also emit locally if needed)
server.io.to(gameId).emit("game_over", {
winner: "opponent",
reason: "disconnect",
});
console.log(
`[Timeout] Successfully resigned game ${gameId} for user ${userId}`
);
} catch (err) {
console.error(
`[Timeout] Failed to resign game for user ${userId}:`,
err.message
);
} }
}
socket.user = user; disconnectTimers.delete(userId);
socket.token = token; }, RECONNECT_GRACE_PERIOD);
socket.handshake.auth.token = token;
if (disconnectTimers.has(user.id)) { disconnectTimers.set(userId, timer);
console.log(`[Connection] User ${user.id} reconnected! Cancelling surrender timer.`); }
clearTimeout(disconnectTimers.get(user.id));
disconnectTimers.delete(user.id);
}
addUser(socket.id, user);
next();
} catch (error) {
if (error.response) {
console.error(
"❌ Erro Laravel:",
error.response.status,
error.response.data
);
} else {
console.error("❌ Erro Rede:", error.message);
}
next(new Error("Authentication error: Invalid token"));
}
});
server.io.on("connection", (socket) => {
gameEvents(server.io, socket);
matchEvents(server.io, socket);
handleConnectionEvents(server.io, socket);
socket.on("notify_disconnect", () => {
socket.isVoluntaryDisconnect = true;
});
socket.on("disconnect", () => {
// Remove from active socket list
removeUser(socket.id);
if (socket.isVoluntaryDisconnect) {
console.log(`[Disconnect] User ${userId} disconnected voluntarily. No surrender needed.`);
return;
}
if (userId) {
console.log(`[Disconnect] User ${userId} disconnected. Waiting ${RECONNECT_GRACE_PERIOD / 1000}s...`);
// -----------------------------------------------------------
// 2. START SURRENDER TIMER
// -----------------------------------------------------------
const timer = setTimeout(async () => {
console.log(`[Timeout] User ${userId} did not return. Forcing surrender.`);
// A. Find the Game ID this user is playing (You need a way to look this up)
// Ideally, your 'socket' object or a global map knows which gameID the user is in.
// For this example, let's assume you stored `socket.activeGameId` when they joined.
const gameId = socket.activeGameId;
if (gameId) {
try {
// B. Call Laravel API to resign on their behalf
// We use the token we saved during the handshake
await axios.post(`${API_URL}/games/${gameId}/resign`, {}, {
headers: {
Authorization: socket.handshake.auth.token,
Accept: "application/json"
}
});
// C. Notify the room (The API likely triggers a Pusher/Socket event,
// but we can also emit locally if needed)
server.io.to(gameId).emit("game_over", {
winner: "opponent",
reason: "disconnect"
});
console.log(`[Timeout] Successfully resigned game ${gameId} for user ${userId}`);
} catch (err) {
console.error(`[Timeout] Failed to resign game for user ${userId}:`, err.message);
}
}
disconnectTimers.delete(userId);
}, RECONNECT_GRACE_PERIOD);
disconnectTimers.set(userId, timer);
}
});
}); });
});
}; };