Compare commits

..
29 changed files with 266 additions and 351 deletions
+2 -14
View File
@@ -17,7 +17,6 @@ class GameController extends Controller
$user = Auth::user(); $user = Auth::user();
$query = Game::query()->with(["winner", "player1", "player2"]); $query = Game::query()->with(["winner", "player1", "player2"]);
// Se não for Admin, mostra apenas os jogos do utilizador
if ($user->type !== 'A') { if ($user->type !== 'A') {
$query->where(function ($q) use ($user) { $query->where(function ($q) use ($user) {
$q->where('player1_user_id', $user->id) $q->where('player1_user_id', $user->id)
@@ -50,9 +49,6 @@ class GameController extends Controller
return response()->json($query->paginate(15)); return response()->json($query->paginate(15));
} }
// --- MÉTODOS ADICIONADOS ---
// Host a new multiplayer Game
public function host(Request $request) public function host(Request $request)
{ {
$request->validate([ $request->validate([
@@ -61,7 +57,6 @@ class GameController extends Controller
$user = $request->user(); $user = $request->user();
// Check for active game
$activeGame = Game::where(function ($q) use ($user) { $activeGame = Game::where(function ($q) use ($user) {
$q->where("player1_user_id", $user->id) $q->where("player1_user_id", $user->id)
->orWhere("player2_user_id", $user->id); ->orWhere("player2_user_id", $user->id);
@@ -77,7 +72,7 @@ class GameController extends Controller
'type' => $request->type, 'type' => $request->type,
'status' => 'Pending', 'status' => 'Pending',
'player1_user_id' => $user->id, 'player1_user_id' => $user->id,
'player2_user_id' => self::GHOST_ID, // Ghost ID = 1 (Waiting for player) 'player2_user_id' => self::GHOST_ID,
'began_at' => now(), 'began_at' => now(),
'player1_points' => 0, 'player1_points' => 0,
'player2_points' => 0, 'player2_points' => 0,
@@ -86,14 +81,13 @@ class GameController extends Controller
return response()->json($game, 201); return response()->json($game, 201);
} }
// List open games (Available to join)
public function open(Request $request) public function open(Request $request)
{ {
$type = $request->query('type', '9'); $type = $request->query('type', '9');
$GHOST_ID = 1; $GHOST_ID = 1;
$games = Game::where('status', 'Pending') $games = Game::where('status', 'Pending')
->where('player2_user_id', $GHOST_ID) // Games waiting for P2 ->where('player2_user_id', $GHOST_ID)
->where('type', $type) ->where('type', $type)
->with('player1') ->with('player1')
->orderBy('began_at', 'asc') ->orderBy('began_at', 'asc')
@@ -101,7 +95,6 @@ class GameController extends Controller
return response()->json($games); return response()->json($games);
} }
// ---------------------------
public function store(Request $request) public function store(Request $request)
{ {
@@ -132,7 +125,6 @@ class GameController extends Controller
$initialStatus = $request->input('status', 'Pending'); $initialStatus = $request->input('status', 'Pending');
// If specific player 2 is provided, auto-start game
if ($player2Id !== self::GHOST_ID && !$request->has('status')) { if ($player2Id !== self::GHOST_ID && !$request->has('status')) {
$initialStatus = 'Playing'; $initialStatus = 'Playing';
} }
@@ -159,17 +151,14 @@ class GameController extends Controller
public function join(Request $request, Game $game) public function join(Request $request, Game $game)
{ {
// Prevent joining if already full or started
if ($game->status !== 'Pending' || $game->player2_user_id !== self::GHOST_ID) { if ($game->status !== 'Pending' || $game->player2_user_id !== self::GHOST_ID) {
return response()->json(['message' => 'Game is not available'], 400); return response()->json(['message' => 'Game is not available'], 400);
} }
// Prevent joining your own game
if ($game->player1_user_id === $request->user()->id) { if ($game->player1_user_id === $request->user()->id) {
return response()->json(['message' => 'Cannot join your own game'], 400); return response()->json(['message' => 'Cannot join your own game'], 400);
} }
// Check if joining user has another active game
$activeGame = Game::where(function ($q) use ($request) { $activeGame = Game::where(function ($q) use ($request) {
$q->where("player1_user_id", $request->user()->id) $q->where("player1_user_id", $request->user()->id)
->orWhere("player2_user_id", $request->user()->id); ->orWhere("player2_user_id", $request->user()->id);
@@ -247,7 +236,6 @@ class GameController extends Controller
public function destroy(Game $game) public function destroy(Game $game)
{ {
// Ensure only the host can delete, and ONLY if it's still Pending
if ($game->player1_user_id !== auth()->id()) { if ($game->player1_user_id !== auth()->id()) {
return response()->json(['message' => 'Unauthorized'], 403); return response()->json(['message' => 'Unauthorized'], 403);
} }
+5 -11
View File
@@ -70,7 +70,7 @@ class MatchController extends Controller
$stakeType = CoinTransactionType::firstOrCreate( $stakeType = CoinTransactionType::firstOrCreate(
["name" => "Match stake"], ["name" => "Match stake"],
["type" => "D"], // Debit ["type" => "D"],
); );
$match = MatchGame::create([ $match = MatchGame::create([
@@ -213,7 +213,7 @@ class MatchController extends Controller
) { ) {
$payoutType = CoinTransactionType::firstOrCreate( $payoutType = CoinTransactionType::firstOrCreate(
["name" => "Match payout"], ["name" => "Match payout"],
["type" => "C"], // Credit ["type" => "C"],
); );
$totalPrize = $match->stake * 2; $totalPrize = $match->stake * 2;
@@ -273,7 +273,7 @@ class MatchController extends Controller
$stakeType = CoinTransactionType::firstOrCreate( $stakeType = CoinTransactionType::firstOrCreate(
["name" => "Match stake"], ["name" => "Match stake"],
["type" => "D"], // Debit ["type" => "D"],
); );
$match = MatchGame::create([ $match = MatchGame::create([
@@ -307,7 +307,7 @@ class MatchController extends Controller
$query = MatchGame::where('status', 'Pending') $query = MatchGame::where('status', 'Pending')
->where(function($q) { ->where(function($q) {
$q->whereNull('player2_user_id') $q->whereNull('player2_user_id')
->orWhere('player2_user_id', 1); // 1 = Ghost ID ->orWhere('player2_user_id', 1);
}); });
if ($request->has('type')) { if ($request->has('type')) {
@@ -321,7 +321,6 @@ class MatchController extends Controller
return response()->json($matches); return response()->json($matches);
} }
// --- FIX: Safely Delete Match by removing dependencies first ---
public function destroy(MatchGame $match) public function destroy(MatchGame $match)
{ {
$user = request()->user(); $user = request()->user();
@@ -335,13 +334,10 @@ class MatchController extends Controller
} }
return DB::transaction(function () use ($match, $user) { return DB::transaction(function () use ($match, $user) {
// 1. Delete associated Games first (The FK constraint cause)
DB::table('games')->where('match_id', $match->id)->delete(); DB::table('games')->where('match_id', $match->id)->delete();
// 2. Unlink existing Coin Transactions (Set match_id to NULL)
CoinTransaction::where('match_id', $match->id)->update(['match_id' => null]); CoinTransaction::where('match_id', $match->id)->update(['match_id' => null]);
// 3. Process Refund
if ($match->stake > 0) { if ($match->stake > 0) {
$user->increment('coins_balance', $match->stake); $user->increment('coins_balance', $match->stake);
@@ -350,10 +346,9 @@ class MatchController extends Controller
['type' => 'C'] ['type' => 'C']
); );
// Create Refund Transaction with NO LINK to the deleted match
CoinTransaction::create([ CoinTransaction::create([
'user_id' => $user->id, 'user_id' => $user->id,
'match_id' => null, // IMPORTANT: Must be null 'match_id' => null,
'coin_transaction_type_id' => $refundType->id, 'coin_transaction_type_id' => $refundType->id,
'transaction_datetime' => now(), 'transaction_datetime' => now(),
'coins' => $match->stake, 'coins' => $match->stake,
@@ -361,7 +356,6 @@ class MatchController extends Controller
]); ]);
} }
// 4. Finally delete the match
$match->delete(); $match->delete();
return response()->json([ return response()->json([
@@ -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 [
+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' => [
[ [
+1 -5
View File
@@ -11,14 +11,12 @@ use App\Http\Controllers\TransactionController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::prefix('v1')->group(function () { Route::prefix('v1')->group(function () {
// Public Routes
Route::post('/login', [AuthController::class, 'login']); Route::post('/login', [AuthController::class, 'login']);
Route::post('/register', [AuthController::class, 'register']); Route::post('/register', [AuthController::class, 'register']);
Route::get('/statistics/public', [StatisticsController::class, 'getPublicStats']); Route::get('/statistics/public', [StatisticsController::class, 'getPublicStats']);
Route::get('/leaderboard', [StatisticsController::class, 'getLeaderboard']); Route::get('/leaderboard', [StatisticsController::class, 'getLeaderboard']);
// Authenticated Routes
Route::middleware(['auth:sanctum', 'throttle:60,1'])->group(function () { Route::middleware(['auth:sanctum', 'throttle:60,1'])->group(function () {
Route::post('/logout', [AuthController::class, 'logout']); Route::post('/logout', [AuthController::class, 'logout']);
Route::post('/coin-purchases', [CoinPurchaseController::class, 'store']); Route::post('/coin-purchases', [CoinPurchaseController::class, 'store']);
@@ -37,7 +35,6 @@ Route::prefix('v1')->group(function () {
Route::get('/transactions', [UserController::class, 'getMyTransactions']); Route::get('/transactions', [UserController::class, 'getMyTransactions']);
}); });
// User Resources
Route::prefix('/users')->group(function () { Route::prefix('/users')->group(function () {
Route::get('/{user}', [UserController::class, 'show']); Route::get('/{user}', [UserController::class, 'show']);
Route::get('/{user}/transactions', [UserController::class, 'getTransactions']); Route::get('/{user}/transactions', [UserController::class, 'getTransactions']);
@@ -61,12 +58,11 @@ Route::prefix('v1')->group(function () {
Route::post('host', [MatchController::class, 'host']); Route::post('host', [MatchController::class, 'host']);
Route::get('open', [MatchController::class, 'open']); Route::get('open', [MatchController::class, 'open']);
Route::post('/{match}/join', [MatchController::class, 'join']); Route::post('/{match}/join', [MatchController::class, 'join']);
Route::post('/{match}/start', [MatchController::class, 'start']); // Changed to /start to be explicit Route::post('/{match}/start', [MatchController::class, 'start']);
Route::delete('/{match}', [MatchController::class, 'destroy']); Route::delete('/{match}', [MatchController::class, 'destroy']);
Route::apiResource('/', MatchController::class)->parameters(['' => 'match']); Route::apiResource('/', MatchController::class)->parameters(['' => 'match']);
}); });
// Admin Routes
Route::middleware('user.type:A') Route::middleware('user.type:A')
->prefix('admin') ->prefix('admin')
->group(function () { ->group(function () {
-2
View File
@@ -42,8 +42,6 @@ const logout = () => {
onMounted(async () => { onMounted(async () => {
await authStore.restoreSession() await authStore.restoreSession()
// REMOVED - The WebSocket for multiplayer is handled separately in the WebSocketStore
// socketStore.handleConnection()
}) })
</script> </script>
+1 -3
View File
@@ -88,7 +88,6 @@ const props = defineProps({
const deckPulse = ref(false) const deckPulse = ref(false)
const trumpReveal = ref(false) const trumpReveal = ref(false)
// Pulse animation when cards remaining changes
watch( watch(
() => props.cardsRemaining, () => props.cardsRemaining,
() => { () => {
@@ -99,7 +98,6 @@ watch(
}, },
) )
// Trump reveal animation
watch( watch(
() => props.revealTrump, () => props.revealTrump,
(newValue) => { (newValue) => {
@@ -107,7 +105,7 @@ watch(
trumpReveal.value = true trumpReveal.value = true
setTimeout(() => { setTimeout(() => {
trumpReveal.value = false trumpReveal.value = false
}, 3000) // Show for 3 seconds }, 3000)
} }
}, },
) )
@@ -54,18 +54,14 @@ const isAnimating = ref(false)
const currentPosition = ref({ x: 0, y: 0 }) const currentPosition = ref({ x: 0, y: 0 })
onMounted(() => { onMounted(() => {
// Start at the deck position
currentPosition.value = { ...props.startPosition } currentPosition.value = { ...props.startPosition }
isVisible.value = true isVisible.value = true
// Wait for delay, then start animation
setTimeout(() => { setTimeout(() => {
// Force a reflow to ensure starting position is set
requestAnimationFrame(() => { requestAnimationFrame(() => {
isAnimating.value = true isAnimating.value = true
currentPosition.value = { ...props.endPosition } currentPosition.value = { ...props.endPosition }
// After animation completes, emit event and hide
setTimeout(() => { setTimeout(() => {
emit('complete') emit('complete')
isVisible.value = false isVisible.value = false
@@ -61,7 +61,6 @@ const props = defineProps({
cardsRemaining: { type: Number, default: 0 }, cardsRemaining: { type: Number, default: 0 },
deckIsEmpty: { type: Boolean, default: false }, deckIsEmpty: { type: Boolean, default: false },
maxCards: { type: Number, default: 3 }, maxCards: { type: Number, default: 3 },
// REMOVIDO: playableCardIndices (já não precisamos dele)
playerScore: { type: Number, default: 0 }, playerScore: { type: Number, default: 0 },
opponentScore: { type: Number, default: 0 }, opponentScore: { type: Number, default: 0 },
playerName: { type: String, default: 'You' }, playerName: { type: String, default: 'You' },
@@ -218,21 +218,18 @@ const showConfetti = ref(false)
const displayPlayerScore = ref(0) const displayPlayerScore = ref(0)
const displayOpponentScore = ref(0) const displayOpponentScore = ref(0)
// Computed title based on winner
const title = computed(() => { const title = computed(() => {
if (props.winner === 'player') return 'Victory!' if (props.winner === 'player') return 'Victory!'
if (props.winner === 'opponent') return 'Defeat' if (props.winner === 'opponent') return 'Defeat'
return 'Draw!' return 'Draw!'
}) })
// Computed subtitle
const subtitle = computed(() => { const subtitle = computed(() => {
if (props.winner === 'player') return 'Congratulations! You won the game!' if (props.winner === 'player') return 'Congratulations! You won the game!'
if (props.winner === 'opponent') return 'Better luck next time!' if (props.winner === 'opponent') return 'Better luck next time!'
return 'The game ended in a draw' return 'The game ended in a draw'
}) })
// Animate score count-up
const animateScore = (fromValue, toValue, callback) => { const animateScore = (fromValue, toValue, callback) => {
const duration = 1000 const duration = 1000
const steps = 30 const steps = 30
@@ -255,19 +252,16 @@ const animateScore = (fromValue, toValue, callback) => {
}, stepDuration) }, stepDuration)
} }
// Watch for visibility and trigger animations
watch( watch(
() => props.isVisible, () => props.isVisible,
(newValue) => { (newValue) => {
if (newValue) { if (newValue) {
// Start confetti if player wins
if (props.winner === 'player') { if (props.winner === 'player') {
setTimeout(() => { setTimeout(() => {
showConfetti.value = true showConfetti.value = true
}, 300) }, 300)
} }
// Animate scores counting up
setTimeout(() => { setTimeout(() => {
animateScore(0, props.playerScore, (value) => { animateScore(0, props.playerScore, (value) => {
displayPlayerScore.value = value displayPlayerScore.value = value
@@ -99,10 +99,9 @@ const turnChanged = ref(false)
const displayPlayerScore = ref(props.playerScore) const displayPlayerScore = ref(props.playerScore)
const displayOpponentScore = ref(props.opponentScore) const displayOpponentScore = ref(props.opponentScore)
// Animate score count-up
const animateScore = (fromValue, toValue, callback) => { const animateScore = (fromValue, toValue, callback) => {
const duration = 500 // Total animation duration in ms const duration = 500
const steps = 20 // Number of increments const steps = 20
const stepDuration = duration / steps const stepDuration = duration / steps
const increment = (toValue - fromValue) / steps const increment = (toValue - fromValue) / steps
@@ -122,7 +121,6 @@ const animateScore = (fromValue, toValue, callback) => {
}, stepDuration) }, stepDuration)
} }
// Watch for player score changes and trigger count-up animation
watch( watch(
() => props.playerScore, () => props.playerScore,
(newScore, oldScore) => { (newScore, oldScore) => {
@@ -143,7 +141,6 @@ watch(
{ immediate: true }, { immediate: true },
) )
// Watch for opponent score changes and trigger count-up animation
watch( watch(
() => props.opponentScore, () => props.opponentScore,
(newScore, oldScore) => { (newScore, oldScore) => {
@@ -164,7 +161,6 @@ watch(
{ immediate: true }, { immediate: true },
) )
// Watch for turn changes and trigger pulse animation
watch( watch(
() => props.currentTurn, () => props.currentTurn,
() => { () => {
-1
View File
@@ -13,7 +13,6 @@ console.log('[main.js] ws connection', wsConnection)
const app = createApp(App) const app = createApp(App)
//app.provide('socket', io(wsConnection))
app.provide('serverBaseURL', `http://${apiDomain}`) app.provide('serverBaseURL', `http://${apiDomain}`)
app.provide('apiBaseURL', `http://${apiDomain}/api/v1`) app.provide('apiBaseURL', `http://${apiDomain}/api/v1`)
+5 -6
View File
@@ -812,7 +812,7 @@ const stats = ref({})
const users = ref([]) const users = ref([])
const transactions = ref([]) const transactions = ref([])
const games = ref([]) const games = ref([])
const matches = ref([]) // NEW const matches = ref([])
const isLoading = ref(false) const isLoading = ref(false)
const loadMoreTrigger = ref(null) const loadMoreTrigger = ref(null)
@@ -822,7 +822,7 @@ const pagination = reactive({
users: { page: 1, lastPage: 1 }, users: { page: 1, lastPage: 1 },
transactions: { page: 1, lastPage: 1 }, transactions: { page: 1, lastPage: 1 },
games: { page: 1, lastPage: 1 }, games: { page: 1, lastPage: 1 },
matches: { page: 1, lastPage: 1 }, // NEW matches: { page: 1, lastPage: 1 },
}) })
const filters = reactive({ const filters = reactive({
@@ -832,7 +832,6 @@ const filters = reactive({
sort_coins: null, sort_coins: null,
game_sort_direction: 'desc', game_sort_direction: 'desc',
game_type: null, game_type: null,
// Match filters
match_type: null, match_type: null,
match_outcome: null, match_outcome: null,
match_date_from: '', match_date_from: '',
@@ -852,7 +851,7 @@ const tabs = [
{ id: 'users', name: 'User Management' }, { id: 'users', name: 'User Management' },
{ id: 'transactions', name: 'Transactions' }, { id: 'transactions', name: 'Transactions' },
{ id: 'games', name: 'Games History' }, { id: 'games', name: 'Games History' },
{ id: 'matches', name: 'Matches History' }, // NEW { id: 'matches', name: 'Matches History' },
] ]
const chartData = computed(() => { const chartData = computed(() => {
@@ -1028,10 +1027,10 @@ const handleSort = async (column) => {
filters.match_type = filters.match_type =
filters.match_type === null ? '9' : filters.match_type === '9' ? '3' : null filters.match_type === null ? '9' : filters.match_type === '9' ? '3' : null
} else if (column === 'date') { } else if (column === 'date') {
filters.pot = null // Reset pot sort when sorting by date filters.pot = null
filters.match_sort_direction = filters.match_sort_direction === 'desc' ? 'asc' : 'desc' filters.match_sort_direction = filters.match_sort_direction === 'desc' ? 'asc' : 'desc'
} else if (column === 'pot') { } else if (column === 'pot') {
filters.match_sort_direction = null // Reset date sort when sorting by pot filters.match_sort_direction = null
if (filters.pot === null) filters.pot = 'desc' if (filters.pot === null) filters.pot = 'desc'
else if (filters.pot === 'desc') filters.pot = 'asc' else if (filters.pot === 'desc') filters.pot = 'asc'
else filters.pot = 'desc' else filters.pot = 'desc'
+170 -118
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,24 +200,32 @@ 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 }) },
{ immediate: true },
)
watch(() => wsStore.gameState, async (newState, oldState) => { watch(
const oldP2 = oldState?.player2?.id; () => wsStore.gameState,
const newP2 = newState?.player2?.id; async (newState, oldState) => {
const oldP2 = oldState?.player2?.id
const newP2 = newState?.player2?.id
if (newP2 && newP2 > 1 && newP2 !== oldP2) { if (newP2 && newP2 > 1 && newP2 !== oldP2) {
await fetchGameMetadata(); await fetchGameMetadata()
} }
}, { deep: true }) },
{ deep: true },
)
watch(mappedTrick, (newVal) => { watch(
mappedTrick,
(newVal) => {
if (newVal.playerCard || newVal.opponentCard) { if (newVal.playerCard || newVal.opponentCard) {
if (trickClearTimer.value) clearTimeout(trickClearTimer.value) if (trickClearTimer.value) clearTimeout(trickClearTimer.value)
displayedTrick.value = { ...newVal } displayedTrick.value = { ...newVal }
@@ -188,23 +239,24 @@ watch(mappedTrick, (newVal) => {
displayedTrick.value = { playerCard: null, opponentCard: null } displayedTrick.value = { playerCard: null, opponentCard: null }
} }
} }
}, { deep: true }) },
{ 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(
() => wsStore.gameState?.currentTurn,
(newTurn) => {
if (newTurn) startTimer() if (newTurn) startTimer()
}, { immediate: true }) },
{ 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(
serverHand,
(newHand) => {
if (visibleHand.value.length === 0 && newHand.length > 0 && !hasInitialDealHappened.value) { if (visibleHand.value.length === 0 && newHand.length > 0 && !hasInitialDealHappened.value) {
animateDeal(newHand) animateDeal(newHand)
hasInitialDealHappened.value = true hasInitialDealHappened.value = true
} else if (!isDealing.value) { } else if (!isDealing.value) {
visibleHand.value = [...newHand] visibleHand.value = [...newHand]
} }
}, { deep: true }) },
{ deep: true },
// --- METHODS --- )
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');
wsStore.disconnect();
next();
} else {
next(false); // Stay on page
} }
return;
if (wsStore.socket) wsStore.socket.emit('notify_disconnect')
wsStore.disconnect()
next()
} else { } else {
// If I am NOT host (just a guest waiting), just leave next(false)
if (wsStore.socket) wsStore.socket.emit('notify_disconnect'); }
wsStore.disconnect(); return
next(); } else {
return; if (wsStore.socket) wsStore.socket.emit('notify_disconnect')
wsStore.disconnect()
next()
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()
} }
wsStore.disconnect(); }, 500)
next(); return
}
wsStore.disconnect()
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 })
}) })
@@ -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,
(newHand) => {
console.log('Player hand updated:', newHand) console.log('Player hand updated:', newHand)
}, { deep: true }) },
{ 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)
}) })
+3 -8
View File
@@ -1183,22 +1183,17 @@ const fetchData = async () => {
if (game.is_draw) outcome = 'draw' if (game.is_draw) outcome = 'draw'
else if (game.winner_user_id === authStore.currentUser.id) outcome = 'win' else if (game.winner_user_id === authStore.currentUser.id) outcome = 'win'
// LÓGICA CORRIGIDA PARA COINS
// A API devolve 'stake' (o valor que cada um apostou)
let stakeAmount = 0 let stakeAmount = 0
if (game.stake) { if (game.stake) {
stakeAmount = parseFloat(game.stake) stakeAmount = parseFloat(game.stake)
} }
// Se Ganhou: O lucro é o valor da stake (ex: apostou 10, recebeu 20, lucro +10)
// Se Perdeu: O prejuízo é o valor da stake (ex: -10)
// Se Empate: Normalmente devolve-se a aposta, por isso é 0 (ou ajusta conforme a tua regra)
let displayAmount = 0 let displayAmount = 0
if (outcome === 'win') { if (outcome === 'win') {
displayAmount = stakeAmount // Ganhou a aposta do outro displayAmount = stakeAmount
} else if (outcome === 'loss') { } else if (outcome === 'loss') {
displayAmount = stakeAmount // Perdeu a sua aposta (o sinal negativo é adicionado no HTML) displayAmount = stakeAmount
} }
return { return {
@@ -1206,7 +1201,7 @@ const fetchData = async () => {
variant: `Type ${game.type}`, variant: `Type ${game.type}`,
outcome: outcome, outcome: outcome,
opponent: opponent?.nickname || 'Unknown Player', opponent: opponent?.nickname || 'Unknown Player',
amount: displayAmount, // Valor corrigido amount: displayAmount,
duration: `${Math.floor(game.total_time / 60)}m ${game.total_time % 60}s`, duration: `${Math.floor(game.total_time / 60)}m ${game.total_time % 60}s`,
began_at: game.began_at, began_at: game.began_at,
details: { details: {
-2
View File
@@ -76,7 +76,6 @@ export const useAPIStore = defineStore('api', () => {
return axios.get(`${API_BASE_URL}/games?${queryParams}`) return axios.get(`${API_BASE_URL}/games?${queryParams}`)
} }
// --- ADICIONADO: Função que faltava ---
const getCurrentUserGames = (params = {}) => { const getCurrentUserGames = (params = {}) => {
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
page: params.page || 1, page: params.page || 1,
@@ -91,7 +90,6 @@ export const useAPIStore = defineStore('api', () => {
return axios.get(`${API_BASE_URL}/users/${authStore.currentUserID}/games?${queryParams}`) return axios.get(`${API_BASE_URL}/users/${authStore.currentUserID}/games?${queryParams}`)
} }
// --------------------------------------
const getCurrentUserMatches = (params = {}) => { const getCurrentUserMatches = (params = {}) => {
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
+7 -7
View File
@@ -3,13 +3,14 @@ import { ref, computed } from 'vue'
import { useAPIStore } from './api' import { useAPIStore } from './api'
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const DEFAULT_TIMEOUT = 30 * 60 * 1000 const DEFAULT_TIMEOUT = 30 * 60 * 1000
const REMEMBER_ME_TIMEOUT = 365 * 24 * 60 * 60 * 1000 const REMEMBER_ME_TIMEOUT = 365 * 24 * 60 * 60 * 1000
const currentUser = ref(undefined) const currentUser = ref(undefined)
const token = ref(localStorage.getItem('token') || null) const token = ref(localStorage.getItem('token') || null)
const tokenExpiry = ref(localStorage.getItem('tokenExpiry') ? parseInt(localStorage.getItem('tokenExpiry')) : null) const tokenExpiry = ref(
localStorage.getItem('tokenExpiry') ? parseInt(localStorage.getItem('tokenExpiry')) : null,
)
const rememberMe = ref(false) const rememberMe = ref(false)
const isLoggedIn = computed(() => { const isLoggedIn = computed(() => {
@@ -25,9 +26,9 @@ export const useAuthStore = defineStore('auth', () => {
}) })
const isTokenAlmostExpired = () => { const isTokenAlmostExpired = () => {
if (!tokenExpiry.value) return false; if (!tokenExpiry.value) return false
return Date.now() > (tokenExpiry.value - 60 * 1000); return Date.now() > tokenExpiry.value - 60 * 1000
}; }
const resetTokenExpiry = () => { const resetTokenExpiry = () => {
const timeout = rememberMe.value ? REMEMBER_ME_TIMEOUT : DEFAULT_TIMEOUT const timeout = rememberMe.value ? REMEMBER_ME_TIMEOUT : DEFAULT_TIMEOUT
@@ -106,7 +107,6 @@ export const useAuthStore = defineStore('auth', () => {
currentUser.value = userResp.data currentUser.value = userResp.data
localStorage.setItem('currentUser', JSON.stringify(userResp.data)) localStorage.setItem('currentUser', JSON.stringify(userResp.data))
} catch (apiError) { } catch (apiError) {
// API call failed, likely token is invalid
logout() logout()
return false return false
} }
@@ -117,7 +117,7 @@ export const useAuthStore = defineStore('auth', () => {
} }
const updateCurrentUserCoins = (coins) => { const updateCurrentUserCoins = (coins) => {
if (!currentUser.value) return; if (!currentUser.value) return
currentUser.value.coins_balance = coins currentUser.value.coins_balance = coins
} }
-1
View File
@@ -273,7 +273,6 @@ export const useBiscaStore = defineStore('bisca', () => {
playerScore.value = 0 playerScore.value = 0
winner.value = 'opponent' winner.value = 'opponent'
// Parar o jogo imediatamente
isGameRunning.value = false isGameRunning.value = false
isGameOver.value = true isGameOver.value = true
} }
+1 -1
View File
@@ -122,7 +122,7 @@ export const useSocketStore = defineStore('websocket', () => {
disconnect, disconnect,
joinGame, joinGame,
playCard, playCard,
surrender, // Exported surrender,
onTrickEnd, onTrickEnd,
onGameOver onGameOver
} }
-5
View File
@@ -4,7 +4,6 @@ import axios from "axios";
const API_URL = "http://localhost:8000/api/v1"; const API_URL = "http://localhost:8000/api/v1";
class BiscaMatch { class BiscaMatch {
// FIX: Added autoPlayCallback parameter
constructor(apiData, emitStateCallback, token, autoPlayCallback) { constructor(apiData, emitStateCallback, token, autoPlayCallback) {
this.id = apiData.id; this.id = apiData.id;
this.emitStateCallback = emitStateCallback; this.emitStateCallback = emitStateCallback;
@@ -92,7 +91,6 @@ class BiscaMatch {
this.currentGame.init(); this.currentGame.init();
// FIX: Start Timer & Connect Callback
this.currentGame.startTurnTimer((uid, cid) => { this.currentGame.startTurnTimer((uid, cid) => {
if (this.autoPlayCallback) this.autoPlayCallback(uid, cid); if (this.autoPlayCallback) this.autoPlayCallback(uid, cid);
}); });
@@ -105,12 +103,10 @@ class BiscaMatch {
} }
} }
// --- FIX: Close DB Game row on surrender ---
async abortCurrentGame(loserId) { async abortCurrentGame(loserId) {
if (this.currentGame && this.currentGame.dbId) { if (this.currentGame && this.currentGame.dbId) {
console.log(`[Match ${this.id}] Aborting Game #${this.currentGame.dbId}`); console.log(`[Match ${this.id}] Aborting Game #${this.currentGame.dbId}`);
try { try {
// Determine winner for the DB record based on who DIDN'T lose
const winnerId = loserId == this.player1Id ? this.player2Id : this.player1Id; const winnerId = loserId == this.player1Id ? this.player2Id : this.player1Id;
const payload = { const payload = {
@@ -208,7 +204,6 @@ class BiscaMatch {
const result = this.currentGame.playCard(userId, cardId); const result = this.currentGame.playCard(userId, cardId);
// FIX: Restart timer if game continues
if (result && (result.action === 'next_turn' || result.action === 'trick_resolved')) { if (result && (result.action === 'next_turn' || result.action === 'trick_resolved')) {
this.currentGame.startTurnTimer((uid, cid) => { this.currentGame.startTurnTimer((uid, cid) => {
if (this.autoPlayCallback) this.autoPlayCallback(uid, cid); if (this.autoPlayCallback) this.autoPlayCallback(uid, cid);
+2 -2
View File
@@ -1,7 +1,7 @@
import { addUser, removeUser } from "../state/connection.js"; import { addUser, removeUser } from "../state/connection.js";
const HEARTBEAT_INTERVAL = 30000; // 30 seconds const HEARTBEAT_INTERVAL = 30000;
const HEARTBEAT_TIMEOUT = 10000; // 10 seconds const HEARTBEAT_TIMEOUT = 10000;
export const handleConnectionEvents = (io, socket) => { export const handleConnectionEvents = (io, socket) => {
let heartbeatInterval; let heartbeatInterval;
-5
View File
@@ -4,13 +4,11 @@ import { getUser } from "../state/connection.js";
const API_URL = "http://localhost:8000/api/v1"; const API_URL = "http://localhost:8000/api/v1";
// --- HELPER: Card Strength for Auto-Play ---
const getStrength = (rank) => { const getStrength = (rank) => {
const strengthMap = { 1: 10, 7: 9, 13: 8, 11: 7, 12: 6, 6: 5, 5: 4, 4: 3, 3: 2, 2: 1 }; const strengthMap = { 1: 10, 7: 9, 13: 8, 11: 7, 12: 6, 6: 5, 5: 4, 4: 3, 3: 2, 2: 1 };
return strengthMap[rank] || 0; return strengthMap[rank] || 0;
}; };
// --- HELPER: Draw Card Logic ---
const drawCard = (game, playerId) => { const drawCard = (game, playerId) => {
const player = game.players[playerId]; const player = game.players[playerId];
if (!player) return; if (!player) return;
@@ -24,7 +22,6 @@ const drawCard = (game, playerId) => {
} }
}; };
// --- HELPER: Sanitize Data ---
const sanitizeState = (state, game) => { const sanitizeState = (state, game) => {
if (!state) return null; if (!state) return null;
const p1 = game.player1 || state.player1 || {}; const p1 = game.player1 || state.player1 || {};
@@ -90,7 +87,6 @@ async function saveGameResult(gameId, result, token) {
} }
} }
// --- SHARED END GAME LOGIC ---
const performGameEnd = (io, game, gameId, winnerId, loserId, reason) => { const performGameEnd = (io, game, gameId, winnerId, loserId, reason) => {
const result = { const result = {
action: "game_ended", action: "game_ended",
@@ -267,7 +263,6 @@ export default (io, socket) => {
if (user) handleGameMove(io, gameId, user.id, cardId); if (user) handleGameMove(io, gameId, user.id, cardId);
}); });
// --- NEW: SURRENDER HANDLER ---
socket.on("surrender", ({ gameId }) => { socket.on("surrender", ({ gameId }) => {
const user = getUser(socket.id); const user = getUser(socket.id);
const game = getGame(gameId); const game = getGame(gameId);
-4
View File
@@ -34,7 +34,6 @@ export default (io, socket) => {
} }
}; };
// --- FIX: Timer Callback ---
const handleAutoPlay = (matchId) => (userId, cardId) => { const handleAutoPlay = (matchId) => (userId, cardId) => {
const match = getMatch(matchId); const match = getMatch(matchId);
if (!match || match.status !== "Playing") return; if (!match || match.status !== "Playing") return;
@@ -66,7 +65,6 @@ export default (io, socket) => {
}); });
const matchData = res.data.data || res.data; const matchData = res.data.data || res.data;
// Pass handleAutoPlay to constructor
match = new BiscaMatch( match = new BiscaMatch(
matchData, matchData,
createMatchEmitter(matchId), createMatchEmitter(matchId),
@@ -126,7 +124,6 @@ export default (io, socket) => {
if(!match || match.status !== "Playing") return; if(!match || match.status !== "Playing") return;
const userId = socket.user.id; const userId = socket.user.id;
// FIX: Abort current game to remove ghost
await match.abortCurrentGame(userId); await match.abortCurrentGame(userId);
console.log(`[Match ${matchId}] User ${userId} surrendered MATCH.`); console.log(`[Match ${matchId}] User ${userId} surrendered MATCH.`);
@@ -144,7 +141,6 @@ export default (io, socket) => {
if (userId === match.player1Id || userId === match.player2Id) { if (userId === match.player1Id || userId === match.player2Id) {
console.log(`[Match ${match.id}] Player ${userId} disconnected.`); console.log(`[Match ${match.id}] Player ${userId} disconnected.`);
// FIX: Abort current game
await match.abortCurrentGame(userId); await match.abortCurrentGame(userId);
const opponentId = match.player1Id == userId ? match.player2Id : match.player1Id; const opponentId = match.player1Id == userId ? match.player2Id : match.player1Id;