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
AfonsoCMSousa a3da9af90d hotfix: Singler player now plays fine 2026-01-03 14:02:58 +00:00
AfonsoCMSousa 83832aaaa9 hotfix: Finnal fix 2026-01-03 14:01:38 +00:00
FernandoJVideira 4cfab79611 Merge pull request 'feature/bestof-series-interface' (#99) from feature/bestof-series-interface into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/99
Reviewed-by: KZix <[email protected]>
Reviewed-by: Edd <[email protected]>
Reviewed-by: FernandoJVideira <[email protected]>
2026-01-03 13:26:18 +00:00
AfonsoCMSousa 00cb5ba966 SyntaxSquad/DADProject#16 fix: merge conflics fixed 2026-01-03 13:25:21 +00:00
AfonsoCMSousa ceba5178f3 SyntaxSquad/DADProject#16 fix: i forgot this 2026-01-03 13:14:59 +00:00
AfonsoCMSousa 0fb64ff30c SyntaxSquad/DADProject#16 feat: MATCHES AND STUFF 2026-01-03 13:14:37 +00:00
AfonsoCMSousa e0ca8e51a6 SyntaxSquad/DADProject#16 feat: Can now start matches, just missing the actuall gameplay 2026-01-03 04:52:02 +00:00
FernandoJVideira cd25915d92 Merge pull request 'fix:admin & user match table sort' (#98) from fix/frontend-admin-match-sort into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/98
Reviewed-by: Edd <[email protected]>
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
2026-01-03 02:35:07 +00:00
FernandoJVideira bcfa5c06f7 fix:user profile match table sort 2026-01-03 02:33:22 +00:00
AfonsoCMSousa 3dcd8df4c7 SyntaxSquad/DADProject#16 fix: Cancle and Return is working and some other fixes involving game duration 2026-01-03 02:19:00 +00:00
FernandoJVideira 46e3f0eef6 fix:admin match table sort 2026-01-03 02:11:41 +00:00
AfonsoCMSousa 7e6766c726 SyntaxSquad/DADProject#16 merge: fix merge conflics 2026-01-03 00:53:51 +00:00
AfonsoCMSousa 1088749386 SyntaxSquad/DADProject#16 fix: Surrender if leave 2026-01-03 00:51:42 +00:00
KZix 52cb893bd0 Merge pull request 'Added matches scrollable to home page' (#96) from na/matches-home-page into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/96
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
Reviewed-by: KZix <[email protected]>
2026-01-03 00:43:38 +00:00
AfonsoCMSousa 23d0ea1343 SyntaxSquad/DADProject#16 fix: Timer issues and Forfeight button 2026-01-02 20:12:18 +00:00
58 changed files with 2432 additions and 5773 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
+16 -13
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);
@@ -244,4 +233,18 @@ class GameController extends Controller
'game' => $game->fresh(), 'game' => $game->fresh(),
]); ]);
} }
public function destroy(Game $game)
{
if ($game->player1_user_id !== auth()->id()) {
return response()->json(['message' => 'Unauthorized'], 403);
}
if ($game->status !== 'Pending') {
return response()->json(['message' => 'Cannot delete a game in progress'], 400);
}
$game->delete();
return response()->noContent();
}
} }
+191 -94
View File
@@ -13,17 +13,26 @@ class MatchController extends Controller
{ {
public function index(Request $request) public function index(Request $request)
{ {
$query = MatchGame::query()->with(['winner', 'player1', 'player2']); $query = MatchGame::query()->with(["winner", "player1", "player2"]);
if ($request->has('type')) { if ($request->has("type")) {
$query->where('type', $request->type); $query->where("type", $request->type);
} }
if ($request->has('status')) { if ($request->has("status")) {
$query->where('status', $request->status); $query->where("status", $request->status);
} }
$query->orderBy('began_at', 'desc'); if ($request->has("pot") && in_array($request->pot, ["asc", "desc"])) {
$query->orderBy("stake", $request->pot);
} elseif (
$request->has("sort_direction") &&
in_array($request->sort_direction, ["asc", "desc"])
) {
$query->orderBy("began_at", $request->sort_direction);
} else {
$query->orderBy("began_at", "desc");
}
return response()->json($query->paginate(15)); return response()->json($query->paginate(15));
} }
@@ -35,13 +44,20 @@ class MatchController extends Controller
$stake = $validated['stake']; $stake = $validated['stake'];
if ($user->coins_balance < $stake) { if ($user->coins_balance < $stake) {
return response()->json(['message' => 'Insufficient coin balance'], 400); return response()->json(
["message" => "Insufficient coin balance"],
400,
);
} }
$activeMatch = MatchGame::where(function ($q) use ($user) { $activeMatch = MatchGame::where(function ($q) use ($user) {
$q->where('player1_user_id', $user->id) $q->where("player1_user_id", $user->id)->orWhere(
->orWhere('player2_user_id', $user->id); "player2_user_id",
})->whereIn('status', ['Pending', 'Playing'])->exists(); $user->id,
);
})
->whereIn("status", ["Pending", "Playing"])
->exists();
if ($activeMatch) { if ($activeMatch) {
return response()->json(['message' => 'You already have an active match.'], 400); return response()->json(['message' => 'You already have an active match.'], 400);
@@ -50,45 +66,50 @@ class MatchController extends Controller
return DB::transaction(function () use ($validated, $user, $stake) { return DB::transaction(function () use ($validated, $user, $stake) {
$GHOST_ID = 1; $GHOST_ID = 1;
$user->decrement('coins_balance', $stake); $user->decrement("coins_balance", $stake);
$stakeType = CoinTransactionType::firstOrCreate( $stakeType = CoinTransactionType::firstOrCreate(
['name' => 'Match stake'], ["name" => "Match stake"],
['type' => 'D'] // Debit ["type" => "D"],
); );
$match = MatchGame::create([ $match = MatchGame::create([
'type' => $validated['type'], "type" => $validated["type"],
'status' => 'Pending', "status" => "Pending",
'player1_user_id' => $user->id, "player1_user_id" => $user->id,
'player2_user_id' => $GHOST_ID, "player2_user_id" => $GHOST_ID,
'winner_user_id' => $GHOST_ID, "winner_user_id" => $GHOST_ID,
'loser_user_id' => $GHOST_ID, "loser_user_id" => $GHOST_ID,
'stake' => $stake, "stake" => $stake,
'began_at' => now(), "began_at" => now(),
'player1_marks' => 0, 'player2_marks' => 0, "player1_marks" => 0,
'player1_points' => 0, 'player2_points' => 0, "player2_marks" => 0,
"player1_points" => 0,
"player2_points" => 0,
]); ]);
CoinTransaction::create([ CoinTransaction::create([
'user_id' => $user->id, "user_id" => $user->id,
'match_id' => $match->id, "match_id" => $match->id,
'coin_transaction_type_id' => $stakeType->id, "coin_transaction_type_id" => $stakeType->id,
'transaction_datetime' => now(), "transaction_datetime" => now(),
'coins' => -$stake, "coins" => -$stake,
]); ]);
return response()->json([ return response()->json(
'message' => 'Match created successfully', [
'match' => $match, "message" => "Match created successfully",
'balance' => $user->coins_balance "match" => $match,
], 201); "balance" => $user->coins_balance,
],
201,
);
}); });
} }
public function show(MatchGame $match) public function show(MatchGame $match)
{ {
$match->load(['player1', 'player2', 'winner']); $match->load(["player1", "player2", "winner"]);
return response()->json($match); return response()->json($match);
} }
@@ -97,31 +118,47 @@ class MatchController extends Controller
$user = request()->user(); $user = request()->user();
if ($match->player1_user_id == $user->id) { if ($match->player1_user_id == $user->id) {
return response()->json(['message' => 'You cannot join your own match'], 400); return response()->json(
["message" => "You cannot join your own match"],
400,
);
} }
if ($match->status !== 'Pending') { if ($match->status !== "Pending") {
return response()->json(['message' => 'Match is full or started'], 400); return response()->json(
["message" => "Match is full or started"],
400,
);
} }
if ($user->coins_balance < $match->stake) { if ($user->coins_balance < $match->stake) {
return response()->json(['message' => 'Insufficient coin balance to join this match'], 400); return response()->json(
["message" => "Insufficient coin balance to join this match"],
400,
);
} }
$activeMatch = MatchGame::where(function ($q) use ($user) { $activeMatch = MatchGame::where(function ($q) use ($user) {
$q->where('player1_user_id', $user->id) $q->where("player1_user_id", $user->id)->orWhere(
->orWhere('player2_user_id', $user->id); "player2_user_id",
})->whereIn('status', ['Pending', 'Playing'])->exists(); $user->id,
);
})
->whereIn("status", ["Pending", "Playing"])
->exists();
if ($activeMatch) { if ($activeMatch) {
return response()->json(['message' => 'You are already in another active match.'], 400); return response()->json(
["message" => "You are already in another active match."],
400,
);
} }
return DB::transaction(function () use ($match, $user) { return DB::transaction(function () use ($match, $user) {
$user->decrement('coins_balance', $match->stake); $user->decrement("coins_balance", $match->stake);
$stakeType = CoinTransactionType::firstOrCreate( $stakeType = CoinTransactionType::firstOrCreate(
['name' => 'Match stake'], ["name" => "Match stake"],
['type' => 'D'] ["type" => "D"],
); );
CoinTransaction::create([ CoinTransaction::create([
@@ -133,62 +170,63 @@ class MatchController extends Controller
]); ]);
$match->update([ $match->update([
'status' => 'Playing', "status" => "Playing",
'player2_user_id' => $user->id "player2_user_id" => $user->id,
]); ]);
return response()->json([ return response()->json([
'message' => 'Joined match successfully!', "message" => "Joined match successfully!",
'match' => $match, "match" => $match,
'balance' => $user->coins_balance "balance" => $user->coins_balance,
]); ]);
}); });
} }
public function update(Request $request, MatchGame $match) public function update(Request $request, MatchGame $match)
{ {
if ($match->status == 'Ended') { if ($match->status == "Ended") {
return response()->json(['message' => 'Match already ended'], 400); return response()->json(["message" => "Match already ended"], 400);
} }
$data = $request->validate([ $data = $request->validate([
'status' => 'in:Playing,Ended,Interrupted', "status" => "in:Playing,Ended,Interrupted",
'winner_user_id' => 'exists:users,id', "winner_user_id" => "exists:users,id",
'loser_user_id' => 'exists:users,id', "loser_user_id" => "exists:users,id",
'player1_marks' => 'integer', "player1_marks" => "integer",
'player2_marks' => 'integer', "player2_marks" => "integer",
'player1_points' => 'integer', "player1_points" => "integer",
'player2_points' => 'integer', "player2_points" => "integer",
'total_time' => 'numeric' "total_time" => "numeric",
]); ]);
return DB::transaction(function () use ($match, $data) { return DB::transaction(function () use ($match, $data) {
if (isset($data['status']) && $data['status'] === 'Ended') { if (isset($data['status']) && $data['status'] === 'Ended') {
$data['ended_at'] = now(); $data['ended_at'] = now();
} }
$match->update($data); $match->update($data);
if ($match->status === 'Ended' && $match->stake > 0 && $match->winner_user_id) { if (
$match->status === "Ended" &&
$match->stake > 0 &&
$match->winner_user_id
) {
$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;
CoinTransaction::create([ CoinTransaction::create([
'user_id' => $match->winner_user_id, "user_id" => $match->winner_user_id,
'match_id' => $match->id, "match_id" => $match->id,
'coin_transaction_type_id' => $payoutType->id, "coin_transaction_type_id" => $payoutType->id,
'transaction_datetime' => now(), "transaction_datetime" => now(),
'coins' => $totalPrize, "coins" => $totalPrize,
]); ]);
$match->winner->increment("coins_balance", $totalPrize);
$match->winner->increment('coins_balance', $totalPrize);
} }
return response()->json($match); return response()->json($match);
@@ -198,34 +236,44 @@ class MatchController extends Controller
public function host(Request $request) public function host(Request $request)
{ {
$request->validate([ $request->validate([
'type' => 'required|in:3,9', "type" => "required|in:3,9",
'stake' => 'required|integer|min:0' "stake" => "required|integer|min:0",
]); ]);
$user = $request->user(); $user = $request->user();
$stake = $request->stake; $stake = $request->stake;
if ($user->coins_balance < $stake) { if ($user->coins_balance < $stake) {
return response()->json(['message' => 'Insufficient coin balance'], 400); return response()->json(
["message" => "Insufficient coin balance"],
400,
);
} }
$activeMatch = MatchGame::where(function ($q) use ($user) { $activeMatch = MatchGame::where(function ($q) use ($user) {
$q->where('player1_user_id', $user->id) $q->where("player1_user_id", $user->id)->orWhere(
->orWhere('player2_user_id', $user->id); "player2_user_id",
})->whereIn('status', ['Pending', 'Playing'])->exists(); $user->id,
);
})
->whereIn("status", ["Pending", "Playing"])
->exists();
if ($activeMatch) { if ($activeMatch) {
return response()->json(['message' => 'You already have an active match.'], 400); return response()->json(
["message" => "You already have an active match."],
400,
);
} }
return DB::transaction(function () use ($request, $user, $stake) { return DB::transaction(function () use ($request, $user, $stake) {
$GHOST_ID = 1; $GHOST_ID = 1;
$user->decrement('coins_balance', $stake); $user->decrement("coins_balance", $stake);
$stakeType = CoinTransactionType::firstOrCreate( $stakeType = CoinTransactionType::firstOrCreate(
['name' => 'Match stake'], ["name" => "Match stake"],
['type' => 'D'] // Debit ["type" => "D"],
); );
$match = MatchGame::create([ $match = MatchGame::create([
@@ -239,14 +287,15 @@ class MatchController extends Controller
'began_at' => now(), 'began_at' => now(),
'player1_marks' => 0, 'player2_marks' => 0, 'player1_marks' => 0, 'player2_marks' => 0,
'player1_points' => 0, 'player2_points' => 0, 'player1_points' => 0, 'player2_points' => 0,
]); ]);
CoinTransaction::create([ CoinTransaction::create([
'user_id' => $user->id, "user_id" => $user->id,
'match_id' => $match->id, "match_id" => $match->id,
'coin_transaction_type_id' => $stakeType->id, "coin_transaction_type_id" => $stakeType->id,
'transaction_datetime' => now(), "transaction_datetime" => now(),
'coins' => -$stake, "coins" => -$stake,
]); ]);
return response()->json($match, 201); return response()->json($match, 201);
@@ -255,16 +304,64 @@ class MatchController extends Controller
public function open(Request $request) public function open(Request $request)
{ {
$type = $request->query('type', '9'); $query = MatchGame::where('status', 'Pending')
$GHOST_ID = 1; ->where(function($q) {
$q->whereNull('player2_user_id')
->orWhere('player2_user_id', 1);
});
$matches = MatchGame::where('status', 'Pending') if ($request->has('type')) {
->where('player2_user_id', $GHOST_ID) $query->where('type', $request->type);
->where('type', $type) }
->with('player1')
->orderBy('began_at', 'asc') $matches = $query->with('player1:id,nickname')
->orderBy('began_at', 'desc')
->paginate(10); ->paginate(10);
return response()->json($matches); return response()->json($matches);
} }
public function destroy(MatchGame $match)
{
$user = request()->user();
if ($match->player1_user_id !== $user->id) {
return response()->json(['message' => 'Unauthorized'], 403);
}
if ($match->status !== 'Pending') {
return response()->json(['message' => 'Cannot cancel a match that has already started'], 400);
}
return DB::transaction(function () use ($match, $user) {
DB::table('games')->where('match_id', $match->id)->delete();
CoinTransaction::where('match_id', $match->id)->update(['match_id' => null]);
if ($match->stake > 0) {
$user->increment('coins_balance', $match->stake);
$refundType = CoinTransactionType::firstOrCreate(
['name' => 'Refund'],
['type' => 'C']
);
CoinTransaction::create([
'user_id' => $user->id,
'match_id' => null,
'coin_transaction_type_id' => $refundType->id,
'transaction_datetime' => now(),
'coins' => $match->stake,
'custom' => "Refund for Match #{$match->id}"
]);
}
$match->delete();
return response()->json([
'message' => 'Match canceled and refunded',
'balance' => $user->coins_balance
]);
});
}
} }
@@ -82,7 +82,7 @@ class ProfileController extends Controller
]); ]);
} }
public function updateAvatar(Request $request) public function uploadAvatar(Request $request)
{ {
$request->validate([ $request->validate([
"avatar" => "required|image|mimes:jpeg,png,jpg,gif|max:2048", "avatar" => "required|image|mimes:jpeg,png,jpg,gif|max:2048",
@@ -65,7 +65,7 @@ class StatisticsController extends Controller
->where('winner_user_id', $user->id) ->where('winner_user_id', $user->id)
->count(); ->count();
$totalLosses = $totalGames - $totalWins; // Simplest math $totalLosses = $totalGames - $totalWins;
$winRate = 0; $winRate = 0;
if ($totalGames > 0) { if ($totalGames > 0) {
@@ -78,7 +78,7 @@ class StatisticsController extends Controller
'total_wins' => $totalWins, 'total_wins' => $totalWins,
'total_losses' => $totalLosses, 'total_losses' => $totalLosses,
'win_rate' => $winRate . '%', 'win_rate' => $winRate . '%',
'current_balance' => $user->coins_balance, // Extra info 'current_balance' => $user->coins_balance,
]); ]);
} }
+145 -136
View File
@@ -12,209 +12,195 @@ 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);
$query = User::query(); $query = User::query();
if ($request->has('type')) { if ($request->has("type")) {
$query->where('type', $request->type); $query->where("type", $request->type);
} }
if ($request->has('blocked')) { if ($request->has("blocked")) {
$query->where('blocked', $request->type); $query->where("blocked", $request->type);
} }
if ($request->has('search')) { if ($request->has("search")) {
$query->where(function ($q) use ($request) { $query->where(function ($q) use ($request) {
$q->where('name', 'like', '%'.$request->search.'%') $q->where("name", "like", "%" . $request->search . "%")
->orWhere('email', 'like', '%'.$request->search.'%') ->orWhere("email", "like", "%" . $request->search . "%")
->orWhere('nickname', 'like', '%'.$request->search.'%'); ->orWhere("nickname", "like", "%" . $request->search . "%");
}); });
} }
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);
$validated = $request->validate([ $validated = $request->validate([
'name' => 'required|string|max:255', "name" => "required|string|max:255",
'email' => 'required|email|unique:users,email', "email" => "required|email|unique:users,email",
'password' => 'required|string|min:3', "password" => "required|string|min:3",
'type' => 'required|in:A,U', "type" => "required|in:A,U",
]); ]);
$validated['password'] = Hash::make($validated['password']); $validated["password"] = Hash::make($validated["password"]);
$user = User::create($validated); $user = User::create($validated);
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 {
return response()->json([ return response()->json([
'id' => $user->id, "id" => $user->id,
'name' => $user->name, "name" => $user->name,
'nickname' => $user->nickname, "nickname" => $user->nickname,
'photo_avatar_filename' => $user->photo_avatar_filename, "photo_avatar_filename" => $user->photo_avatar_filename,
'type' => $user->type, "type" => $user->type,
]); ]);
} }
} }
/**
* 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);
$validated = $request->validate([ $validated = $request->validate([
'name' => 'sometimes|string|max:255', "name" => "sometimes|string|max:255",
'nickname' => [ "nickname" => [
'sometimes', "sometimes",
'string', "string",
'max:20', "max:20",
Rule::unique('users')->ignore($user->id), Rule::unique("users")->ignore($user->id),
], ],
'email' => [ "email" => [
'sometimes', "sometimes",
'email', "email",
Rule::unique('users')->ignore($user->id), Rule::unique("users")->ignore($user->id),
], ],
'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);
} }
public function updatePassword(Request $request, User $user) public function updatePassword(Request $request, User $user)
{ {
$this->authorize('update', $user); $this->authorize("update", $user);
$validated = $request->validate([ $validated = $request->validate([
'current_password' => 'required|string', "current_password" => "required|string",
'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'], 403); ["message" => "Current password is incorrect"],
403,
);
} }
// 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) $q->where("player1_user_id", $user->id)->orWhere(
->orWhere('player2_user_id', $user->id); "player2_user_id",
$user->id,
);
})->exists(); })->exists();
if ($hasTransactions || $hasGames) { if ($hasTransactions || $hasGames) {
$user->delete(); // Soft delete $user->delete();
$message = 'User account deactivated (soft-deleted due to transaction/game history)'; $message =
"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) $q->where("player1_user_id", $user->id)->orWhere(
->orWhere('player2_user_id', $user->id); "player2_user_id",
$user->id,
);
}) })
->with(['winner', 'player1', 'player2']); ->with(["winner", "player1", "player2"]);
if ($request->has('type')) { if ($request->has("type")) {
$query->where('type', $request->type); $query->where("type", $request->type);
} }
if ($request->has('status')) { if ($request->has("status")) {
$query->where('status', $request->status); $query->where("status", $request->status);
} }
$sortDirection = $request->get('sort_direction', 'desc'); if ($request->has("date_from")) {
$query->orderBy('began_at', $sortDirection); $query->whereDate("began_at", ">=", $request->date_from);
}
if ($request->has("date_to")) {
$query->whereDate("began_at", "<=", $request->date_to);
}
$sortColumn = $request->get("sort_by", "began_at");
$sortDirection = $request->get("sort_direction", "desc");
$allowedColumns = [
"began_at",
"ended_at",
"total_time",
"total_points",
];
if (in_array($sortColumn, $allowedColumns)) {
$query->orderBy($sortColumn, $sortDirection);
} else {
$query->orderBy("began_at", $sortDirection);
}
$games = $query->paginate(10); $games = $query->paginate(10);
$games->getCollection()->transform(function ($game) use ($user) { $games->getCollection()->transform(function ($game) use ($user) {
if ($game->winner_user_id == $user->id) { if ($game->winner_user_id == $user->id) {
$game->outcome = 'win'; $game->outcome = "win";
} elseif ($game->is_draw) { } elseif ($game->is_draw) {
$game->outcome = 'draw'; $game->outcome = "draw";
} else { } else {
$game->outcome = 'loss'; $game->outcome = "loss";
} }
return $game; return $game;
}); });
@@ -222,41 +208,57 @@ 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);
$query = MatchGame::query() $query = MatchGame::query()
->where(function ($q) use ($user) { ->where(function ($q) use ($user) {
$q->where('player1_user_id', $user->id) $q->where("player1_user_id", $user->id)->orWhere(
->orWhere('player2_user_id', $user->id); "player2_user_id",
$user->id,
);
}) })
->with(['winner', 'player1', 'player2']); ->with(["winner", "player1", "player2"]);
if ($request->has('type')) { if ($request->has("type")) {
$query->where('type', $request->type); $query->where("type", $request->type);
} }
if ($request->has('status')) { if ($request->has("status")) {
$query->where('status', $request->status); $query->where("status", $request->status);
} }
$sortDirection = $request->get('sort_direction', 'desc'); if ($request->has("date_from")) {
$query->orderBy('began_at', $sortDirection); $query->whereDate("began_at", ">=", $request->date_from);
}
if ($request->has("date_to")) {
$query->whereDate("began_at", "<=", $request->date_to);
}
$sortColumn = $request->get("sort_by", "began_at");
$sortDirection = $request->get("sort_direction", "desc");
$allowedColumns = ["began_at", "ended_at", "stake", "total_time"];
if (in_array($sortColumn, $allowedColumns)) {
$query->orderBy($sortColumn, $sortDirection);
} else {
$query->orderBy("began_at", $sortDirection);
}
$matches = $query->paginate(10); $matches = $query->paginate(10);
$matches->getCollection()->transform(function ($match) use ($user) { $matches->getCollection()->transform(function ($match) use ($user) {
if ($match->winner_user_id == $user->id) { if ($match->winner_user_id == $user->id) {
$match->outcome = 'win'; $match->outcome = "win";
} elseif ($match->winner_user_id && $match->winner_user_id != $user->id) { } elseif (
$match->outcome = 'loss'; $match->winner_user_id &&
$match->winner_user_id != $user->id
) {
$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;
}); });
@@ -266,50 +268,57 @@ class UserController extends Controller
public function block(User $user) public function block(User $user)
{ {
$this->authorize('block', $user); $this->authorize("block", $user);
$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"]);
} }
public function unblock(User $user) public function unblock(User $user)
{ {
$this->authorize('unblock', $user); $this->authorize("unblock", $user);
$user->blocked = false; $user->blocked = false;
$user->save(); $user->save();
return response()->json(['message' => 'User unblocked successfully']); return response()->json(["message" => "User unblocked successfully"]);
} }
public function getTransactions(Request $request, User $user) public function getTransactions(Request $request, User $user)
{ {
if ($request->user()->id !== $user->id && $request->user()->type !== 'A') { if (
return response()->json(['message' => 'Forbidden'], 403); $request->user()->id !== $user->id &&
$request->user()->type !== "A"
) {
return response()->json(["message" => "Forbidden"], 403);
} }
$query = $user->transactions()->with('type'); $query = $user->transactions()->with("type");
if ($request->has('type')) { if ($request->has("type")) {
$query->whereHas('type', function ($q) use ($request) { $query->whereHas("type", function ($q) use ($request) {
$q->where('type', $request->type); $q->where("type", $request->type);
}); });
} }
if ($request->has('date_from')) { if ($request->has("date_from")) {
$query->whereDate('transaction_datetime', '>=', $request->date_from); $query->whereDate(
"transaction_datetime",
">=",
$request->date_from,
);
} }
if ($request->has('date_to')) { if ($request->has("date_to")) {
$query->whereDate('transaction_datetime', '<=', $request->date_to); $query->whereDate("transaction_datetime", "<=", $request->date_to);
} }
$transactions = $query->orderBy('transaction_datetime', 'desc') $transactions = $query
->orderBy("transaction_datetime", "desc")
->paginate(10); ->paginate(10);
return response()->json($transactions); return response()->json($transactions);
@@ -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 [
+6 -7
View File
@@ -4,17 +4,16 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class CoinTransactionType extends Model class CoinTransactionType extends Model
{ {
use HasFactory, SoftDeletes; use HasFactory;
protected $table = 'coin_transaction_types'; public $timestamps = false;
protected $fillable = ['name', 'type', 'custom']; protected $fillable = [
'name',
protected $casts = [ 'type',
'custom' => 'array', '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
+5 -7
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']);
@@ -58,13 +55,14 @@ Route::prefix('v1')->group(function () {
}); });
Route::prefix('matches')->group(function () { Route::prefix('matches')->group(function () {
Route::apiResource('/', MatchController::class)->parameters(['' => 'match']); Route::post('host', [MatchController::class, 'host']);
Route::post('/{match}/join', [MatchController::class, 'join']);
Route::post('host', [MatchController::class, 'host']); // <--- NOVA
Route::get('open', [MatchController::class, 'open']); Route::get('open', [MatchController::class, 'open']);
Route::post('/{match}/join', [MatchController::class, 'join']);
Route::post('/{match}/start', [MatchController::class, 'start']);
Route::delete('/{match}', [MatchController::class, 'destroy']);
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 -2
View File
@@ -16,7 +16,7 @@
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"@tanstack/vue-table": "^8.21.3", "@tanstack/vue-table": "^8.21.3",
"@vueuse/core": "^14.0.0", "@vueuse/core": "^14.1.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -24,7 +24,7 @@
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"lucide-vue-next": "^0.554.0", "lucide-vue-next": "^0.554.0",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"reka-ui": "^2.6.0", "reka-ui": "^2.7.0",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
-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,
() => { () => {
+8 -21
View File
@@ -2,20 +2,6 @@
<div> <div>
<NavigationMenu> <NavigationMenu>
<NavigationMenuList class="justify-around gap-20"> <NavigationMenuList class="justify-around gap-20">
<NavigationMenuItem>
<NavigationMenuTrigger>Testing</NavigationMenuTrigger>
<NavigationMenuContent>
<li>
<NavigationMenuLink as-child>
<RouterLink to="/testing/laravel">Laravel</RouterLink>
</NavigationMenuLink>
<NavigationMenuLink as-child>
<RouterLink to="/testing/websockets">Web Sockets</RouterLink>
</NavigationMenuLink>
</li>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem v-if="!userLoggedIn"> <NavigationMenuItem v-if="!userLoggedIn">
<NavigationMenuLink> <NavigationMenuLink>
<RouterLink to="/login">Login</RouterLink> <RouterLink to="/login">Login</RouterLink>
@@ -55,10 +41,9 @@ import {
NavigationMenuList, NavigationMenuList,
NavigationMenuTrigger, NavigationMenuTrigger,
} from '@/components/ui/navigation-menu' } from '@/components/ui/navigation-menu'
import router from '@/router'; import router from '@/router'
import { watch } from 'vue'; import { watch } from 'vue'
import { useUserStore } from '@/stores/user'; import { useUserStore } from '@/stores/user'
const emits = defineEmits(['logout']) const emits = defineEmits(['logout'])
const { userLoggedIn } = defineProps(['userLoggedIn']) const { userLoggedIn } = defineProps(['userLoggedIn'])
@@ -68,7 +53,7 @@ const biscaStore = useBiscaStore()
const logoutClickHandler = () => { const logoutClickHandler = () => {
if (biscaStore.isGameRunning) { if (biscaStore.isGameRunning) {
const confirmLogout = window.confirm( const confirmLogout = window.confirm(
'If you leave now this game will count as "FORFEIT". Are you sure you want to logout?' 'If you leave now this game will count as "FORFEIT". Are you sure you want to logout?',
) )
if (!confirmLogout) return if (!confirmLogout) return
@@ -85,11 +70,13 @@ const logoutClickHandler = () => {
const userStore = useUserStore() const userStore = useUserStore()
watch(() => userLoggedIn, async (isLoggedIn) => { watch(
() => userLoggedIn,
async (isLoggedIn) => {
if (isLoggedIn) { if (isLoggedIn) {
await userStore.fetchCoins() await userStore.fetchCoins()
} else { } else {
userStore.coins = 0 userStore.coins = 0
} }
}, { immediate: true }) }, { immediate: true })
</script> </script>
@@ -0,0 +1,29 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { Label } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
for: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<Label
data-slot="label"
v-bind="delegatedProps"
:class="
cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
props.class,
)
"
>
<slot />
</Label>
</template>
@@ -0,0 +1 @@
export { default as Label } from "./Label.vue";
@@ -0,0 +1,49 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { SwitchRoot, SwitchThumb, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
defaultValue: { type: Boolean, required: false },
modelValue: { type: [Boolean, null], required: false },
disabled: { type: Boolean, required: false },
id: { type: String, required: false },
value: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
name: { type: String, required: false },
required: { type: Boolean, required: false },
class: { type: null, required: false },
});
const emits = defineEmits(["update:modelValue"]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<SwitchRoot
v-slot="slotProps"
data-slot="switch"
v-bind="forwarded"
:class="
cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
>
<SwitchThumb
data-slot="switch-thumb"
:class="
cn(
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0',
)
"
>
<slot name="thumb" v-bind="slotProps" />
</SwitchThumb>
</SwitchRoot>
</template>
@@ -0,0 +1 @@
export { default as Switch } from "./Switch.vue";
-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')
-342
View File
@@ -1,342 +0,0 @@
<template>
<div class="min-h-screen bg-gradient-to-br from-green-700 via-green-800 to-green-900 p-8">
<div class="max-w-6xl mx-auto">
<!-- Title -->
<h1 class="text-white text-3xl font-bold mb-8 text-center">
All Animations Test Page
</h1>
<!-- Control Panel -->
<div class="bg-gray-900/80 rounded-lg p-6 mb-8">
<h2 class="text-white text-xl font-semibold mb-4">Controls</h2>
<!-- Score Animation Tests -->
<div class="mb-6">
<h3 class="text-emerald-400 font-semibold mb-3">Score Count-Up Animation</h3>
<div class="flex flex-wrap gap-3">
<button
@click="increasePlayerScore"
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg font-medium transition-colors"
>
+10 Player Score
</button>
<button
@click="increaseOpponentScore"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium transition-colors"
>
+10 Opponent Score
</button>
<button
@click="resetScores"
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg font-medium transition-colors"
>
Reset Scores
</button>
</div>
</div>
<!-- Turn Transition Tests -->
<div class="mb-6">
<h3 class="text-blue-400 font-semibold mb-3">Turn Transition Animation</h3>
<div class="flex flex-wrap gap-3">
<button
@click="toggleTurn"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
>
Switch Turn
</button>
<button
@click="currentTurn = 'player'"
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg font-medium transition-colors"
>
Your Turn
</button>
<button
@click="currentTurn = 'opponent'"
class="px-4 py-2 bg-rose-600 hover:bg-rose-700 text-white rounded-lg font-medium transition-colors"
>
Bot's Turn
</button>
</div>
</div>
<!-- Trump Reveal Tests -->
<div class="mb-6">
<h3 class="text-amber-400 font-semibold mb-3">Trump Reveal Animation</h3>
<div class="flex flex-wrap gap-3">
<button
@click="triggerTrumpReveal"
class="px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-lg font-medium transition-colors"
>
Reveal Trump
</button>
</div>
</div>
<!-- Game Over Tests -->
<div class="mb-6">
<h3 class="text-purple-400 font-semibold mb-3">Game Over Screen</h3>
<div class="flex flex-wrap gap-3">
<button
@click="showGameOverPlayerWin"
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg font-medium transition-colors"
>
Player Wins
</button>
<button
@click="showGameOverOpponentWin"
class="px-4 py-2 bg-rose-600 hover:bg-rose-700 text-white rounded-lg font-medium transition-colors"
>
Opponent Wins
</button>
<button
@click="showGameOverDraw"
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg font-medium transition-colors"
>
Draw
</button>
</div>
</div>
<!-- Full Sequence -->
<div>
<h3 class="text-pink-400 font-semibold mb-3">Complete Sequence</h3>
<div class="flex flex-wrap gap-3">
<button
@click="runFullSequence"
:disabled="isRunningSequence"
class="px-4 py-2 bg-pink-600 hover:bg-pink-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors"
>
Run Full Animation Sequence
</button>
</div>
</div>
</div>
<!-- Game Board Preview -->
<div class="grid grid-rows-[auto_1fr] gap-8">
<!-- Score Display -->
<div class="flex justify-center">
<ScoreDisplay
:player-score="playerScore"
:opponent-score="opponentScore"
:current-turn="currentTurn"
:round-number="1"
/>
</div>
<!-- Deck Area (with trump) -->
<div class="flex justify-center">
<div class="bg-gray-800/30 rounded-lg p-6">
<h3 class="text-white text-sm font-semibold mb-4 text-center">Trump Card</h3>
<DeckArea
:trump-card="trumpCard"
:cards-remaining="cardsRemaining"
:is-empty="false"
:reveal-trump="revealTrump"
/>
</div>
</div>
</div>
<!-- Instructions -->
<div class="bg-gray-900/80 rounded-lg p-6 mt-8 text-gray-300">
<h3 class="text-white font-semibold mb-2">Instructions:</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<li>
<strong>Score Count-Up:</strong> Click "+10" buttons to see scores animate smoothly
from old to new value
</li>
<li>
<strong>Turn Transition:</strong> Switch turns to see the indicator pulse and glow with
color change
</li>
<li>
<strong>Trump Reveal:</strong> Triggers a 3-second pulse/glow animation on the trump
card with label
</li>
<li>
<strong>Game Over:</strong> Shows victory/defeat modal with score count-up and confetti
(on win)
</li>
<li>
<strong>Full Sequence:</strong> Runs all animations in order: trump reveal → turn
changes → score updates → game over
</li>
</ul>
</div>
</div>
<!-- Game Over Modal -->
<GameOver
:is-visible="gameOverVisible"
:winner="gameOverWinner"
:player-score="playerScore"
:opponent-score="opponentScore"
:stats="gameOverStats"
@close="gameOverVisible = false"
@play-again="handlePlayAgain"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
import ScoreDisplay from '@/components/game/ScoreDisplay.vue'
import DeckArea from '@/components/game/DeckArea.vue'
import GameOver from '@/components/game/GameOver.vue'
// Game state
const playerScore = ref(23)
const opponentScore = ref(15)
const currentTurn = ref('player')
const cardsRemaining = ref(30)
const revealTrump = ref(false)
const isRunningSequence = ref(false)
// Trump card
const trumpCard = ref({ suit: 'o', rank: 7 })
// Game Over state
const gameOverVisible = ref(false)
const gameOverWinner = ref('player')
const gameOverStats = ref({
playerTricks: 8,
opponentTricks: 6,
})
// Score manipulation
const increasePlayerScore = () => {
playerScore.value += 10
}
const increaseOpponentScore = () => {
opponentScore.value += 10
}
const resetScores = () => {
playerScore.value = 0
opponentScore.value = 0
}
// Turn control
const toggleTurn = () => {
currentTurn.value = currentTurn.value === 'player' ? 'opponent' : 'player'
}
// Trump reveal
const triggerTrumpReveal = () => {
revealTrump.value = true
setTimeout(() => {
revealTrump.value = false
}, 100)
}
// Game Over screens
const showGameOverPlayerWin = () => {
playerScore.value = 67
opponentScore.value = 53
gameOverWinner.value = 'player'
gameOverStats.value = {
playerTricks: 11,
opponentTricks: 9,
}
gameOverVisible.value = true
}
const showGameOverOpponentWin = () => {
playerScore.value = 45
opponentScore.value = 75
gameOverWinner.value = 'opponent'
gameOverStats.value = {
playerTricks: 7,
opponentTricks: 13,
}
gameOverVisible.value = true
}
const showGameOverDraw = () => {
playerScore.value = 60
opponentScore.value = 60
gameOverWinner.value = 'draw'
gameOverStats.value = {
playerTricks: 10,
opponentTricks: 10,
}
gameOverVisible.value = true
}
const handlePlayAgain = () => {
gameOverVisible.value = false
resetScores()
currentTurn.value = 'player'
cardsRemaining.value = 40
}
// Full animation sequence
const runFullSequence = async () => {
if (isRunningSequence.value) return
isRunningSequence.value = true
// Reset everything
resetScores()
currentTurn.value = 'player'
gameOverVisible.value = false
await new Promise((resolve) => setTimeout(resolve, 500))
// 1. Trump Reveal
console.log('1. Trump Reveal')
triggerTrumpReveal()
await new Promise((resolve) => setTimeout(resolve, 3500))
// 2. First turn
console.log('2. Player turn')
currentTurn.value = 'player'
await new Promise((resolve) => setTimeout(resolve, 1500))
// 3. Player scores
console.log('3. Player scores')
playerScore.value = 11
await new Promise((resolve) => setTimeout(resolve, 2000))
// 4. Switch turn
console.log('4. Switch to opponent turn')
currentTurn.value = 'opponent'
await new Promise((resolve) => setTimeout(resolve, 1500))
// 5. Opponent scores
console.log('5. Opponent scores')
opponentScore.value = 10
await new Promise((resolve) => setTimeout(resolve, 2000))
// 6. Back to player
console.log('6. Back to player')
currentTurn.value = 'player'
await new Promise((resolve) => setTimeout(resolve, 1000))
// 7. More scoring
console.log('7. More scoring')
playerScore.value = 23
await new Promise((resolve) => setTimeout(resolve, 1500))
opponentScore.value = 19
await new Promise((resolve) => setTimeout(resolve, 1500))
playerScore.value = 34
await new Promise((resolve) => setTimeout(resolve, 1500))
// 8. Final scores and game over
console.log('8. Game over')
playerScore.value = 67
opponentScore.value = 53
await new Promise((resolve) => setTimeout(resolve, 2000))
// Show game over
gameOverWinner.value = 'player'
gameOverStats.value = {
playerTricks: 11,
opponentTricks: 9,
}
gameOverVisible.value = true
isRunningSequence.value = false
}
</script>
-252
View File
@@ -1,252 +0,0 @@
<template>
<div class="min-h-screen bg-gradient-to-br from-green-700 via-green-800 to-green-900 p-8">
<div class="max-w-4xl mx-auto">
<!-- Title -->
<h1 class="text-white text-3xl font-bold mb-8 text-center">Animation Test Page</h1>
<!-- Control Panel -->
<div class="bg-gray-900/80 rounded-lg p-6 mb-8">
<h2 class="text-white text-xl font-semibold mb-4">Controls</h2>
<div class="flex flex-wrap gap-3">
<button
@click="playPlayerCard"
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg font-medium transition-colors"
>
Play Player Card
</button>
<button
@click="playOpponentCard"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium transition-colors"
>
Play Opponent Card
</button>
<button
@click="playBothCards"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
>
Play Both Cards
</button>
<button
@click="clearCards"
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg font-medium transition-colors"
>
Clear Cards
</button>
<button
@click="setPlayerWinner"
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg font-medium transition-colors"
>
Player Wins
</button>
<button
@click="setOpponentWinner"
class="px-4 py-2 bg-rose-600 hover:bg-rose-700 text-white rounded-lg font-medium transition-colors"
>
Opponent Wins
</button>
<button
@click="fullSequence"
class="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg font-medium transition-colors"
>
Full Trick Sequence
</button>
<button
@click="collectTrickPlayerWins"
:disabled="!playerCard || !opponentCard"
class="px-4 py-2 bg-cyan-600 hover:bg-cyan-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors"
>
Collect Trick (Player Wins)
</button>
<button
@click="collectTrickOpponentWins"
:disabled="!playerCard || !opponentCard"
class="px-4 py-2 bg-orange-600 hover:bg-orange-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors"
>
Collect Trick (Opponent Wins)
</button>
</div>
<!-- Current State Display -->
<div class="mt-4 text-gray-300 text-sm">
<p>
<strong>Player Card:</strong>
{{ playerCard ? `${playerCard.suit}${playerCard.rank}` : 'None' }}
</p>
<p>
<strong>Opponent Card:</strong>
{{ opponentCard ? `${opponentCard.suit}${opponentCard.rank}` : 'None' }}
</p>
<p><strong>Winner:</strong> {{ winner || 'None' }}</p>
<p><strong>First Player:</strong> {{ firstPlayer || 'None' }}</p>
</div>
</div>
<!-- PlayArea Component -->
<div class="bg-gray-800/50 rounded-lg p-8">
<h2 class="text-white text-xl font-semibold mb-4 text-center">Play Area</h2>
<PlayArea
:player-card="playerCard"
:opponent-card="opponentCard"
:winner="winner"
:first-player="firstPlayer"
/>
</div>
<!-- Instructions -->
<div class="bg-gray-900/80 rounded-lg p-6 mt-8 text-gray-300">
<h3 class="text-white font-semibold mb-2">Instructions:</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><strong>Play Player Card:</strong> Animates a card from bottom (player's hand)</li>
<li><strong>Play Opponent Card:</strong> Animates a card from top (opponent's hand)</li>
<li><strong>Play Both Cards:</strong> Both cards animate in simultaneously</li>
<li><strong>Clear Cards:</strong> Triggers exit animations (cards slide out)</li>
<li>
<strong>Winner Buttons:</strong> Add glowing ring effect to winning card (play cards
first)
</li>
<li>
<strong>Full Trick Sequence:</strong> Plays both cards shows winner clears (2 second
delay)
</li>
<li>
<strong>Collect Trick (Player Wins):</strong> Sets player as winner, waits 1.5s, then
cards slide DOWN toward player area
</li>
<li>
<strong>Collect Trick (Opponent Wins):</strong> Sets opponent as winner, waits 1.5s,
then cards slide UP toward opponent area
</li>
</ul>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import PlayArea from '@/components/game/PlayArea.vue'
// Reactive state
const playerCard = ref(null)
const opponentCard = ref(null)
const winner = ref(null)
const firstPlayer = ref(null)
// Sample cards to test with
const sampleCards = [
{ suit: 'c', rank: 1 },
{ suit: 'c', rank: 7 },
{ suit: 'c', rank: 13 },
{ suit: 'e', rank: 11 },
{ suit: 'e', rank: 12 },
{ suit: 'o', rank: 2 },
{ suit: 'o', rank: 3 },
{ suit: 'p', rank: 4 },
{ suit: 'p', rank: 7 },
]
// Get random card
const getRandomCard = () => {
return sampleCards[Math.floor(Math.random() * sampleCards.length)]
}
// Control functions
const playPlayerCard = () => {
if (!playerCard.value && !opponentCard.value) {
firstPlayer.value = 'player'
}
playerCard.value = getRandomCard()
winner.value = null
}
const playOpponentCard = () => {
if (!playerCard.value && !opponentCard.value) {
firstPlayer.value = 'opponent'
}
opponentCard.value = getRandomCard()
winner.value = null
}
const playBothCards = () => {
firstPlayer.value = 'player' // Player plays first in this scenario
playerCard.value = getRandomCard()
opponentCard.value = getRandomCard()
winner.value = null
}
const clearCards = () => {
playerCard.value = null
opponentCard.value = null
winner.value = null
firstPlayer.value = null
}
const setPlayerWinner = () => {
if (playerCard.value) {
winner.value = 'player'
}
}
const setOpponentWinner = () => {
if (opponentCard.value) {
winner.value = 'opponent'
}
}
const fullSequence = async () => {
// Clear first
clearCards()
await new Promise((resolve) => setTimeout(resolve, 500))
// Play both cards
playBothCards()
await new Promise((resolve) => setTimeout(resolve, 1000))
// Show winner (randomly)
winner.value = Math.random() > 0.5 ? 'player' : 'opponent'
await new Promise((resolve) => setTimeout(resolve, 2000))
// Clear cards
clearCards()
}
// Collect trick with player winning
const collectTrickPlayerWins = async () => {
if (!playerCard.value || !opponentCard.value) return
// Set player as winner
winner.value = 'player'
// Wait 1.5 seconds to show winner glow
await new Promise((resolve) => setTimeout(resolve, 1500))
// Clear cards (triggers exit animation toward player)
playerCard.value = null
opponentCard.value = null
// Reset after animation completes
await new Promise((resolve) => setTimeout(resolve, 600))
winner.value = null
firstPlayer.value = null
}
// Collect trick with opponent winning
const collectTrickOpponentWins = async () => {
if (!playerCard.value || !opponentCard.value) return
// Set opponent as winner
winner.value = 'opponent'
// Wait 1.5 seconds to show winner glow
await new Promise((resolve) => setTimeout(resolve, 1500))
// Clear cards (triggers exit animation toward opponent)
playerCard.value = null
opponentCard.value = null
// Reset after animation completes
await new Promise((resolve) => setTimeout(resolve, 600))
winner.value = null
firstPlayer.value = null
}
</script>
-275
View File
@@ -1,275 +0,0 @@
<template>
<div class="min-h-screen bg-gradient-to-br from-green-700 via-green-800 to-green-900 p-8">
<div class="max-w-6xl mx-auto">
<!-- Title -->
<h1 class="text-white text-3xl font-bold mb-8 text-center">Card Dealing Test Page</h1>
<!-- Control Panel -->
<div class="bg-gray-900/80 rounded-lg p-6 mb-8">
<h2 class="text-white text-xl font-semibold mb-4">Controls</h2>
<div class="flex flex-wrap gap-3 mb-4">
<button
@click="startDealing"
:disabled="isDealing"
class="px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors"
>
Start Dealing
</button>
<button
@click="dealSingleCard"
:disabled="isDealing || cardsRemaining === 0"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors"
>
Deal One Card
</button>
<button
@click="reset"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium transition-colors"
>
Reset
</button>
</div>
<!-- Current State Display -->
<div class="grid grid-cols-3 gap-4 text-gray-300 text-sm">
<div>
<p><strong>Player Hand:</strong> {{ playerHand.length }} cards</p>
<p><strong>Opponent Hand:</strong> {{ opponentHand.length }} cards</p>
</div>
<div>
<p><strong>Cards Remaining:</strong> {{ cardsRemaining }}</p>
<p><strong>Dealing:</strong> {{ isDealing ? 'Yes' : 'No' }}</p>
</div>
<div>
<p><strong>Next Recipient:</strong> {{ nextRecipient }}</p>
</div>
</div>
</div>
<!-- Game Board Layout -->
<div class="grid grid-rows-[auto_1fr_auto] gap-8">
<!-- Opponent Hand (Top) -->
<div class="flex justify-center">
<div class="bg-gray-800/30 rounded-lg p-4">
<h3 class="text-white text-sm font-semibold mb-2 text-center">Opponent Hand</h3>
<PlayerHand
ref="opponentHandRef"
:cards="opponentHand"
:face-down="true"
:max-cards="3"
:playable-cards="[]"
/>
</div>
</div>
<!-- Center Area (Deck) -->
<div class="flex items-center justify-center">
<div class="bg-gray-800/30 rounded-lg p-6">
<h3 class="text-white text-sm font-semibold mb-4 text-center">Deck</h3>
<DeckArea
ref="deckRef"
:trump-card="trumpCard"
:cards-remaining="cardsRemaining"
:is-empty="cardsRemaining === 0"
/>
</div>
</div>
<!-- Player Hand (Bottom) -->
<div class="flex justify-center">
<div class="bg-gray-800/30 rounded-lg p-4">
<h3 class="text-white text-sm font-semibold mb-2 text-center">Your Hand</h3>
<PlayerHand
ref="playerHandRef"
:cards="playerHand"
:face-down="false"
:max-cards="3"
:playable-cards="[]"
/>
</div>
</div>
</div>
<!-- Instructions -->
<div class="bg-gray-900/80 rounded-lg p-6 mt-8 text-gray-300">
<h3 class="text-white font-semibold mb-2">Instructions:</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><strong>Start Dealing:</strong> Automatically deals 6 cards (3 to each player) with 200ms delay</li>
<li><strong>Deal One Card:</strong> Manually deal a single card to the next recipient</li>
<li><strong>Reset:</strong> Clear all hands and reset the deck to 40 cards</li>
<li>Watch the FlyingCard animation as cards move from deck to hands</li>
<li>Notice the deck counter decreasing and the pulse effect</li>
<li>Cards appear in hands with a fade-in animation</li>
</ul>
</div>
</div>
<!-- Flying Cards Container -->
<FlyingCard
v-for="flyingCard in flyingCards"
:key="flyingCard.id"
:card="flyingCard.card"
:start-position="flyingCard.startPosition"
:end-position="flyingCard.endPosition"
:duration="500"
:face-down="flyingCard.faceDown"
:delay="flyingCard.delay"
@complete="onFlyingCardComplete(flyingCard)"
/>
</div>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
import PlayerHand from '@/components/game/PlayerHand.vue'
import DeckArea from '@/components/game/DeckArea.vue'
import FlyingCard from '@/components/game/FlyingCard.vue'
// Refs for components
const playerHandRef = ref(null)
const opponentHandRef = ref(null)
const deckRef = ref(null)
// Game state
const playerHand = ref([])
const opponentHand = ref([])
const cardsRemaining = ref(40)
const isDealing = ref(false)
const flyingCards = ref([])
let flyingCardIdCounter = 0
// Trump card (fixed for testing)
const trumpCard = ref({ suit: 'o', rank: 7 })
// Sample deck
const sampleCards = [
{ suit: 'c', rank: 1 },
{ suit: 'c', rank: 2 },
{ suit: 'c', rank: 3 },
{ suit: 'c', rank: 4 },
{ suit: 'c', rank: 5 },
{ suit: 'c', rank: 6 },
{ suit: 'c', rank: 7 },
{ suit: 'c', rank: 11 },
{ suit: 'c', rank: 12 },
{ suit: 'c', rank: 13 },
{ suit: 'e', rank: 1 },
{ suit: 'e', rank: 2 },
{ suit: 'e', rank: 3 },
{ suit: 'e', rank: 4 },
{ suit: 'e', rank: 5 },
{ suit: 'e', rank: 6 },
{ suit: 'e', rank: 7 },
{ suit: 'e', rank: 11 },
{ suit: 'e', rank: 12 },
{ suit: 'e', rank: 13 },
{ suit: 'o', rank: 1 },
{ suit: 'o', rank: 2 },
{ suit: 'o', rank: 3 },
{ suit: 'o', rank: 4 },
{ suit: 'o', rank: 5 },
{ suit: 'o', rank: 6 },
{ suit: 'p', rank: 1 },
{ suit: 'p', rank: 2 },
{ suit: 'p', rank: 3 },
{ suit: 'p', rank: 4 },
{ suit: 'p', rank: 5 },
{ suit: 'p', rank: 6 },
{ suit: 'p', rank: 7 },
{ suit: 'p', rank: 11 },
{ suit: 'p', rank: 12 },
{ suit: 'p', rank: 13 },
]
// Computed
const nextRecipient = computed(() => {
const totalDealt = playerHand.value.length + opponentHand.value.length
return totalDealt % 2 === 0 ? 'Player' : 'Opponent'
})
// Get random card
const getRandomCard = () => {
return sampleCards[Math.floor(Math.random() * sampleCards.length)]
}
// Get position of an element
const getElementPosition = (el) => {
if (!el) return { x: 0, y: 0 }
const rect = el.$el?.getBoundingClientRect() || el.getBoundingClientRect()
return {
x: rect.left + rect.width / 2 - 64, // Center, minus half card width
y: rect.top + rect.height / 2 - 89, // Center, minus half card height
}
}
// Deal a single card
const dealSingleCard = async () => {
if (cardsRemaining.value === 0) return
await nextTick()
// Determine recipient
const totalDealt = playerHand.value.length + opponentHand.value.length
const isPlayer = totalDealt % 2 === 0
// Get positions
const startPos = getElementPosition(deckRef.value)
const endPos = getElementPosition(isPlayer ? playerHandRef.value : opponentHandRef.value)
// Create flying card
const card = getRandomCard()
const flyingCard = {
id: flyingCardIdCounter++,
card,
startPosition: startPos,
endPosition: endPos,
faceDown: !isPlayer, // Face down for opponent
delay: 0,
recipient: isPlayer ? 'player' : 'opponent',
}
flyingCards.value.push(flyingCard)
cardsRemaining.value--
}
// Flying card animation complete
const onFlyingCardComplete = (flyingCard) => {
// Add card to appropriate hand
if (flyingCard.recipient === 'player') {
playerHand.value.push(flyingCard.card)
} else {
opponentHand.value.push(flyingCard.card)
}
// Remove flying card
flyingCards.value = flyingCards.value.filter((fc) => fc.id !== flyingCard.id)
}
// Start automatic dealing sequence
const startDealing = async () => {
if (isDealing.value) return
isDealing.value = true
// Deal 6 cards (3 to each player)
for (let i = 0; i < 6; i++) {
if (cardsRemaining.value === 0) break
await dealSingleCard()
// Wait 200ms before next card
await new Promise((resolve) => setTimeout(resolve, 700))
}
isDealing.value = false
}
// Reset everything
const reset = () => {
playerHand.value = []
opponentHand.value = []
cardsRemaining.value = 40
flyingCards.value = []
isDealing.value = false
}
</script>
-444
View File
@@ -1,444 +0,0 @@
<template>
<div class="min-h-screen bg-gradient-to-br from-green-700 via-green-800 to-green-900">
<!-- Control Panel (Floating) -->
<div class="fixed top-4 left-4 bg-gray-900/95 rounded-lg p-4 max-w-xs z-50 shadow-2xl">
<h3 class="text-white font-bold mb-3 text-sm">Test Controls</h3>
<div class="space-y-2">
<button
@click="dealInitialCards"
:disabled="isDealing"
class="w-full px-3 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 text-white rounded text-xs font-medium transition-colors"
>
Deal Cards
</button>
<button
@click="playRandomCards"
:disabled="playerHand.length === 0"
class="w-full px-3 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 text-white rounded text-xs font-medium transition-colors"
>
Play Both Cards
</button>
<button
@click="collectTrick"
:disabled="!currentTrick.playerCard || !currentTrick.opponentCard"
class="w-full px-3 py-2 bg-purple-600 hover:bg-purple-700 disabled:bg-gray-600 text-white rounded text-xs font-medium transition-colors"
>
Collect Trick
</button>
<button
@click="revealTrump = !revealTrump"
class="w-full px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded text-xs font-medium transition-colors"
>
{{ revealTrump ? 'Hide' : 'Reveal' }} Trump
</button>
<button
@click="showGameOver"
class="w-full px-3 py-2 bg-rose-600 hover:bg-rose-700 text-white rounded text-xs font-medium transition-colors"
>
Show Game Over
</button>
<button
@click="resetGame"
class="w-full px-3 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded text-xs font-medium transition-colors"
>
Reset Game
</button>
<div class="pt-2 border-t border-gray-700 mt-2 text-gray-400 text-xs">
<p><strong>Turn:</strong> {{ currentTurn === 'player' ? 'You' : 'Bot' }}</p>
<p><strong>Deck:</strong> {{ cardsRemaining }} cards</p>
</div>
</div>
</div>
<!-- Main Game Board -->
<div
class="grid grid-rows-[auto_auto_1fr_auto] grid-cols-[1fr_3fr_1fr] gap-6 p-8 min-h-screen"
>
<!-- Score Display (Top Left) -->
<div class="col-start-1 col-end-4 row-start-1 flex justify-start items-start">
<ScoreDisplay
:player-score="playerScore"
:opponent-score="opponentScore"
:current-turn="currentTurn"
:round-number="1"
/>
</div>
<!-- Opponent Hand -->
<div class="col-start-2 col-end-3 row-start-2 flex justify-center">
<PlayerHand
ref="opponentHandRef"
:cards="opponentHand"
:face-down="true"
:max-cards="3"
:playable-cards="[]"
/>
</div>
<!-- Play Area -->
<div class="col-start-2 col-end-3 row-start-3 flex items-center justify-center">
<PlayArea
:player-card="currentTrick.playerCard"
:opponent-card="currentTrick.opponentCard"
:winner="currentTrick.winner"
:first-player="currentTrick.firstPlayer"
/>
</div>
<!-- Deck Area -->
<div class="col-start-3 col-end-4 row-start-3 flex items-center justify-center">
<DeckArea
ref="deckRef"
:trump-card="trumpCard"
:cards-remaining="cardsRemaining"
:is-empty="cardsRemaining === 0"
:reveal-trump="revealTrump"
/>
</div>
<!-- Player Hand -->
<div class="col-start-2 col-end-3 row-start-4 flex justify-center">
<PlayerHand
ref="playerHandRef"
:cards="playerHand"
:face-down="false"
:max-cards="3"
:playable-cards="playableCards"
@card-clicked="handleCardClick"
/>
</div>
</div>
<!-- Flying Cards Container -->
<FlyingCard
v-for="flyingCard in flyingCards"
:key="flyingCard.id"
:card="flyingCard.card"
:start-position="flyingCard.startPosition"
:end-position="flyingCard.endPosition"
:duration="500"
:face-down="flyingCard.faceDown"
:delay="flyingCard.delay"
@complete="onFlyingCardComplete(flyingCard)"
/>
<!-- Game Over Modal -->
<GameOver
:is-visible="gameOverVisible"
:winner="gameOverWinner"
:player-score="playerScore"
:opponent-score="opponentScore"
:stats="gameOverStats"
@close="gameOverVisible = false"
@play-again="resetGame"
/>
</div>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
import GameBoard from '@/components/game/GameBoard.vue'
import ScoreDisplay from '@/components/game/ScoreDisplay.vue'
import PlayerHand from '@/components/game/PlayerHand.vue'
import PlayArea from '@/components/game/PlayArea.vue'
import DeckArea from '@/components/game/DeckArea.vue'
import FlyingCard from '@/components/game/FlyingCard.vue'
import GameOver from '@/components/game/GameOver.vue'
// Component refs
const playerHandRef = ref(null)
const opponentHandRef = ref(null)
const deckRef = ref(null)
// Game state
const playerHand = ref([])
const opponentHand = ref([])
const playerScore = ref(0)
const opponentScore = ref(0)
const currentTurn = ref('player')
const cardsRemaining = ref(40)
const isDealing = ref(false)
const revealTrump = ref(false)
// Trump card
const trumpCard = ref({ suit: 'o', rank: 7 })
// Current trick
const currentTrick = ref({
playerCard: null,
opponentCard: null,
winner: null,
firstPlayer: null,
})
// Flying cards for dealing animation
const flyingCards = ref([])
let flyingCardIdCounter = 0
// Game Over
const gameOverVisible = ref(false)
const gameOverWinner = ref('player')
const gameOverStats = ref({
playerTricks: 0,
opponentTricks: 0,
})
// Sample deck
const sampleCards = [
{ suit: 'c', rank: 1 },
{ suit: 'c', rank: 2 },
{ suit: 'c', rank: 3 },
{ suit: 'c', rank: 4 },
{ suit: 'c', rank: 5 },
{ suit: 'c', rank: 6 },
{ suit: 'c', rank: 7 },
{ suit: 'c', rank: 11 },
{ suit: 'c', rank: 12 },
{ suit: 'c', rank: 13 },
{ suit: 'e', rank: 1 },
{ suit: 'e', rank: 2 },
{ suit: 'e', rank: 3 },
{ suit: 'e', rank: 4 },
{ suit: 'e', rank: 5 },
{ suit: 'e', rank: 6 },
{ suit: 'e', rank: 7 },
{ suit: 'e', rank: 11 },
{ suit: 'e', rank: 12 },
{ suit: 'e', rank: 13 },
{ suit: 'o', rank: 1 },
{ suit: 'o', rank: 2 },
{ suit: 'o', rank: 3 },
{ suit: 'o', rank: 4 },
{ suit: 'o', rank: 5 },
{ suit: 'o', rank: 6 },
{ suit: 'p', rank: 1 },
{ suit: 'p', rank: 2 },
{ suit: 'p', rank: 3 },
{ suit: 'p', rank: 4 },
{ suit: 'p', rank: 5 },
{ suit: 'p', rank: 6 },
{ suit: 'p', rank: 7 },
{ suit: 'p', rank: 11 },
{ suit: 'p', rank: 12 },
{ suit: 'p', rank: 13 },
]
// Playable cards (all cards in hand for testing)
const playableCards = computed(() => {
return playerHand.value.map((_, index) => index)
})
// Get random card
const getRandomCard = () => {
return sampleCards[Math.floor(Math.random() * sampleCards.length)]
}
// Get element position
const getElementPosition = (el) => {
if (!el) return { x: 0, y: 0 }
const rect = el.$el?.getBoundingClientRect() || el.getBoundingClientRect()
return {
x: rect.left + rect.width / 2 - 64,
y: rect.top + rect.height / 2 - 89,
}
}
// Deal a single card
const dealSingleCard = async (isPlayer) => {
if (cardsRemaining.value === 0) return
await nextTick()
const startPos = getElementPosition(deckRef.value)
const endPos = getElementPosition(isPlayer ? playerHandRef.value : opponentHandRef.value)
const card = getRandomCard()
const flyingCard = {
id: flyingCardIdCounter++,
card,
startPosition: startPos,
endPosition: endPos,
faceDown: !isPlayer,
delay: 0,
recipient: isPlayer ? 'player' : 'opponent',
}
flyingCards.value.push(flyingCard)
cardsRemaining.value--
}
// Flying card complete
const onFlyingCardComplete = (flyingCard) => {
if (flyingCard.recipient === 'player') {
playerHand.value.push(flyingCard.card)
} else {
opponentHand.value.push(flyingCard.card)
}
flyingCards.value = flyingCards.value.filter((fc) => fc.id !== flyingCard.id)
}
// Deal initial cards
const dealInitialCards = async () => {
if (isDealing.value) return
isDealing.value = true
// Reveal trump at start
revealTrump.value = true
await new Promise((resolve) => setTimeout(resolve, 1000))
revealTrump.value = false
// Deal 6 cards (3 to each player)
for (let i = 0; i < 6; i++) {
if (cardsRemaining.value === 0) break
const isPlayer = i % 2 === 0
await dealSingleCard(isPlayer)
await new Promise((resolve) => setTimeout(resolve, 700))
}
isDealing.value = false
}
// Handle card click from player hand
const handleCardClick = ({ card, index }) => {
if (currentTurn.value !== 'player') return
if (currentTrick.value.playerCard) return
// Set first player if no cards played yet
if (!currentTrick.value.opponentCard) {
currentTrick.value.firstPlayer = 'player'
}
// Play the card
currentTrick.value.playerCard = card
playerHand.value.splice(index, 1)
// Switch turn
currentTurn.value = 'opponent'
// Auto-play opponent card after delay
setTimeout(() => {
if (opponentHand.value.length > 0 && !currentTrick.value.opponentCard) {
playOpponentCard()
}
}, 1000)
}
// Play opponent card
const playOpponentCard = () => {
if (opponentHand.value.length === 0) return
// Set first player if no cards played yet
if (!currentTrick.value.playerCard) {
currentTrick.value.firstPlayer = 'opponent'
}
// Play random card
const randomIndex = Math.floor(Math.random() * opponentHand.value.length)
const card = opponentHand.value[randomIndex]
currentTrick.value.opponentCard = card
opponentHand.value.splice(randomIndex, 1)
// Switch turn
currentTurn.value = 'player'
}
// Play random cards from both players
const playRandomCards = () => {
if (playerHand.value.length === 0) return
// Player plays first
currentTrick.value.firstPlayer = 'player'
const playerCard = playerHand.value[0]
currentTrick.value.playerCard = playerCard
playerHand.value.shift()
// Opponent plays after delay
setTimeout(() => {
if (opponentHand.value.length > 0) {
const opponentCard = opponentHand.value[0]
currentTrick.value.opponentCard = opponentCard
opponentHand.value.shift()
}
}, 600)
}
// Collect trick
const collectTrick = async () => {
if (!currentTrick.value.playerCard || !currentTrick.value.opponentCard) return
// Randomly determine winner
const winner = Math.random() > 0.5 ? 'player' : 'opponent'
currentTrick.value.winner = winner
// Wait to show winner glow
await new Promise((resolve) => setTimeout(resolve, 1500))
// Update score (random points)
const points = Math.floor(Math.random() * 15) + 5
if (winner === 'player') {
playerScore.value += points
gameOverStats.value.playerTricks++
} else {
opponentScore.value += points
gameOverStats.value.opponentTricks++
}
// Clear cards (triggers collection animation)
currentTrick.value.playerCard = null
currentTrick.value.opponentCard = null
// Reset after animation
await new Promise((resolve) => setTimeout(resolve, 600))
currentTrick.value.winner = null
currentTrick.value.firstPlayer = null
// Deal new cards if deck has cards
if (cardsRemaining.value >= 2 && playerHand.value.length < 3) {
await new Promise((resolve) => setTimeout(resolve, 500))
await dealSingleCard(winner === 'player')
await new Promise((resolve) => setTimeout(resolve, 700))
await dealSingleCard(winner === 'opponent')
}
// Switch turn to winner
currentTurn.value = winner
}
// Show game over
const showGameOver = () => {
// Make player win for demo
if (playerScore.value <= opponentScore.value) {
playerScore.value = opponentScore.value + 10
}
gameOverWinner.value =
playerScore.value > opponentScore.value
? 'player'
: opponentScore.value > playerScore.value
? 'opponent'
: 'draw'
gameOverVisible.value = true
}
// Reset game
const resetGame = () => {
playerHand.value = []
opponentHand.value = []
playerScore.value = 0
opponentScore.value = 0
currentTurn.value = 'player'
cardsRemaining.value = 40
currentTrick.value = {
playerCard: null,
opponentCard: null,
winner: null,
firstPlayer: null,
}
flyingCards.value = []
gameOverVisible.value = false
gameOverStats.value = {
playerTricks: 0,
opponentTricks: 0,
}
revealTrump.value = false
}
</script>
+37 -82
View File
@@ -603,65 +603,6 @@
v-else-if="activeTab === 'matches'" v-else-if="activeTab === 'matches'"
class="animate-in fade-in slide-in-from-bottom-2 duration-300" class="animate-in fade-in slide-in-from-bottom-2 duration-300"
> >
<div class="p-4 bg-gray-50 border-b border-gray-300 flex flex-wrap gap-4 items-end">
<div class="flex flex-col gap-1">
<label class="text-[10px] font-bold uppercase text-gray-400 tracking-widest"
>From</label
>
<input
type="date"
v-model="filters.match_date_from"
@change="resetAndFetch"
class="bg-white border border-gray-300 p-2 text-xs font-bold uppercase outline-none focus:border-black"
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-[10px] font-bold uppercase text-gray-400 tracking-widest"
>To</label
>
<input
type="date"
v-model="filters.match_date_to"
@change="resetAndFetch"
class="bg-white border border-gray-300 p-2 text-xs font-bold uppercase outline-none focus:border-black"
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-[10px] font-bold uppercase text-gray-400 tracking-widest"
>Variant</label
>
<select
v-model="filters.match_type"
@change="resetAndFetch"
class="bg-white border border-gray-300 p-2 text-xs font-bold uppercase outline-none focus:border-black min-w-[100px]"
>
<option :value="null">All</option>
<option value="3">Bisca 3</option>
<option value="9">Bisca 9</option>
</select>
</div>
<div class="flex flex-col gap-1">
<label class="text-[10px] font-bold uppercase text-gray-400 tracking-widest"
>Outcome</label
>
<select
v-model="filters.match_outcome"
@change="resetAndFetch"
class="bg-white border border-gray-300 p-2 text-xs font-bold uppercase outline-none focus:border-black min-w-[100px]"
>
<option :value="null">All</option>
<option value="win">Win</option>
<option value="draw">Draw</option>
</select>
</div>
<button
@click="clearMatchFilters"
class="text-[10px] font-bold uppercase underline hover:text-gray-600 mb-2 ml-auto"
>
Clear
</button>
</div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<div <div
id="table-scroll-container" id="table-scroll-container"
@@ -670,20 +611,34 @@
<table class="w-full text-left border-collapse sticky-header"> <table class="w-full text-left border-collapse sticky-header">
<thead class="bg-gray-50 border-b border-gray-300"> <thead class="bg-gray-50 border-b border-gray-300">
<tr> <tr>
<th class="p-4 text-[10px] font-bold uppercase tracking-widest text-gray-500"> <th
class="p-4 text-[10px] font-bold uppercase tracking-widest text-gray-500"
@click="handleSort('date')"
>
Match Date Match Date
<span>{{ filters.match_sort_direction === 'desc' ? ' ↓' : ' ↑' }}</span>
</th> </th>
<th class="p-4 text-[10px] font-bold uppercase tracking-widest text-gray-500"> <th class="p-4 text-[10px] font-bold uppercase tracking-widest text-gray-500">
Matchup (P1 vs P2) Matchup (P1 vs P2)
</th> </th>
<th class="p-4 text-[10px] font-bold uppercase tracking-widest text-gray-500"> <th
class="p-4 text-[10px] font-bold uppercase tracking-widest text-gray-500"
@click="handleSort('type')"
>
Variant Variant
<span v-if="filters.match_type"> (Bisca {{ filters.match_type }})</span>
<span v-else> </span>
</th> </th>
<th class="p-4 text-[10px] font-bold uppercase tracking-widest text-gray-500"> <th class="p-4 text-[10px] font-bold uppercase tracking-widest text-gray-500">
Outcome Outcome
</th> </th>
<th class="p-4 text-[10px] font-bold uppercase tracking-widest text-gray-500"> <th
class="p-4 text-[10px] font-bold uppercase tracking-widest text-gray-500"
@click="handleSort('pot')"
>
Total Pot Total Pot
<span v-if="filters.pot"> ({{ filters.pot }})</span>
<span v-else> </span>
</th> </th>
</tr> </tr>
</thead> </thead>
@@ -857,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)
@@ -867,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({
@@ -877,11 +832,12 @@ 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: '',
match_date_to: '', match_date_to: '',
match_sort_direction: null,
pot: null,
}) })
watch(activeTab, async () => { watch(activeTab, async () => {
@@ -895,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(() => {
@@ -987,19 +943,6 @@ const filteredUsers = computed(() => {
return users.value.filter((user) => user.id !== useAuthStore().currentUserID) return users.value.filter((user) => user.id !== useAuthStore().currentUserID)
}) })
const resetAndFetch = async () => {
resetPagination()
await fetchData()
}
const clearMatchFilters = async () => {
filters.match_type = null
filters.match_outcome = null
filters.match_date_from = ''
filters.match_date_to = ''
await resetAndFetch()
}
const fetchData = async () => { const fetchData = async () => {
if (isLoading.value) return if (isLoading.value) return
const currentTab = activeTab.value const currentTab = activeTab.value
@@ -1039,10 +982,9 @@ const fetchData = async () => {
} else if (currentTab === 'matches') { } else if (currentTab === 'matches') {
const params = { const params = {
page: pagination[currentTab].page, page: pagination[currentTab].page,
sort_direction: filters.match_sort_direction,
type: filters.match_type, type: filters.match_type,
outcome: filters.match_outcome, pot: filters.pot,
date_from: filters.match_date_from || undefined,
date_to: filters.match_date_to || undefined,
} }
response = await apiStore.getAdminMatches(params) response = await apiStore.getAdminMatches(params)
matches.value.push(...response.data.data) matches.value.push(...response.data.data)
@@ -1080,6 +1022,19 @@ const handleSort = async (column) => {
else if (filters.game_type === '9') filters.game_type = '3' else if (filters.game_type === '9') filters.game_type = '3'
else filters.game_type = null else filters.game_type = null
} }
} else if (activeTab.value === 'matches') {
if (column === 'type') {
filters.match_type =
filters.match_type === null ? '9' : filters.match_type === '9' ? '3' : null
} else if (column === 'date') {
filters.pot = null
filters.match_sort_direction = filters.match_sort_direction === 'desc' ? 'asc' : 'desc'
} else if (column === 'pot') {
filters.match_sort_direction = null
if (filters.pot === null) filters.pot = 'desc'
else if (filters.pot === 'desc') filters.pot = 'asc'
else filters.pot = 'desc'
}
} }
resetPagination() resetPagination()
+182 -66
View File
@@ -9,20 +9,36 @@
</button> </button>
</div> </div>
<div 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
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" fill="none" stroke="currentColor"> <svg
<circle cx="12" cy="12" r="10" stroke-width="2"/> class="w-6 h-6"
<polyline points="12 6 12 12 16 14" stroke-width="2"/> :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" />
<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>
@@ -33,38 +49,43 @@
<GameBoard <GameBoard
:trump-card="trumpCard" :trump-card="trumpCard"
:cards-remaining="deckCount + (trumpCard ? 1 : 0)" :cards-remaining="deckCount + (trumpCard ? 1 : 0)"
:player-hand="visibleHand" :player-hand="visibleHand"
:opponent-hand="opponentHand" :opponent-hand="opponentHand"
:player-score="playerScore" :player-score="playerScore"
:opponent-score="opponentScore" :opponent-score="opponentScore"
:player-name="myDisplayName" :player-name="myDisplayName"
:opponent-name="opponentDisplayName" :opponent-name="opponentDisplayName"
:current-turn="currentTurnLabel" :current-turn="currentTurnLabel"
:current-trick="displayedTrick" :current-trick="displayedTrick"
:is-my-turn="isMyTurn" :is-my-turn="isMyTurn"
@play-card="handlePlayCard" @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 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"> <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"
>
Match ID: <span class="font-bold text-white">{{ matchState.id }}</span>
</div>
<button <button
@click="handleCloseModal" @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" 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"
@@ -91,7 +112,7 @@
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted, watch, inject } from 'vue' import { ref, computed, onMounted, onUnmounted, watch, inject } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
import { useSocketStore } from '@/stores/socket' import { useSocketStore } from '@/stores/socket'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
@@ -115,26 +136,26 @@ const timeLeft = ref(20)
const timerInterval = ref(null) const timerInterval = ref(null)
const hasConnectedOnce = ref(false) const hasConnectedOnce = ref(false)
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(() => {
@@ -145,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
@@ -175,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 }
@@ -206,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)
@@ -230,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
@@ -247,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 {
@@ -271,41 +309,117 @@ 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)
} }
// --- FIX: SURRENDER LOGIC --- const handleBeforeUnload = (event) => {
if (shouldShowBoard.value && !gameOver.value) {
event.preventDefault()
event.returnValue = ''
}
}
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.')) {
// Send event to server. Do NOT disconnect locally yet.
// Wait for the server to send "Game Over" event back.
wsStore.surrender(gameId.value) wsStore.surrender(gameId.value)
} }
} }
const handleCloseModal = () => { const handleCloseModal = async () => {
if (!shouldShowBoard.value && amIHost.value && !gameOver.value) {
try {
await axios.delete(`${API_BASE_URL}/games/${gameId.value}`)
console.log('Lobby cancelled and deleted.')
} catch (e) {
console.error('Failed to delete pending game:', e)
}
}
if (wsStore.socket) {
wsStore.socket.emit('notify_disconnect')
}
wsStore.disconnect() wsStore.disconnect()
router.push({ name: 'home' }) router.push({ name: 'home' })
} }
// --- LIFECYCLE ---
onBeforeRouteLeave(async (to, from, next) => {
if (!shouldShowBoard.value && !gameOver.value) {
if (amIHost.value) {
const confirmClose = window.confirm('This will cancel the lobby. Are you sure?')
if (confirmClose) {
try {
await axios.delete(`${API_BASE_URL}/games/${gameId.value}`)
} catch (e) {
console.error(e)
}
if (wsStore.socket) wsStore.socket.emit('notify_disconnect')
wsStore.disconnect()
next()
} else {
next(false)
}
return
} else {
if (wsStore.socket) wsStore.socket.emit('notify_disconnect')
wsStore.disconnect()
next()
return
}
}
if (gameOver.value) {
next()
return
}
const answer = window.confirm(
"If you leave, you will be register has 'losing' or 'surrendered', Are you sure you want to leave?",
)
if (answer) {
try {
await wsStore.surrender(gameId.value)
} catch (e) {}
if (wsStore.socket) {
wsStore.socket.emit('notify_disconnect', () => {
wsStore.disconnect()
next()
})
setTimeout(() => {
if (wsStore.connected) {
wsStore.disconnect()
next()
}
}, 500)
return
}
wsStore.disconnect()
next()
} else {
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 })
}) })
@@ -317,6 +431,8 @@ onMounted(async () => {
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
if (timerInterval.value) clearInterval(timerInterval.value) if (timerInterval.value) clearInterval(timerInterval.value)
wsStore.disconnect() wsStore.disconnect()
}) })
@@ -0,0 +1,373 @@
<script setup>
import { ref, onMounted, computed, inject, onUnmounted, watch } from 'vue'
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useSocketStore } from '@/stores/socket'
import { Coins, Trophy, Frown } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import GameBoard from '@/components/game/GameBoard.vue'
import axios from 'axios'
import { toast } from 'vue-sonner'
const API_BASE_URL = inject('apiBaseURL')
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const wsStore = useSocketStore()
const isCancelling = ref(false)
const matchState = ref({
id: route.params.id,
wager: 0,
myMarks: 0,
oppMarks: 0,
currentGameId: null,
player1: null,
player2: null,
status: 'Pending'
})
const timeLeft = ref(20)
const timerInterval = ref(null)
const showSurrenderModal = ref(false)
const showTrickResult = ref(false)
const lastTrickWinner = ref(null)
const showRoundModal = ref(false)
const roundData = ref({ winnerId: null, marksAdded: 0, myPoints: 0, oppPoints: 0, reason: '' })
const myUserId = computed(() => authStore.currentUser?.id)
const amIHost = computed(() => matchState.value.player1?.id === myUserId.value)
const isPending = computed(() => !matchState.value.player2 || matchState.value.player2.id <= 1)
const matchOver = computed(() => matchState.value.myMarks >= 4 || matchState.value.oppMarks >= 4)
const iWonMatch = computed(() => matchState.value.myMarks > matchState.value.oppMarks)
const isMyTurn = computed(() => String(wsStore.gameState?.currentTurn) === String(myUserId.value))
const currentTurnLabel = computed(() => isMyTurn.value ? 'player' : 'opponent')
const myDisplayName = computed(() => amIHost.value ? matchState.value.player1?.nickname : matchState.value.player2?.nickname)
const opponentDisplayName = computed(() => amIHost.value ? matchState.value.player2?.nickname : matchState.value.player1?.nickname)
const opponentHand = computed(() => {
const count = wsStore.gameState?.opponent?.cardCount || 0
return Array.from({ length: count }, (_, i) => ({ id: `opp-${i}`, suit: 'back', rank: 0 }))
})
watch(() => wsStore.gameState?.currentTurn, () => { startTimer() }, { deep: true })
const startTimer = () => {
if (timerInterval.value) clearInterval(timerInterval.value);
timeLeft.value = 20;
if (!wsStore.gameState) return;
timerInterval.value = setInterval(() => {
if (timeLeft.value > 0) timeLeft.value--;
else clearInterval(timerInterval.value);
}, 1000);
}
const handlePlayCard = (card) => {
if (wsStore.socket) wsStore.socket.emit('game:play-card', { matchId: matchState.value.id, cardId: card.id })
}
const openSurrenderModal = () => { showSurrenderModal.value = true }
const confirmSurrenderRound = () => {
showSurrenderModal.value = false;
if (wsStore.socket) wsStore.socket.emit('game:surrender', { matchId: matchState.value.id });
}
const confirmSurrenderMatch = () => {
showSurrenderModal.value = false;
if (wsStore.socket) wsStore.socket.emit('match:surrender', { matchId: matchState.value.id });
}
const closeRoundModal = () => { showRoundModal.value = false; }
const updateLocalState = (data) => {
if (!data) return
if (data.player1) matchState.value.player1 = data.player1
if (data.player2) matchState.value.player2 = data.player2
if (data.stake) matchState.value.wager = data.stake
matchState.value.status = data.status
if (data.marks) {
const myId = authStore.currentUser?.id;
const oppId = amIHost.value ? matchState.value.player2?.id : matchState.value.player1?.id;
matchState.value.myMarks = data.marks[myId] || 0;
matchState.value.oppMarks = data.marks[oppId] || 0;
}
if (data.game) {
matchState.value.currentGameId = data.game.id;
wsStore.gameState = data.game;
} else if (data.status === 'Playing') {
matchState.value.currentGameId = null;
wsStore.gameState = null;
}
}
const cancelMatch = async () => {
if (isCancelling.value) return;
isCancelling.value = true;
try {
await axios.delete(`${API_BASE_URL}/matches/${matchState.value.id}`)
router.push({ name: 'home' })
} catch (e) {
if (e.response && e.response.status === 404) {
router.push({ name: 'home' })
} else {
isCancelling.value = false;
alert(e.message)
}
}
}
const leaveMatch = () => router.push({ name: 'home' })
const handleBeforeUnload = (e) => {
if (matchState.value.status === 'Playing' && !matchOver.value) {
const msg = "If you leave now, you will lose the match and your wager.";
e.returnValue = msg;
return msg;
}
}
onBeforeRouteLeave((to, from, next) => {
if (isCancelling.value) {
return next();
}
if (matchState.value.status === 'Playing' && !matchOver.value) {
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 });
next();
} else {
next(false);
}
} else if (isPending.value && amIHost.value) {
if (confirm("Cancel this lobby?")) {
isCancelling.value = true;
cancelMatch().then(() => next());
} else {
next(false);
}
} else {
next();
}
})
onMounted(async () => {
window.addEventListener('beforeunload', handleBeforeUnload)
try {
const res = await axios.get(`${API_BASE_URL}/matches/${matchState.value.id}`)
const d = res.data.data || res.data
matchState.value.player1 = d.player1
matchState.value.player2 = d.player2
matchState.value.wager = d.stake
} catch (e) { }
wsStore.connect()
if (wsStore.socket) {
wsStore.socket.emit('match:enter', { matchId: matchState.value.id })
wsStore.socket.on('match:update', updateLocalState)
wsStore.socket.on('match:new-round', (data) => {
updateLocalState(data);
toast.info("New round started!");
})
wsStore.socket.on('match:ended', updateLocalState)
wsStore.socket.on('game:update', (state) => { wsStore.gameState = state })
wsStore.socket.on('game:trick-end', (result) => {
lastTrickWinner.value = result.winnerId;
showTrickResult.value = true;
setTimeout(() => { showTrickResult.value = false }, 2000);
});
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 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 = {
winnerId: data.winnerId,
marksAdded: data.marksAdded,
myPoints: myPoints,
oppPoints: oppPoints,
reason: data.reason
};
showRoundModal.value = true;
});
}
})
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
if (wsStore.socket) {
wsStore.socket.off('match:update')
wsStore.socket.off('match:new-round')
wsStore.socket.off('game:trick-end')
wsStore.socket.off('match:round-ended')
}
wsStore.disconnect()
})
</script>
<template>
<div class="relative min-h-screen bg-green-900 text-white overflow-hidden">
<div class="fixed top-0 left-0 right-0 z-40 bg-green-950/90 backdrop-blur border-b border-green-800 p-2 shadow-xl">
<div class="max-w-4xl mx-auto flex items-center justify-between">
<div class="flex flex-col items-start">
<div class="flex items-center gap-2">
<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">
<Coins class="w-3 h-3 mr-1" /> {{ matchState.wager }}
</span>
</div>
<div class="flex gap-1 mt-1">
<div v-for="i in 4" :key="i" class="w-3 h-3 rounded-full border border-green-400 bg-green-900"
:class="{ '!bg-green-400 shadow-[0_0_10px_rgba(74,222,128,0.5)]': matchState.myMarks >= i }"></div>
</div>
</div>
<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-2xl font-black font-mono text-white drop-shadow-md">{{ matchState.myMarks }} - {{
matchState.oppMarks }}</span>
</div>
<div class="flex flex-col items-end">
<span class="font-bold text-lg text-white drop-shadow-md">{{ opponentDisplayName }}</span>
<div class="flex gap-1 mt-1">
<div v-for="i in 4" :key="i" class="w-3 h-3 rounded-full border border-red-400 bg-green-900"
:class="{ '!bg-red-500 shadow-[0_0_10px_rgba(239,68,68,0.5)]': matchState.oppMarks >= i }"></div>
</div>
</div>
</div>
</div>
<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">
<span>🏳</span> Surrender
</button>
</div>
<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="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">
<circle cx="12" cy="12" r="10" stroke-width="2" />
<polyline points="12 6 12 12 16 14" stroke-width="2" />
</svg>
<span :class="['text-2xl font-mono font-bold', timeLeft <= 5 ? 'text-red-500' : 'text-white']">{{ timeLeft
}}</span>
</div>
</div>
</div>
<div class="pt-20 h-screen">
<GameBoard v-if="wsStore.gameState && !matchOver" :game-id="matchState.currentGameId" @play-card="handlePlayCard"
:trump-card="wsStore.gameState?.trumpCard" :cards-remaining="wsStore.gameState?.deckCount"
: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
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="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>
<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>
</div>
</div>
<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>
<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">
<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" />
<h1 class="text-5xl font-black uppercase mb-2" :class="iWonMatch ? 'text-yellow-400' : 'text-gray-300'">{{
iWonMatch ? 'Victory!' : 'Defeat' }}</h1>
<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 v-if="showRoundModal"
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md">
<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">
<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="mb-8">
<p class="text-lg text-white" v-if="roundData.winnerId == myUserId">
You won <span class="text-yellow-400 font-bold">+{{ roundData.marksAdded }} marks</span>!
</p>
<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 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">
<h3 class="text-xl font-bold text-white mb-6">Surrender Options</h3>
<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">
<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>
</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">
<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>
</button>
</div>
<button @click="showSurrenderModal = false"
class="mt-6 text-gray-400 hover:text-white text-sm underline">Cancel</button>
</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">
<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">
<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>
</div>
</div>
</Transition>
</div>
</template>
@@ -17,8 +17,11 @@
:opponent-hand="store.opponentHand" :opponent-hand="store.opponentHand"
:player-score="store.playerScore" :player-score="store.playerScore"
:opponent-score="store.opponentScore" :opponent-score="store.opponentScore"
:current-turn="store.currentTurn" :current-turn="'player'"
:is-my-turn="true"
:current-trick="store.table" :current-trick="store.table"
:player-name="'You'"
:opponent-name="'CPU'"
@play-card="handlePlayCard" @play-card="handlePlayCard"
/> />
</div> </div>
@@ -41,9 +44,9 @@
</template> </template>
<script setup> <script setup>
import { onMounted, onUnmounted, onBeforeMount } from 'vue' import { onMounted, onUnmounted, onBeforeMount, watch } from 'vue'
import { useRouter, onBeforeRouteLeave } from 'vue-router' import { useRouter, onBeforeRouteLeave } from 'vue-router'
import { toast } from 'vue-sonner' // Import Toast for feedback import { toast } from 'vue-sonner'
import { useBiscaStore } from '@/stores/bisca' import { useBiscaStore } from '@/stores/bisca'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import GameBoard from '@/components/game/GameBoard.vue' import GameBoard from '@/components/game/GameBoard.vue'
@@ -60,6 +63,14 @@ const store = useBiscaStore()
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
watch(
() => store.playerHand,
(newHand) => {
console.log('Player hand updated:', newHand)
},
{ deep: true },
)
onBeforeMount(() => { onBeforeMount(() => {
store.isGameOver = false store.isGameOver = false
}) })
@@ -67,6 +78,14 @@ onBeforeMount(() => {
onMounted(() => { onMounted(() => {
store.startGame(props.gameType) store.startGame(props.gameType)
window.addEventListener('beforeunload', handleBrowserUnload) window.addEventListener('beforeunload', handleBrowserUnload)
setTimeout(() => {
console.log('Game state after init:', {
playerHand: store.playerHand,
currentTurn: store.currentTurn,
isGameRunning: store.isGameRunning,
})
}, 500)
}) })
onUnmounted(() => { onUnmounted(() => {
+184 -175
View File
@@ -1,7 +1,10 @@
<script setup> <script setup>
import { User, Trophy } from 'lucide-vue-next' import { User, Trophy, Coins } from 'lucide-vue-next'
import { ref, onMounted, nextTick, inject } from 'vue' 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 { Label } from '@/components/ui/label'
import { watch } from 'vue';
import { import {
Card, Card,
CardContent, CardContent,
@@ -12,7 +15,9 @@ import {
import { useAPIStore } from '@/stores/api' import { useAPIStore } from '@/stores/api'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user'
import axios from 'axios' import axios from 'axios'
import { toast } from 'vue-sonner'
const API_BASE_URL = inject('apiBaseURL') const API_BASE_URL = inject('apiBaseURL')
const apiStore = useAPIStore() const apiStore = useAPIStore()
@@ -29,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)
@@ -44,6 +46,15 @@ const lbObserver = ref(null)
const lbMorePages = ref(true) const lbMorePages = ref(true)
const publicStats = ref(null) const publicStats = ref(null)
const isLoggedIn = useAuthStore().isLoggedIn const isLoggedIn = useAuthStore().isLoggedIn
const isBestOfThree = ref(false)
const wagerAmount = ref(0)
const authStore = useAuthStore()
const userStore = useUserStore()
const userLoggedIn = computed(() => authStore.isLoggedIn)
const userCoins = ref(0)
const hostLoading = ref(false)
const gHasMore = ref(true);
const mHasMore = ref(true);
const setupMatchesObserver = () => { const setupMatchesObserver = () => {
if (mObserver.value) mObserver.value.disconnect(); if (mObserver.value) mObserver.value.disconnect();
@@ -74,38 +85,40 @@ const setupGamesObserver = () => {
} }
const getPendingGames = async (mode = selectedMode.value) => { const getPendingGames = async (mode = selectedMode.value) => {
if (gLoading.value) return; if (gLoading.value || !gHasMore.value) return;
gLoading.value = true; gLoading.value = true;
try { try {
const response = await axios.get(`${API_BASE_URL}/games/open`, { const response = await axios.get(`${API_BASE_URL}/games/open`, {
params: { params: { type: mode, page: gPage.value }
type: mode,
page: gPage.value
}
}); });
const newGames = response.data.data; const newGames = response.data.data;
if (newGames.length === 0) {
gHasMore.value = false;
} else {
openGames.value = [...openGames.value, ...newGames]; openGames.value = [...openGames.value, ...newGames];
gPage.value++;
}
} finally { } finally {
gLoading.value = false; gLoading.value = false;
} }
} }
const getPendingMatches = async (mode = selectedMode.value) => { const getPendingMatches = async (mode = selectedMode.value) => {
if (mLoading.value) return; if (mLoading.value || !mHasMore.value) return;
mLoading.value = true; mLoading.value = true;
try { try {
const response = await axios.get(`${API_BASE_URL}/matches/open`, { const response = await axios.get(`${API_BASE_URL}/matches/open`, {
params: { params: { type: mode, page: mPage.value }
type: mode,
page: mPage.value
}
}); });
const newMatches = response.data.data; const newMatches = response.data.data;
if (newMatches.length === 0) {
mHasMore.value = false;
} else {
openMatches.value = [...openMatches.value, ...newMatches]; openMatches.value = [...openMatches.value, ...newMatches];
mPage.value++;
}
} finally { } finally {
mLoading.value = false; mLoading.value = false;
} }
@@ -135,8 +148,8 @@ const openStats = async () => {
const getAvatarUrl = (filename) => { const getAvatarUrl = (filename) => {
return filename return filename
? `${API_BASE_URL.replace('/api/v1', '').replace('v1/')}/storage/photos_avatars/${filename}` ? `${API_BASE_URL.replace('/api/v1', '').replace('v1/', '')}/storage/photos_avatars/${filename}`
: `${API_BASE_URL.replace('/api/v1', '').replace('v1/')}/storage/photos_avatars/anonymous.png`; : `${API_BASE_URL.replace('/api/v1', '').replace('v1/', '')}/storage/photos_avatars/anonymous.png`;
} }
const fetchLeaderboard = async () => { const fetchLeaderboard = async () => {
@@ -145,11 +158,9 @@ const fetchLeaderboard = async () => {
try { try {
const response = await apiStore.getLeaderboard({ page: lbPage.value }); const response = await apiStore.getLeaderboard({ page: lbPage.value });
const newEntries = response.data.data; const newEntries = response.data.data;
if (newEntries.length < 10) { if (newEntries.length < 10) {
lbMorePages.value = false; lbMorePages.value = false;
} }
leaderboardEntries.value = [...leaderboardEntries.value, ...newEntries]; leaderboardEntries.value = [...leaderboardEntries.value, ...newEntries];
lbPage.value++; lbPage.value++;
} finally { } finally {
@@ -173,7 +184,6 @@ const setupLeaderboardObserver = () => {
fetchLeaderboard(); fetchLeaderboard();
} }
}, { threshold: 0.1 }); }, { threshold: 0.1 });
if (lbObserverTarget.value) { if (lbObserverTarget.value) {
lbObserver.value.observe(lbObserverTarget.value); lbObserver.value.observe(lbObserverTarget.value);
} }
@@ -183,9 +193,11 @@ const clickMode = async (mode) => {
if (selectedMode.value === mode) return; if (selectedMode.value === mode) return;
selectedMode.value = mode; selectedMode.value = mode;
openGames.value = []; openGames.value = [];
gPage.value = 1;
openMatches.value = []; openMatches.value = [];
gPage.value = 1;
mPage.value = 1; mPage.value = 1;
gHasMore.value = true;
mHasMore.value = true;
await Promise.all([getPendingGames(), getPendingMatches()]); await Promise.all([getPendingGames(), getPendingMatches()]);
setupGamesObserver(); setupGamesObserver();
setupMatchesObserver(); setupMatchesObserver();
@@ -195,10 +207,99 @@ const startSingle = () => {
router.push({ name: 'bisca' + selectedMode.value }); router.push({ name: 'bisca' + selectedMode.value });
} }
const toggleReady = () => { const joinMatch = async (match) => {
isReady.value = !isReady.value const cost = match.stake || match.wager || 0;
if (cost > userCoins.value) {
toast.error(`Insufficient funds! You need ${cost} coins.`);
return;
}
if (!confirm(`Join match for ${cost} coins?`)) {
return;
}
try {
await axios.post(`${API_BASE_URL}/matches/${match.id}/join`)
router.push({
name: 'multiplayer-match',
params: { id: match.id },
query: { type: selectedMode.value }
})
} catch (error) {
toast.error(error.response?.data?.message || 'Failed to join match');
}
} }
const hostGame = async () => {
if (isBestOfThree.value) {
if (wagerAmount.value <= 0) {
toast.error("Stake must be at least 1 coin.");
return;
}
if (wagerAmount.value > userCoins.value) {
toast.error("Insufficient coins!");
return;
}
}
hostLoading.value = true;
const endpoint = isBestOfThree.value ? '/matches/host' : '/games/host'
const payload = isBestOfThree.value
? { type: parseInt(selectedMode.value), stake: parseInt(wagerAmount.value) }
: { type: parseInt(selectedMode.value) }
try {
const response = await axios.post(`${API_BASE_URL}${endpoint}`, payload)
const resultId = response.data.match?.id || response.data.id || response.data.data?.id;
if (!resultId) throw new Error("Missing ID");
showHostModal.value = false;
router.push({
name: isBestOfThree.value ? 'multiplayer-match' : 'multiplayer-game',
params: { id: resultId },
query: { type: selectedMode.value }
})
} catch (error) {
if (error.response?.data?.message) {
toast.error(error.response.data.message);
} else {
toast.error('Failed to host game');
}
} finally {
hostLoading.value = false;
}
}
const joinGame = async (game) => {
try {
await axios.post(`${API_BASE_URL}/games/${game.id}/join`)
router.push({
name: 'multiplayer-game',
params: { id: game.id },
query: { type: selectedMode.value }
})
} catch (error) {
toast.error(error.response?.data?.message || 'Failed to join game');
}
}
const openHostModal = (forMatch) => {
isBestOfThree.value = forMatch;
showHostModal.value = true;
wagerAmount.value = 0;
}
watch(() => userLoggedIn, async (isLoggedIn) => {
if (isLoggedIn) {
await userStore.fetchCoins()
userCoins.value = userStore.coins
} else {
userStore.coins = 0
}
}, { immediate: true })
onMounted(async () => { onMounted(async () => {
await fetchPublicStats(); await fetchPublicStats();
if (isLoggedIn) { if (isLoggedIn) {
@@ -208,57 +309,10 @@ onMounted(async () => {
setupMatchesObserver(); setupMatchesObserver();
} }
}) })
const hostGame = async () => {
showHostModal.value = true
try {
const response = await axios.post(`${API_BASE_URL}/games/host`, {
type: selectedMode.value
})
const gameId = response.data.id
console.log('Game hosted:', gameId)
router.push({
name: 'multiplayer-game',
params: { id: gameId },
query: { type: selectedMode.value }
})
} catch (error) {
console.error('Failed to host game:', error)
console.error('Error details:', error.response?.data)
showHostModal.value = false
alert('Failed to host game: ' + (error.response?.data?.message || error.message))
}
}
const joinGame = async (game) => {
selectedGame.value = game
try {
await axios.post(`${API_BASE_URL}/games/${game.id}/join`)
console.log('Joined game:', game.id)
router.push({
name: 'multiplayer-game',
params: { id: game.id },
query: { type: selectedMode.value }
})
} catch (error) {
console.error('Failed to join game:', error)
console.error('Error details:', error.response?.data)
alert('Failed to join game: ' + (error.response?.data?.message || error.message))
}
}
</script> </script>
<template> <template>
<div class="min-h-screen bg-cover bg-center transition-all duration-700"> <div class="min-h-screen bg-cover bg-center transition-all duration-700">
<div class="flex flex-col justify-center items-center gap-5 pt-10 pb-10 px-4"> <div class="flex flex-col justify-center items-center gap-5 pt-10 pb-10 px-4">
<Card v-if="publicStats" class="w-full max-w-2xl border-[#a855f7] bg-[#f8f0ff] shadow-sm"> <Card v-if="publicStats" class="w-full max-w-2xl border-[#a855f7] bg-[#f8f0ff] shadow-sm">
@@ -284,37 +338,24 @@ const joinGame = async (game) => {
<div class="relative w-full max-w-md bg-card rounded-lg border shadow-sm overflow-hidden"> <div class="relative w-full max-w-md bg-card rounded-lg border shadow-sm overflow-hidden">
<div class="relative h-14 flex items-center"> <div class="relative h-14 flex items-center">
<button @click="clickMode('9')" <button @click="clickMode('9')"
class="h-full flex items-center justify-center font-semibold transition-all duration-500 ease-in-out border-r last:border-r-0" class="h-full flex items-center justify-center font-semibold transition-all duration-500 ease-in-out border-r last:border-r-0"
:class="{ :class="{ 'w-2/3 z-10 text-purple-600 bg-purple-500/10': selectedMode === '9', 'w-1/3 z-0 text-muted-foreground/40': selectedMode !== '9' }">
'w-2/3 z-10 text-purple-600 dark:text-purple-400 bg-purple-500/10': selectedMode === '9',
'w-1/3 z-0 text-muted-foreground/40 bg-transparent': selectedMode !== '9'
}">
<span>Bisca de 9</span> <span>Bisca de 9</span>
</button> </button>
<button @click="clickMode('3')" <button @click="clickMode('3')"
class="h-full flex items-center justify-center font-semibold transition-all duration-500 ease-in-out" class="h-full flex items-center justify-center font-semibold transition-all duration-500 ease-in-out"
:class="{ :class="{ 'w-2/3 z-10 text-purple-600 bg-purple-500/10': selectedMode === '3', 'w-1/3 z-0 text-muted-foreground/40': selectedMode !== '3' }">
'w-2/3 z-10 text-purple-600 dark:text-purple-400 bg-purple-500/10': selectedMode === '3',
'w-1/3 z-0 text-muted-foreground/40 bg-transparent': selectedMode !== '3'
}">
<span>Bisca de 3</span> <span>Bisca de 3</span>
</button> </button>
</div> </div>
</div> </div>
<div class="flex flex-col items-center gap-5 w-[100vw]"> <div class="flex flex-col items-center gap-5 w-[100vw]">
<Card class="w-full max-w-2xl"> <Card class="w-full max-w-2xl">
<CardHeader> <CardHeader>
<CardTitle class="text-3xl font-bold text-center"> <CardTitle class="text-3xl font-bold text-center">Single Player</CardTitle>
Single Player <CardDescription class="text-center">Go against one of our bots!</CardDescription>
</CardTitle>
<CardDescription class="text-center">
Go against one of our bots!
</CardDescription>
</CardHeader> </CardHeader>
<CardContent class="space-y-6"> <CardContent class="space-y-6">
<div class="flex justify-center"> <div class="flex justify-center">
@@ -325,30 +366,29 @@ const joinGame = async (game) => {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card class="w-full max-w-2xl flex flex-col"> <Card class="w-full max-w-2xl flex flex-col">
<CardHeader> <CardHeader>
<CardTitle class="text-3xl font-bold text-center">Multi Player</CardTitle> <CardTitle class="text-3xl font-bold text-center">Multi Player</CardTitle>
<CardDescription class="text-center">Go one on one with another player!</CardDescription> <CardDescription class="text-center">Go one on one with another player!</CardDescription>
</CardHeader> </CardHeader>
<CardContent class="flex-1 flex flex-col space-y-6"> <CardContent class="flex-1 flex flex-col space-y-6">
<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 <label class="text-sm font-bold uppercase tracking-wider text-muted-foreground">Open Matches (Best of
Matches</label> 3)</label>
<Button v-if="isLoggedIn" @click="showHostModal()" size="sm" variant="outline" <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 transition-all duration-300 font-bold"> class="border-purple-500 text-purple-500 hover:bg-purple-500 hover:text-white font-bold">
Host Host
</Button> </Button>
</div> </div>
<div class="rounded-lg border bg-card text-card-foreground shadow-sm"> <div class="rounded-lg border bg-card text-card-foreground shadow-sm">
<div v-if="openGames.length === 0" <div v-if="openMatches.length === 0" class="p-6 text-center text-sm text-muted-foreground">
class="p-6 text-center text-sm text-muted-foreground"> No open matches.
No open macthes yet. Try hosting one!
</div> </div>
<div v-else class="max-h-[350px] overflow-y-auto custom-scrollbar p-1"> <div v-else class="max-h-[350px] overflow-y-auto custom-scrollbar p-1">
<div v-for="(match, index) in openMatches" :key="match.id" <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 <div
@@ -356,19 +396,20 @@ const joinGame = async (game) => {
{{ index + 1 }} {{ index + 1 }}
</div> </div>
<div> <div>
<div class="font-medium text-sm">Match ID: {{ match.id }}</div> <div class="font-medium text-sm flex items-center gap-2">
<div class="text-[10px] text-muted-foreground">{{ new Match #{{ match.id }}
Date(game.began_at).toLocaleDateString() }} {{ new <span
Date(game.began_at).toLocaleTimeString([], { class="flex items-center text-xs bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full">
hour: '2-digit', <Coins class="w-3 h-3 mr-1" /> {{ match.stake || match.wager }}
minute: '2-digit' }) }} </span>
</div>
<div class="text-[10px] text-muted-foreground">Host: {{ match.player1?.nickname || 'Unknown' }}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div ref="mObserverTarget" class="h-10 flex items-center justify-center"> <div ref="mObserverTarget" class="h-10 flex items-center justify-center">
<span v-if="mLoading" class="text-xs text-purple-500 animate-pulse">Loading <span v-if="mLoading" class="text-xs text-purple-500 animate-pulse">Loading...</span>
matches...</span>
</div> </div>
</div> </div>
</div> </div>
@@ -376,18 +417,16 @@ const joinGame = async (game) => {
<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 <label class="text-sm font-medium uppercase tracking-wider text-muted-foreground">Open Games
Games</label> (Single)</label>
<Button v-if="isLoggedIn" @click="hostGame()" size="sm" variant="outline" <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 transition-all duration-300 font-bold"> class="border-purple-500 text-purple-500 hover:bg-purple-500 hover:text-white font-bold">
Host Host
</Button> </Button>
</div> </div>
<div class="rounded-lg border bg-card text-card-foreground shadow-sm"> <div class="rounded-lg border bg-card text-card-foreground shadow-sm">
<div v-if="openGames.length === 0" <div v-if="openGames.length === 0" class="p-6 text-center text-sm text-muted-foreground">
class="p-6 text-center text-sm text-muted-foreground"> No open games.
No open games yet. Try hosting one!
</div> </div>
<div v-else class="max-h-[350px] overflow-y-auto custom-scrollbar p-1"> <div v-else class="max-h-[350px] overflow-y-auto custom-scrollbar p-1">
<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)"
@@ -398,78 +437,21 @@ const joinGame = async (game) => {
{{ index + 1 }} {{ index + 1 }}
</div> </div>
<div> <div>
<div class="font-medium text-sm">Host: {{ game.player1?.nickname || <div class="font-medium text-sm">Host: {{ game.player1?.nickname || 'Anonymous' }}</div>
'Anonymous' }}</div>
<div class="text-[10px] text-muted-foreground">{{ new
Date(game.began_at).toLocaleDateString() }} {{ new
Date(game.began_at).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit' }) }}</div>
</div> </div>
</div> </div>
</div> </div>
<div ref="gObserverTarget" class="h-10 flex items-center justify-center"> <div ref="gObserverTarget" class="h-10 flex items-center justify-center">
<span v-if="gLoading" class="text-xs text-purple-500 animate-pulse">Loading <span v-if="gLoading" class="text-xs text-purple-500 animate-pulse">Loading...</span>
games...</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div> </div>
<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">
<CardHeader>
<CardTitle class="text-center">Hosting Game</CardTitle>
</CardHeader>
<CardContent class="flex flex-col items-center gap-6 py-10">
<div class="relative">
<div
class="h-16 w-16 animate-spin rounded-full border-4 border-purple-500 border-t-transparent">
</div>
</div>
<p class="animate-pulse text-lg font-medium text-muted-foreground">Waiting for opponents...</p>
<Button variant="outline" @click="showHostModal = false">Cancel Hosting</Button>
</CardContent>
</Card>
</div>
<div v-if="showJoinModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<Card class="w-full max-w-md border-purple-500 shadow-2xl">
<CardHeader>
<CardTitle>Join Game</CardTitle>
<CardDescription v-if="selectedGame">
Hosted by {{ selectedGame.player1?.nickname || 'Anonymous' }}
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="rounded-lg bg-muted p-4 text-sm">
<p><strong>Game Type:</strong> Bisca de {{ selectedMode }}</p>
<p><strong>Started at:</strong> {{ selectedGame?.began_at }}</p>
</div>
<div v-if="isReady"
class="flex items-center justify-center rounded-md bg-green-500/10 p-3 text-green-600 dark:text-green-400">
<span class="animate-pulse font-semibold">Waiting for the host to start...</span>
</div>
<div class="flex gap-3 pt-4">
<Button variant="destructive" class="flex-1" @click="showJoinModal = false">
Leave
</Button>
<Button class="flex-1 transition-all duration-300" :variant="isReady ? 'outline' : 'default'"
:class="!isReady ? 'bg-purple-600 hover:bg-purple-700' : ''" @click="toggleReady">
{{ isReady ? 'Cancel Ready' : 'Ready up' }}
</Button>
</div>
</CardContent>
</Card>
</div>
<div v-if="showStatsModal" <div v-if="showStatsModal"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"> class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
@@ -571,6 +553,31 @@ const joinGame = async (game) => {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<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">
<CardHeader>
<CardTitle class="text-center">{{ isBestOfThree ? 'Host Match (Best of 3)' : 'Host Game' }}</CardTitle>
</CardHeader>
<CardContent class="flex flex-col gap-6 py-4">
<div v-if="isBestOfThree" class="space-y-2">
<Label>Wager (Coins)</Label>
<div class="relative">
<Coins class="absolute left-3 top-2.5 h-4 w-4 text-yellow-500" />
<Input v-model="wagerAmount" type="number" min="0" class="pl-9" />
</div>
<p class="text-xs text-muted-foreground text-right">Balance: {{ userCoins }}</p>
</div>
<div class="flex gap-2">
<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">
{{ hostLoading ? 'Creating...' : 'Create' }}
</Button>
</div>
</CardContent>
</Card>
</div> </div>
<div class="fixed right-4 top-1/2 -translate-y-1/2 flex flex-col gap-4 z-40"> <div class="fixed right-4 top-1/2 -translate-y-1/2 flex flex-col gap-4 z-40">
@@ -583,4 +590,6 @@ const joinGame = async (game) => {
<User class="h-6 w-6" /> <User class="h-6 w-6" />
</Button> </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
+8 -36
View File
@@ -1,15 +1,10 @@
import HomePage from '@/pages/home/HomePage.vue' import HomePage from '@/pages/home/HomePage.vue'
import LoginPage from '@/pages/login/LoginPage.vue' import LoginPage from '@/pages/login/LoginPage.vue'
import LaravelPage from '@/pages/testing/LaravelPage.vue'
import WebsocketsPage from '@/pages/testing/WebsocketsPage.vue'
import UserPage from '@/pages/user/UserPage.vue' import UserPage from '@/pages/user/UserPage.vue'
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import TestAnimations from '@/pages/TestAnimations.vue'
import TestDealing from '@/pages/TestDealing.vue'
import TestAllAnimations from '@/pages/TestAllAnimations.vue'
import TestGameBoard from '@/pages/TestGameBoard.vue'
import SinglePlayerGamePage from '@/pages/game/SinglePlayerGamePage.vue' import SinglePlayerGamePage from '@/pages/game/SinglePlayerGamePage.vue'
import MultiplayerGamePage from '@/pages/game/MultiplayerGamePage.vue' import MultiplayerGamePage from '@/pages/game/MultiplayerGamePage.vue'
import MultiplayerMatchPage from '@/pages/game/MultiplayerMatchPage.vue'
import RegisterPage from '@/pages/register/RegisterPage.vue' import RegisterPage from '@/pages/register/RegisterPage.vue'
import CoinsPurchasePage from '@/pages/purchase/CoinPurchasePage.vue' import CoinsPurchasePage from '@/pages/purchase/CoinPurchasePage.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
@@ -22,7 +17,7 @@ const router = createRouter({
path: '/', path: '/',
name: 'home', name: 'home',
component: HomePage, component: HomePage,
meta: { requiresAdmin: false } meta: { requiresAdmin: false },
}, },
{ {
path: '/login', path: '/login',
@@ -52,6 +47,12 @@ const router = createRouter({
path: '/game/multiplayer/:id', path: '/game/multiplayer/:id',
name: 'multiplayer-game', name: 'multiplayer-game',
component: MultiplayerGamePage, component: MultiplayerGamePage,
meta: { requiresAuth: true },
},
{
path: '/match/:id',
name: 'multiplayer-match',
component: MultiplayerMatchPage,
meta: { requiresAuth: true } meta: { requiresAuth: true }
}, },
{ {
@@ -70,35 +71,6 @@ const router = createRouter({
component: AdminPage, component: AdminPage,
meta: { requiresAuth: true, requiresAdmin: true }, meta: { requiresAuth: true, requiresAdmin: true },
}, },
{
path: '/testing',
children: [
{
path: 'laravel',
component: LaravelPage,
},
{
path: 'websockets',
component: WebsocketsPage,
},
{
path: 'animations',
component: TestAnimations,
},
{
path: 'dealing',
component: TestDealing,
},
{
path: 'all-animations',
component: TestAllAnimations,
},
{
path: 'gameboard',
component: TestGameBoard,
},
],
},
], ],
}) })
+2 -7
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({
@@ -181,12 +179,9 @@ export const useAPIStore = defineStore('api', () => {
const getAdminMatches = (params = {}) => { const getAdminMatches = (params = {}) => {
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
page: params.page || 1, page: params.page || 1,
...(params.sort_direction == 'asc' && { sort_direction: 'asc' }),
...(params.type && { type: params.type }), ...(params.type && { type: params.type }),
...(params.outcome && { outcome: params.outcome }), ...(params.pot && { pot: params.pot }),
...(params.date_from && { date_from: params.date_from }),
...(params.date_to && { date_to: params.date_to }),
sort_by: params.sort_by || 'began_at',
sort_direction: params.sort_direction || 'desc',
}).toString() }).toString()
return axios.get(`${API_BASE_URL}/matches?${queryParams}`) return axios.get(`${API_BASE_URL}/matches?${queryParams}`)
+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 -7
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
@@ -92,7 +94,6 @@ export const useSocketStore = defineStore('websocket', () => {
socket.value.emit('play-card', { gameId, cardId }) socket.value.emit('play-card', { gameId, cardId })
} }
// --- NEW: Surrender Action ---
const surrender = (gameId) => { const surrender = (gameId) => {
if (!socket.value?.connected) return if (!socket.value?.connected) return
console.log('[Game] Surrendering:', gameId) console.log('[Game] Surrendering:', gameId)
@@ -123,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:
+6
View File
@@ -0,0 +1,6 @@
{
"name": "DADProject",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
-2
View File
@@ -1,2 +0,0 @@
PORT=
API_URL=
+6 -4
View File
@@ -48,8 +48,9 @@ class BiscaGame {
this.dealInitialCards(); this.dealInitialCards();
const playerIds = Object.keys(this.players); const playerKeys = Object.keys(this.players);
this.currentTurn = playerIds.find((id) => id != 1) || playerIds[0]; const startKey = playerKeys.find((key) => key != 1) || playerKeys[0];
this.currentTurn = this.players[startKey].id;
this.startTime = Date.now(); this.startTime = Date.now();
this.status = "playing"; this.status = "playing";
@@ -189,7 +190,7 @@ class BiscaGame {
this.players[winnerId].tricks += 1; this.players[winnerId].tricks += 1;
this.currentTurn = winnerId; this.currentTurn = winnerId;
if (this.type == 3) { if (this.deck.length > 0 || this.trumpCard) {
this.drawCard(winnerId); this.drawCard(winnerId);
this.drawCard(this.getOpponentId(winnerId)); this.drawCard(this.getOpponentId(winnerId));
} }
@@ -221,7 +222,7 @@ class BiscaGame {
? p2Obj.id ? p2Obj.id
: p1Obj.id; : p1Obj.id;
return { const result = {
action: "game_ended", action: "game_ended",
trickResult, trickResult,
winnerId: finalWinner, winnerId: finalWinner,
@@ -233,6 +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 -107
View File
@@ -1,12 +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 {
constructor(apiData, emitStateCallback, token) { constructor(apiData, emitStateCallback, token, autoPlayCallback) {
this.id = apiData.id; this.id = apiData.id;
this.emitStateCallback = emitStateCallback; this.emitStateCallback = emitStateCallback;
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;
@@ -14,6 +15,8 @@ class BiscaMatch {
this.player1Id = apiData.player1_user_id; this.player1Id = apiData.player1_user_id;
this.player2Id = apiData.player2_user_id; this.player2Id = apiData.player2_user_id;
this.player1 = apiData.player1;
this.player2 = apiData.player2;
this.marks = { this.marks = {
[this.player1Id]: apiData.player1_marks || 0, [this.player1Id]: apiData.player1_marks || 0,
@@ -33,11 +36,12 @@ class BiscaMatch {
} }
} }
async joinPlayer(userId) { async joinPlayer(userId, user) {
if (this.player2Id !== this.GHOST_ID) return false; if (this.player2Id !== this.GHOST_ID) return false;
if (userId === this.player1Id) return false; if (userId === this.player1Id) return false;
this.player2Id = userId; this.player2Id = userId;
this.player2 = user;
this.marks[userId] = 0; this.marks[userId] = 0;
this.points[userId] = 0; this.points[userId] = 0;
this.status = "Playing"; this.status = "Playing";
@@ -48,60 +52,43 @@ class BiscaMatch {
getAuthHeaders() { getAuthHeaders() {
return { return {
headers: { Authorization: this.token }, headers: {
Authorization: this.token,
"Content-Type": "application/json",
Accept: "application/json",
},
}; };
} }
async startNewGame() { async startNewGame() {
console.log(`[Match ${this.id}] A iniciar nova mão (Game DB)...`); console.log(`[Match ${this.id}] Starting new round...`);
let newGameDbId = null; let newGameDbId = null;
try { try {
let payloadP1 = this.player1Id;
let payloadP2 = this.player2Id;
if (this.player2Id !== this.GHOST_ID) {
console.log(
"🔄 Multiplayer detetado: Trocando ordem para satisfazer a API..."
);
payloadP1 = this.player2Id;
payloadP2 = this.player1Id;
}
const payload = { const payload = {
type: this.type, type: this.type,
player1_user_id: payloadP1, player1_user_id: this.player1Id,
player2_user_id: payloadP2, player2_user_id: this.player2Id,
match_id: this.id, match_id: this.id,
status: "Playing", status: "Playing",
began_at: new Date().toISOString(), began_at: new Date().toISOString(),
}; };
console.log("📤 [Payload Adaptado]:", JSON.stringify(payload, null, 2));
const res = await axios.post( const res = await axios.post(
`${API_URL}/games`, `${API_URL}/games`,
payload, payload,
this.getAuthHeaders() 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;
console.log(`[Match ${this.id}] Game DB criado: #${newGameDbId}`); const p1Name = this.player1?.nickname || `User ${this.player1Id}`;
const p2Name = this.player2?.nickname || `User ${this.player2Id}`;
const apiP1 = gameData.player1_user_id;
const apiP2 = gameData.player2_user_id;
const p1 = { id: apiP1, name: `User ${apiP1}` };
const p2 = { id: apiP2, name: `User ${apiP2}` };
this.currentGame = new BiscaGame( this.currentGame = new BiscaGame(
this.id, this.id,
this.type, this.type,
p1, { id: this.player1Id, name: p1Name },
p2, { id: this.player2Id, name: p2Name },
newGameDbId, newGameDbId,
(result) => { (result) => {
this.handleGameEnd(result); this.handleGameEnd(result);
@@ -110,27 +97,49 @@ class BiscaMatch {
this.currentGame.init(); this.currentGame.init();
this.currentGame.startTurnTimer((uid, cid) => {
if (this.autoPlayCallback) this.autoPlayCallback(uid, cid);
});
if (this.emitStateCallback) { if (this.emitStateCallback) {
this.emitStateCallback("match:new-round", this.getState()); this.emitStateCallback("match:new-round-start", null);
} }
} catch (e) { } catch (e) {
if (e.response) { console.error(`❌ Start Game Error: ${e.message}`);
console.error( }
`❌ Erro API (${e.response.status}):`, }
JSON.stringify(e.response.data)
async abortCurrentGame(loserId) {
if (this.currentGame && this.currentGame.dbId) {
console.log(`[Match ${this.id}] Aborting Game #${this.currentGame.dbId}`);
try {
const winnerId =
loserId == this.player1Id ? this.player2Id : this.player1Id;
const payload = {
status: "Ended",
winner_user_id: winnerId,
loser_user_id: loserId,
is_draw: false,
ended_at: new Date().toISOString(),
};
await axios.put(
`${API_URL}/games/${this.currentGame.dbId}`,
payload,
this.getAuthHeaders()
); );
} else { } catch (e) {
console.error(`❌ Erro Código: ${e.message}`); console.error("Error aborting game:", e.message);
} }
} }
} }
async handleGameEnd(result) { async handleGameEnd(result) {
console.log( console.log(`[Match ${this.id}] Round ended. Winner: ${result.winnerId}`);
`[Match ${this.id}] Mão terminada. Vencedor: ${result.winnerId}`
);
if (this.points[result.player1_id] !== undefined)
this.points[result.player1_id] += result.player1_points; this.points[result.player1_id] += result.player1_points;
if (this.points[result.player2_id] !== undefined)
this.points[result.player2_id] += result.player2_points; this.points[result.player2_id] += result.player2_points;
if (this.currentGame && this.currentGame.dbId) { if (this.currentGame && this.currentGame.dbId) {
@@ -144,44 +153,16 @@ class BiscaMatch {
player2_points: result.player2_points, player2_points: result.player2_points,
ended_at: new Date().toISOString(), ended_at: new Date().toISOString(),
}; };
console.log(
`📤 A enviar update para Game #${this.currentGame.dbId}...`
);
// --- CORREÇÃO IMPORTANTE: Forçar o header aqui ---
const config = {
headers: {
Authorization: this.token,
"Content-Type": "application/json",
Accept: "application/json",
},
};
await axios.put( await axios.put(
`${API_URL}/games/${this.currentGame.dbId}`, `${API_URL}/games/${this.currentGame.dbId}`,
payload, payload,
config this.getAuthHeaders()
);
console.log(
`✅ Game #${this.currentGame.dbId} atualizado com sucesso.`
); );
} catch (e) { } catch (e) {
// --- LOG DETALHADO PARA VER O MOTIVO DO 403 --- console.error("DB Game Update Error:", e.message);
if (e.response) {
console.error(`❌ ERRO CRÍTICO API (${e.response.status}):`);
// Isto vai imprimir o JSON exato que o Laravel devolve
console.error(JSON.stringify(e.response.data, null, 2));
} else {
console.error("Erro Axios:", e.message);
} }
} }
} else {
console.warn("⚠️ Ignorado: Não há ID de jogo na DB para atualizar.");
}
// ... (resto da lógica de atribuir marcas e finishMatch mantém-se igual)
let marksToAdd = 0; let marksToAdd = 0;
let roundWinnerId = result.winnerId; let roundWinnerId = result.winnerId;
@@ -193,32 +174,42 @@ class BiscaMatch {
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; this.marks[roundWinnerId] += marksToAdd;
} }
if (this.emitStateCallback) {
this.emitStateCallback("match:round-ended", {
winnerId: roundWinnerId,
marksAdded: marksToAdd,
p1Points: result.player1_points,
p2Points: result.player2_points,
p1Marks: this.marks[this.player1Id],
p2Marks: this.marks[this.player2Id],
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) { if (this.emitStateCallback)
this.emitStateCallback("match:ended", this.getState()); this.emitStateCallback("match:ended-signal", null);
}
} else { } else {
console.log(`[Match ${this.id}] Ninguém ganhou 4 marcas. A baralhar...`); setTimeout(() => {
await this.startNewGame(); this.startNewGame();
}, 1000);
} }
} }
async finishMatch() { async finishMatch() {
this.status = "Ended"; this.status = "Ended";
const winnerId = const winnerId =
this.marks[this.player1Id] >= 4 ? this.player1Id : this.player2Id; this.marks[this.player1Id] >= 4 ? this.player1Id : this.player2Id;
const loserId = const loserId =
winnerId == this.player1Id ? this.player2Id : this.player1Id; winnerId == this.player1Id ? this.player2Id : this.player1Id;
try { const payload = {
await axios.put(
`${API_URL}/matches/${this.id}`,
{
status: "Ended", status: "Ended",
winner_user_id: winnerId, winner_user_id: winnerId,
loser_user_id: loserId, loser_user_id: loserId,
@@ -226,48 +217,52 @@ class BiscaMatch {
player2_marks: this.marks[this.player2Id], player2_marks: this.marks[this.player2Id],
player1_points: this.points[this.player1Id], player1_points: this.points[this.player1Id],
player2_points: this.points[this.player2Id], player2_points: this.points[this.player2Id],
}, };
try {
await axios.put(
`${API_URL}/matches/${this.id}`,
payload,
this.getAuthHeaders() this.getAuthHeaders()
); );
console.log(`[Match ${this.id}] Encerrado com sucesso na API.`); console.log(`[Match ${this.id}] Match Closed in DB.`);
} catch (e) { } catch (e) {
console.error(`Erro ao fechar Match API: ${e.message}`); console.error(`Match Close Error: ${e.message}`);
} }
} }
playCard(userId, cardId) { playCard(userId, cardId) {
if (!this.currentGame || this.status !== "Playing") return; if (!this.currentGame || this.status !== "Playing") return;
return this.currentGame.playCard(userId, cardId);
const result = this.currentGame.playCard(userId, cardId);
if (
result &&
(result.action === "next_turn" || result.action === "trick_resolved")
) {
this.currentGame.startTurnTimer((uid, cid) => {
if (this.autoPlayCallback) this.autoPlayCallback(uid, cid);
});
} }
getState() { return result;
let gameState = null;
if (this.currentGame) {
const p1Id = this.player1Id;
const p2Id = this.player2Id;
if (this.currentGame.players && this.currentGame.players[p1Id]) {
gameState = this.currentGame.getStateForPlayer(p1Id);
} else if (this.currentGame.players && this.currentGame.players[p2Id]) {
gameState = this.currentGame.getStateForPlayer(p2Id);
} else if (this.currentGame.players) {
const availableIds = Object.keys(this.currentGame.players);
if (availableIds.length > 0) {
console.warn(
`⚠️ [Match ${this.id}] IDs desalinhados! A usar vista do user ${availableIds[0]}`
);
gameState = this.currentGame.getStateForPlayer(availableIds[0]);
}
}
} }
getGameState(userId) {
if (!this.currentGame) return null;
if (this.currentGame.players[userId])
return this.currentGame.getStateForPlayer(userId);
return null;
}
getPublicState() {
return { return {
matchId: this.id, matchId: this.id,
status: this.status, status: this.status,
marks: this.marks, marks: this.marks,
points: this.points, points: this.points,
game: gameState, player1: this.player1,
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;
+48 -26
View File
@@ -2,15 +2,24 @@ 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 = {
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 +33,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 || {};
@@ -42,13 +50,13 @@ const sanitizeState = (state, game) => {
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,
}; };
}; };
@@ -67,7 +75,7 @@ const broadcastState = (io, gameId, game) => {
} }
} }
} }
} };
async function saveGameResult(gameId, result, token) { async function saveGameResult(gameId, result, token) {
try { try {
@@ -82,24 +90,29 @@ 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,
isDraw: false, isDraw: false,
player1_points: game.players[Object.keys(game.players)[0]]?.points || 0, player1_points: game.players[Object.keys(game.players)[0]]?.points || 0,
player2_points: game.players[Object.keys(game.players)[1]]?.points || 0, player2_points: game.players[Object.keys(game.players)[1]]?.points || 0,
reason: reason reason: reason,
}; };
game.status = "ended"; game.status = "ended";
@@ -110,24 +123,24 @@ const performGameEnd = (io, game, gameId, winnerId, loserId, reason) => {
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);
@@ -155,11 +168,14 @@ const handleGameMove = (io, gameId, userId, cardId) => {
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);
@@ -197,7 +213,9 @@ export default (io, socket) => {
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";
@@ -209,7 +227,7 @@ export default (io, socket) => {
{ 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) {
@@ -218,7 +236,7 @@ export default (io, socket) => {
} }
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)) {
@@ -241,13 +259,16 @@ export default (io, socket) => {
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;
if (game.players[myId]) { if (game.players[myId]) {
game.players[myId].token = socket.handshake.auth.token; game.players[myId].token = socket.handshake.auth.token;
@@ -265,7 +286,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);
@@ -274,7 +294,9 @@ export default (io, socket) => {
console.log(`[Game] User ${user.nickname} surrendered.`); console.log(`[Game] User ${user.nickname} surrendered.`);
const winnerId = Object.keys(game.players).find(id => String(id) !== String(user.id)); const winnerId = Object.keys(game.players).find(
(id) => String(id) !== String(user.id)
);
performGameEnd(io, game, gameId, winnerId, user.id, "surrender"); performGameEnd(io, game, gameId, winnerId, user.id, "surrender");
}); });
}; };
+115 -85
View File
@@ -2,137 +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 roomName = `match_${matchId}`;
const room = io.sockets.adapter.rooms.get(roomName);
if (room) {
for (const socketId of room) {
const clientSocket = io.sockets.sockets.get(socketId);
if (!clientSocket || !clientSocket.user) continue;
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 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); io.to(`match_${matchId}`).emit(eventName, payload);
}
};
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) => { socket.on("match:enter", async (data) => {
const { matchId } = data; const { matchId } = data;
if (!socket.user || !socket.user.id)
if (!socket.user || !socket.user.id) { return socket.emit("error", { message: "Auth failed" });
return socket.emit("error", { message: "User not authenticated" });
}
const userId = socket.user.id; const userId = socket.user.id;
const room = `match_${matchId}`; socket.activeMatchId = matchId;
const room = `match_${matchId}`;
let match = getMatch(matchId); let match = getMatch(matchId);
if (!match) { if (!match) {
try { try {
console.log(`A pedir match ${matchId} à API...`);
const res = await axios.get(`${API_URL}/matches/${matchId}`, { const res = await axios.get(`${API_URL}/matches/${matchId}`, {
headers: { Authorization: socket.token }, headers: {
Authorization: socket.token || socket.handshake.auth.token,
},
}); });
const matchData = res.data.data || res.data; const matchData = res.data.data || res.data;
match = new BiscaMatch( match = new BiscaMatch(
matchData, matchData,
createMatchEmitter(matchId), createMatchEmitter(matchId),
socket.token socket.token,
handleAutoPlay(matchId)
); );
addMatch(match); addMatch(match);
console.log(`Match ${matchId} carregado em memória.`);
} catch (e) { } catch (e) {
console.error("Erro ao carregar Match da API:", e.message); return socket.emit("error", "Match not found");
socket.emit("error", "Match not found or API error");
return;
} }
} else {
match.token = socket.token;
} }
if (match) {
match.token = socket.token;
}
match.emitStateCallback = createMatchEmitter(matchId); match.emitStateCallback = createMatchEmitter(matchId);
if (userId !== match.player1Id && match.player2Id === 1)
if (userId !== match.player1Id && match.player2Id === 1) { await match.joinPlayer(userId, socket.user);
console.log(`User ${userId} a substituir o Ghost no Match ${matchId}`);
await match.joinPlayer(userId);
}
socket.join(room); socket.join(room);
console.log(`Socket ${socket.id} entrou na sala ${room}`); const publicState = match.getPublicState();
const gameState = match.getGameState(userId);
if (match.status === "Playing") { socket.emit("match:update", { ...publicState, game: gameState });
socket.emit("match:update", match.getState()); if (match.status === "Playing") broadcastMatchUpdate(matchId, match);
} else {
socket.emit("match:waiting", { msg: "Waiting for opponent..." });
}
}); });
socket.on("game:play-card", (data) => { socket.on("game:play-card", (data) => {
const { matchId, cardId } = data; const { matchId, cardId } = data;
const match = getMatch(matchId); const match = getMatch(matchId);
if (!match || match.status !== "Playing") return;
if (!match || match.status !== "Playing") {
console.log(
"Tentativa de jogar sem match ativo ou match não encontrado."
);
return;
}
const result = match.playCard(socket.user.id, cardId); const result = match.playCard(socket.user.id, cardId);
if (result && result.error)
if (result && result.error) { return socket.emit("error", { message: result.error });
console.log( if (result && result.action === "trick_resolved") {
`❌ [Erro Jogo] User ${socket.user.id} tentou jogar carta ${cardId} mas: ${result.error}` io.to(`match_${matchId}`).emit("game:trick-end", result.trickResult);
); setTimeout(() => {
broadcastMatchUpdate(matchId, match);
socket.emit("error", { message: result.error }); }, 1500);
return; } else {
} broadcastMatchUpdate(matchId, match);
if (match.currentGame) {
io.to(`match_${matchId}`).emit(
"game:update",
match.currentGame.getStateForPlayer(socket.user.id)
);
} }
}); });
socket.on("debug:end-game", async (data) => {
const { matchId } = data;
const match = getMatch(matchId);
if (match) {
console.log(`[DEBUG] User ${socket.user.id} a forçar fim de jogo.`);
// Simula que o user que clicou ganhou 120 pontos
const winnerId = socket.user.id;
const loserId =
winnerId == match.player1Id ? match.player2Id : match.player1Id;
// Objeto de resultado falso para fechar o jogo socket.on("game:surrender", async ({ matchId }) => {
const fakeResult = { const match = getMatch(matchId);
winnerId: winnerId, if (!match || match.status !== "Playing") return;
loserId: loserId, 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, isDraw: false,
player1_points: winnerId == match.player1Id ? 120 : 0,
player2_points: winnerId == match.player2Id ? 120 : 0,
player1_id: match.player1Id, player1_id: match.player1Id,
player2_id: match.player2Id, player2_id: match.player2Id,
player1_points: match.player1Id == opponentId ? 91 : 0,
player2_points: match.player2Id == opponentId ? 91 : 0,
reason: "surrender",
}; };
await match.handleGameEnd(result);
await match.handleGameEnd(fakeResult);
}
}); });
// DEBUG: Forçar fim do Match Completo socket.on("match:surrender", async ({ matchId }) => {
socket.on("debug:end-match", async (data) => {
const { matchId } = data;
const match = getMatch(matchId); const match = getMatch(matchId);
if (match) { if (!match || match.status !== "Playing") return;
console.log(`[DEBUG] User ${socket.user.id} a forçar fim de MATCH.`); const userId = socket.user.id;
// Dá 4 marcas a quem clicou
match.marks[socket.user.id] = 4;
await match.finishMatch();
// Emite o estado final await match.abortCurrentGame(userId);
io.to(`match_${matchId}`).emit("match:ended", match.getState());
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);
}
}
} }
}); });
}; };
+88 -3
View File
@@ -9,7 +9,12 @@ 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 RECONNECT_GRACE_PERIOD = 1 * 60 * 1000;
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, {
@@ -18,6 +23,8 @@ export const serverStart = (port) => {
}, },
}); });
console.log(`[WebSocket] API_URL is set to ${API_URL}`);
server.io.use(async (socket, next) => { server.io.use(async (socket, next) => {
let token = socket.handshake.auth.token; let token = socket.handshake.auth.token;
@@ -33,7 +40,7 @@ export const serverStart = (port) => {
const response = await axios.get(`${API_URL}/users/me`, { const response = await axios.get(`${API_URL}/users/me`, {
headers: { headers: {
Authorization: token, Authorization: token,
Accept: "application/json" Accept: "application/json",
}, },
}); });
@@ -47,6 +54,14 @@ export const serverStart = (port) => {
socket.token = token; socket.token = token;
socket.handshake.auth.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); addUser(socket.id, user);
next(); next();
@@ -66,13 +81,83 @@ export const serverStart = (port) => {
server.io.on("connection", (socket) => { server.io.on("connection", (socket) => {
gameEvents(server.io, socket); gameEvents(server.io, socket);
matchEvents(server.io, socket); matchEvents(server.io, socket);
handleConnectionEvents(server.io, socket); handleConnectionEvents(server.io, socket);
socket.on("notify_disconnect", () => {
socket.isVoluntaryDisconnect = true;
});
socket.on("disconnect", () => { socket.on("disconnect", () => {
// Remove from active socket list
removeUser(socket.id); 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);
}
}); });
}); });
}; };
+1
View File
@@ -32,3 +32,4 @@ export const removeGame = (id) => {
console.log(`[Game State] Game removed: ${id}`); console.log(`[Game State] Game removed: ${id}`);
} }
}; };