diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..7e5b7d7
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/encodings.xml b/.idea/encodings.xml
new file mode 100644
index 0000000..df87cf9
--- /dev/null
+++ b/.idea/encodings.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/data.json b/data.json
index 3eee4b7..fe51488 100644
--- a/data.json
+++ b/data.json
@@ -1,19 +1 @@
-[
- {
- "username": "jerome",
- "password": "Sigma",
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJsb3dlcl91c2VybmFtZSI6Implcm9tZSIsImlhdCI6MTc0NDg4OTY3MSwiZXhwIjoxNzQ0ODkxNDcxfQ.zYg3uxh_iNZ2xdEma8p3p-URT3Xa7wnYiQyOx0-QTac",
- "money": 400
- },
- {
- "username": "Gianimon",
- "password": "gianimon",
- "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IkdpYW5pbW9uIiwiaWF0IjoxNzQ0ODc0NTczLCJleHAiOjE3NDQ4NzYzNzN9.dGp5WTKIDVhomKZ8J7nevz8O7PqwRhR9wEgeyDtL_XE",
- "money": 300
- },
- {
- "username": "any",
- "password": "any",
- "money": 300
- }
-]
\ No newline at end of file
+[]
diff --git a/gambling.js b/gambling.js
index a4f25d0..e4eb994 100644
--- a/gambling.js
+++ b/gambling.js
@@ -1,14 +1,22 @@
const express = require("express");
const app = express();
+const http = require("http");
+const {WebSocket, WebSocketServer} = require("ws");
const swaggerUi = require("swagger-ui-express");
const swaggerDocument = require("./swagger.json");
const path = require("path");
app.use(express.static(path.join(__dirname, "public")));
-const fs = require("fs");
+const fs = require("fs/promises");
const dotenv = require("dotenv").config();
const jwt = require("jsonwebtoken");
const cookieParser = require("cookie-parser");
-const port = 25565;
+const bcrypt = require("bcrypt");
+const sqlite3 = require("sqlite3");
+const {open} = require("sqlite");
+const port = process.env.PORT || 25566;
+const server = http.createServer(app);
+const wss = new WebSocketServer({server});
+let db;
app.use(express.json());
app.use("/swagger-ui", swaggerUi.serve, swaggerUi.setup(swaggerDocument));
@@ -18,69 +26,160 @@ app.use(cookieParser());
Here are Helper function -----------------------------------------------------------------------------------------------
*/
-function get_data() {
+async function initializeDatabase() {
+ db = await open({
+ filename: path.join(__dirname, "users.sqlite"),
+ driver: sqlite3.Database
+ });
+
+ await db.exec(`
+ CREATE TABLE IF NOT EXISTS users (
+ username TEXT PRIMARY KEY COLLATE NOCASE,
+ password_hash TEXT NOT NULL,
+ token TEXT,
+ money INTEGER NOT NULL DEFAULT 200,
+ lobby_id TEXT
+ )
+ `);
+
+ await migrateJsonUsers();
+}
+
+async function migrateJsonUsers() {
const dataPath = path.join(__dirname, "data.json");
- if (!fs.existsSync(dataPath)) {
- fs.writeFileSync(dataPath, JSON.stringify([]));
+ try {
+ const rawUsers = JSON.parse(await fs.readFile(dataPath, "utf8"));
+ if (!Array.isArray(rawUsers)) return;
+
+ for (const user of rawUsers) {
+ if (!user.username || !user.password) continue;
+ const username = user.username.toLowerCase();
+ const existingUser = await findUserByUsername(username);
+ if (existingUser) continue;
+
+ const passwordHash = user.password.startsWith("$2")
+ ? user.password
+ : await bcrypt.hash(user.password, 12);
+
+ await db.run(
+ "INSERT INTO users (username, password_hash, token, money, lobby_id) VALUES (?, ?, ?, ?, ?)",
+ username,
+ passwordHash,
+ user.token || null,
+ Number.isFinite(user.money) ? user.money : 200,
+ user.lobbyId || null
+ );
+ }
+
+ if (rawUsers.length > 0) {
+ await fs.writeFile(dataPath, "[]\n");
+ }
+ } catch (error) {
+ if (error.code !== "ENOENT") {
+ console.error("Failed to migrate data.json users:", error);
+ }
}
- const data = fs.readFileSync(dataPath);
- return JSON.parse(data);
}
-function save_data(data) {
- fs.writeFileSync(path.join(__dirname, "data.json"), JSON.stringify(data, null, 2));
+async function getUsers() {
+ return db.all("SELECT username, token, money, lobby_id AS lobbyId FROM users");
}
-function saveUser(user) {
- const users = get_data();
- users.push(user);
- save_data(users);
+async function findUserByUsername(username) {
+ return db.get(
+ "SELECT username, password_hash AS passwordHash, token, money, lobby_id AS lobbyId FROM users WHERE username = ?",
+ username
+ );
+}
+
+async function saveUser({username, password, token, money = 200}) {
+ const passwordHash = await bcrypt.hash(password, 12);
+ await db.run(
+ "INSERT INTO users (username, password_hash, token, money) VALUES (?, ?, ?, ?)",
+ username,
+ passwordHash,
+ token,
+ money
+ );
+}
+
+async function setUserToken(username, token) {
+ await db.run("UPDATE users SET token = ? WHERE username = ?", token || null, username);
+}
+
+async function setUserLobby(username, lobbyId) {
+ await db.run("UPDATE users SET lobby_id = ? WHERE username = ?", lobbyId || null, username);
+}
+
+async function setUserMoney(username, money) {
+ await db.run("UPDATE users SET money = ? WHERE username = ?", Math.max(0, Math.floor(money)), username);
}
function generateAccessToken(user) {
return jwt.sign(user, process.env.TOKEN_SECRET, {expiresIn: "1800s"});
}
-function authenticateToken(request, response, next) {
+function parseCookieHeader(cookieHeader = "") {
+ return cookieHeader.split(";").reduce((cookies, cookie) => {
+ const separatorIndex = cookie.indexOf("=");
+ if (separatorIndex === -1) return cookies;
+ const key = cookie.slice(0, separatorIndex).trim();
+ const value = decodeURIComponent(cookie.slice(separatorIndex + 1).trim());
+ cookies[key] = value;
+ return cookies;
+ }, {});
+}
+
+function getAuthTokenFromRequest(request) {
+ return request.headers.authorization?.split(" ")[1] || request.cookies.authorization;
+}
+
+async function verifyAuthToken(token) {
+ if (!token) return null;
+ try {
+ const decoded = jwt.verify(token, process.env.TOKEN_SECRET);
+ const foundUser = await findUserByUsername(decoded.username);
+ if (!foundUser || foundUser.token !== token) return null;
+ return decoded;
+ } catch {
+ return null;
+ }
+}
+
+async function authenticateToken(request, response, next) {
/* #swagger.security = [{
"bearerAuth": []
-}] */
- const token =
- request.headers.authorization?.split(" ")[1] ||
- request.cookies.authorization;
+ }] */
+ const token = getAuthTokenFromRequest(request);
if (token == null) return response.sendStatus(401);
- jwt.verify(token, process.env.TOKEN_SECRET, (err, user) => {
- if (err) return response.sendStatus(403);
- const users = get_data();
- const foundUser = users.find(u => u.username === user.username);
- if (!foundUser || foundUser.token !== token) {
- return response.status(403).json({error: "Token has been revoked or is invalid"});
- }
- request.user = user;
- next();
- });
+ const user = await verifyAuthToken(token);
+ if (!user) {
+ return response.status(403).json({error: "Token has been revoked or is invalid"});
+ }
+ request.user = user;
+ next();
}
/*
Here is the the Login logic -----------------------------------------------------------------------------------------------
*/
-app.post("/register", (request, response) => {
+app.post("/register", async (request, response) => {
let {username, password} = request.body;
- let lower_username = username.toLowerCase();
if (!username || !password)
return response.status(400).json({error: "Username and password are required"});
- const users = get_data();
- if (users.some(u => u.username === username)) {
+ username = username.toLowerCase();
+ const existingUser = await findUserByUsername(username);
+ if (existingUser) {
return response.status(400).json({error: "Username already exists"});
}
const token = generateAccessToken({username});
- saveUser({username, password, token, "money": 200});
- response.json({lower_username, token});
+ await saveUser({username, password, token, money: 200});
+ response.json({username, token});
});
-app.get("/leaderboard", authenticateToken, (req, res) => {
- const users = get_data();
+app.get("/leaderboard", authenticateToken, async (req, res) => {
+ const users = await getUsers();
const sorted = users.sort((a, b) => b.money - a.money);
const top10 = sorted.slice(0, 10).map(u => ({
username: u.username,
@@ -103,51 +202,43 @@ app.get("/login", (request, response) => {
response.sendFile(path.join(__dirname, "./public/login.html"));
})
-app.post("/login", (request, response) => {
+app.post("/login", async (request, response) => {
let {username, password} = request.body;
- username = username.toLowerCase();
if (!username || !password)
return response.status(400).json({error: "Username and password are required"});
- const users = get_data();
- const user = users.find(u => u.username === username);
+ username = username.toLowerCase();
+ const user = await findUserByUsername(username);
if (!user) return response.status(401).json({error: "Invalid credentials"});
- if (user.password !== password) {
+ const passwordMatches = await bcrypt.compare(password, user.passwordHash);
+ if (!passwordMatches) {
return response.status(401).json({error: "Invalid password"});
}
const token = generateAccessToken({username});
- user.token = token;
- save_data(users);
+ await setUserToken(username, token);
response.json({username, token});
});
-app.get("/", (req, res) => {
+app.get("/", async (req, res) => {
const token =
req.headers.authorization?.split(" ")[1] ||
req.cookies.authorization;
if (!token) {
return res.sendFile(path.join(__dirname, "./public/unauthorized.html"));
}
- jwt.verify(token, process.env.TOKEN_SECRET, (err, user) => {
- if (err) {
- return res.sendFile(path.join(__dirname, "./public/unauthorized.html"));
- }
- const users = get_data();
- const foundUser = users.find(u => u.username === user.username);
- if (!foundUser || foundUser.token !== token) {
- return res.sendFile(path.join(__dirname, "./public/unauthorized.html"));
- }
- req.user = user;
- res.sendFile(path.join(__dirname, "./public/home.html"));
- });
+ const user = await verifyAuthToken(token);
+ if (!user) {
+ return res.sendFile(path.join(__dirname, "./public/unauthorized.html"));
+ }
+ req.user = user;
+ res.sendFile(path.join(__dirname, "./public/home.html"));
});
-app.get("/balance", authenticateToken, (request, response) => {
+app.get("/balance", authenticateToken, async (request, response) => {
/* #swagger.security = [{
"bearerAuth": []
}] */
const username = request.user.username;
- const allUsers = get_data();
- const me = allUsers.find(u => u.username === username);
+ const me = await findUserByUsername(username);
if (!me) {
return response.status(404).json({error: "User not found"});
}
@@ -160,14 +251,11 @@ app.post("/verify", authenticateToken, (request, response) => {
app.delete("/logout", authenticateToken, async (request, response) => {
const username = request.user.username;
- const users = get_data();
- const user = users.find(u => u.username === username);
+ const user = await findUserByUsername(username);
if (!user) return response.status(404).json({error: "User not found"});
const lobbyId = user.lobbyId;
- await leaveLobby(lobbyId, username);
- await deleteLobby(lobbyId);
- delete user.token;
- save_data(users);
+ await leavePokerLobby(lobbyId, username);
+ await setUserToken(username, null);
response.clearCookie("authorization", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
@@ -177,6 +265,842 @@ app.delete("/logout", authenticateToken, async (request, response) => {
});
const playerMap = {};
+const lobbySockets = {};
+const lobbyHosts = {};
+const lobbyPrivacy = {};
+const pokerGames = {};
+const disconnectTimers = {};
+const ROUND_INTERMISSION_MS = 30000;
+const TURN_TIMEOUT_MS = 30000;
+const DISCONNECT_GRACE_MS = 10000;
+const MINIMUM_STAKE = 10;
+const STARTING_STACK = 200;
+
+const cardSuits = ["S", "H", "D", "C"];
+const cardRanks = ["A", "K", "Q", "J", "10", "9", "8", "7", "6", "5", "4", "3", "2"];
+
+function createPlaceholderDeck() {
+ const deck = [];
+ for (const suit of cardSuits) {
+ for (const rank of cardRanks) {
+ deck.push({rank, suit});
+ }
+ }
+ return deck.sort(() => Math.random() - 0.5);
+}
+
+async function getInitialPokerStack(player, previousStacks = {}) {
+ if (Number.isFinite(previousStacks[player])) {
+ return previousStacks[player];
+ }
+ const user = await findUserByUsername(player);
+ return Number.isFinite(user?.money) ? user.money : STARTING_STACK;
+}
+
+async function persistPokerStacks(game) {
+ await Promise.all(game.players.map(player => setUserMoney(player, game.stacks[player] || 0)));
+}
+
+async function createPokerGame(lobbyId) {
+ const players = playerMap[lobbyId] || [];
+ const existingGame = pokerGames[lobbyId];
+ if (existingGame?.nextRoundTimer) {
+ clearTimeout(existingGame.nextRoundTimer);
+ }
+ const roundNumber = existingGame ? existingGame.round + 1 : 1;
+ const previousStacks = existingGame?.stacks || {};
+ const dealerIndex = existingGame
+ ? (existingGame.dealerIndex + 1) % Math.max(players.length, 1)
+ : 0;
+ const stacks = {};
+ const playersWithChips = [];
+
+ for (const player of players) {
+ stacks[player] = await getInitialPokerStack(player, previousStacks);
+ if (stacks[player] >= MINIMUM_STAKE) {
+ playersWithChips.push(player);
+ }
+ }
+ const activePlayers = playersWithChips.length >= 2 ? playersWithChips : [];
+
+ const deck = createPlaceholderDeck();
+ const hands = {};
+ const folded = {};
+ const acted = {};
+ const contributions = {};
+ const playerBets = {};
+ let pot = 0;
+
+ for (const player of activePlayers) {
+ hands[player] = [deck.pop(), deck.pop()];
+ folded[player] = false;
+ acted[player] = false;
+ contributions[player] = MINIMUM_STAKE;
+ playerBets[player] = 0;
+ stacks[player] -= MINIMUM_STAKE;
+ pot += MINIMUM_STAKE;
+ }
+
+ for (const player of players) {
+ if (!activePlayers.includes(player)) {
+ folded[player] = true;
+ acted[player] = true;
+ contributions[player] = 0;
+ playerBets[player] = 0;
+ }
+ }
+
+ pokerGames[lobbyId] = {
+ lobbyId,
+ players,
+ activePlayers,
+ deck,
+ round: roundNumber,
+ street: "preflop",
+ phase: activePlayers.length >= 2 ? "betting" : "waiting",
+ pot,
+ currentBet: 0,
+ bet: 0,
+ dealerIndex,
+ communityCards: [],
+ hands,
+ folded,
+ acted,
+ contributions,
+ playerBets,
+ stacks,
+ currentPlayerIndex: activePlayers.length >= 2
+ ? getFirstActiveLeftOfDealer({players, folded, dealerIndex})
+ : -1,
+ lastAction: activePlayers.length >= 2
+ ? `Round ${roundNumber} started. $${MINIMUM_STAKE} minimum stake paid by each player.`
+ : `Waiting for at least two players with $${MINIMUM_STAKE}.`,
+ lastBettor: null,
+ winner: null,
+ showdown: null,
+ potAwarded: false,
+ nextRoundTimer: null,
+ nextRoundStartsAt: null,
+ turnTimer: null,
+ turnTimerPlayer: null,
+ turnEndsAt: null,
+ minimumStake: MINIMUM_STAKE
+ };
+
+ startTurnTimer(pokerGames[lobbyId]);
+ return pokerGames[lobbyId];
+}
+
+async function createPokerIntermission(lobbyId) {
+ const players = playerMap[lobbyId] || [];
+ const existingGame = pokerGames[lobbyId];
+ if (existingGame?.nextRoundTimer) {
+ clearTimeout(existingGame.nextRoundTimer);
+ }
+
+ const roundNumber = existingGame ? existingGame.round + 1 : 1;
+ const previousStacks = existingGame?.stacks || {};
+ const stacks = {};
+ const folded = {};
+ const acted = {};
+ const contributions = {};
+ const playerBets = {};
+
+ for (const player of players) {
+ stacks[player] = await getInitialPokerStack(player, previousStacks);
+ folded[player] = true;
+ acted[player] = true;
+ contributions[player] = 0;
+ playerBets[player] = 0;
+ }
+
+ const previousResult = existingGame?.winner
+ ? `${existingGame.lastAction} Next round starts in ${ROUND_INTERMISSION_MS / 1000} seconds.`
+ : `Next round starts in ${ROUND_INTERMISSION_MS / 1000} seconds. Minimum stake: $${MINIMUM_STAKE}.`;
+
+ const intermission = {
+ lobbyId,
+ players,
+ activePlayers: [],
+ deck: [],
+ round: roundNumber,
+ street: "intermission",
+ phase: "intermission",
+ pot: 0,
+ currentBet: 0,
+ bet: 0,
+ dealerIndex: existingGame?.dealerIndex ?? 0,
+ communityCards: [],
+ hands: {},
+ folded,
+ acted,
+ contributions,
+ playerBets,
+ stacks,
+ currentPlayerIndex: -1,
+ lastAction: previousResult,
+ lastBettor: null,
+ winner: existingGame?.winner || null,
+ showdown: existingGame?.showdown || null,
+ potAwarded: false,
+ nextRoundTimer: null,
+ nextRoundStartsAt: Date.now() + ROUND_INTERMISSION_MS,
+ turnTimer: null,
+ turnTimerPlayer: null,
+ turnEndsAt: null,
+ minimumStake: MINIMUM_STAKE
+ };
+
+ pokerGames[lobbyId] = intermission;
+ intermission.nextRoundTimer = setTimeout(async () => {
+ if (!pokerGames[lobbyId] || !playerMap[lobbyId]?.length) return;
+ await createPokerGame(lobbyId);
+ broadcastLobby(lobbyId);
+ broadcastPokerState(lobbyId);
+ }, ROUND_INTERMISSION_MS);
+
+ return intermission;
+}
+
+function getCurrentPlayer(game) {
+ if (game.phase !== "betting") return null;
+ return game.players[game.currentPlayerIndex] || null;
+}
+
+function getActivePlayers(game) {
+ return game.players.filter(player => !game.folded[player] && game.hands[player]);
+}
+
+function canActInBetting(game, player) {
+ return !game.folded[player] && game.hands[player] && (game.stacks[player] || 0) > 0;
+}
+
+function getPlayablePlayers(game) {
+ return game.players.filter(player => (game.stacks[player] || 0) >= MINIMUM_STAKE);
+}
+
+function getCallAmount(game, username) {
+ return Math.max(0, (game.currentBet || 0) - (game.playerBets[username] || 0));
+}
+
+function isSpectator(game, username) {
+ return game.phase !== "intermission" && (!game.hands[username] || (game.stacks[username] || 0) < 0);
+}
+
+function getFirstActiveLeftOfDealer(game) {
+ for (let offset = 1; offset <= game.players.length; offset++) {
+ const index = (game.dealerIndex + offset) % game.players.length;
+ const player = game.players[index];
+ if (game.hands ? canActInBetting(game, player) : !game.folded[player]) return index;
+ }
+ return -1;
+}
+
+function getQueue(game) {
+ if (game.phase !== "betting" || game.currentPlayerIndex < 0) {
+ return getActivePlayers(game);
+ }
+
+ const queue = [];
+ for (let offset = 0; offset < game.players.length; offset++) {
+ const index = (game.currentPlayerIndex + offset) % game.players.length;
+ const player = game.players[index];
+ if (canActInBetting(game, player)) {
+ queue.push(player);
+ }
+ }
+ return queue;
+}
+
+function clearTurnTimer(game) {
+ if (game?.turnTimer) {
+ clearTimeout(game.turnTimer);
+ }
+ if (game) {
+ game.turnTimer = null;
+ game.turnTimerPlayer = null;
+ game.turnEndsAt = null;
+ }
+}
+
+function startTurnTimer(game) {
+ clearTurnTimer(game);
+ const currentPlayer = getCurrentPlayer(game);
+ if (!game || game.phase !== "betting" || !currentPlayer) return;
+
+ game.turnTimerPlayer = currentPlayer;
+ game.turnEndsAt = Date.now() + TURN_TIMEOUT_MS;
+ game.turnTimer = setTimeout(() => {
+ const activeGame = pokerGames[game.lobbyId];
+ if (!activeGame || activeGame !== game || getCurrentPlayer(activeGame) !== currentPlayer) return;
+
+ const result = applyPokerAction(activeGame, currentPlayer, "hit", {}, true);
+ if (result.error && getCurrentPlayer(activeGame) === currentPlayer) {
+ applyPokerAction(activeGame, currentPlayer, "fold", {}, true);
+ }
+ broadcastPokerState(activeGame.lobbyId);
+ }, TURN_TIMEOUT_MS);
+}
+
+function resetBettingRound(game, street) {
+ clearTurnTimer(game);
+ game.street = street;
+ game.phase = "betting";
+ game.currentBet = 0;
+ game.bet = 0;
+ game.lastBettor = null;
+
+ for (const player of game.players) {
+ game.acted[player] = game.folded[player] || (game.hands[player] && (game.stacks[player] || 0) <= 0);
+ game.playerBets[player] = 0;
+ }
+
+ game.currentPlayerIndex = getFirstActiveLeftOfDealer(game);
+ if (game.currentPlayerIndex === -1 || haveAllActivePlayersMatched(game)) {
+ revealNextStreet(game);
+ return;
+ }
+ startTurnTimer(game);
+}
+
+async function completeRoundWithWinner(game, winner) {
+ clearTurnTimer(game);
+ game.phase = "complete";
+ game.currentPlayerIndex = -1;
+ game.winner = winner;
+ if (!game.potAwarded) {
+ game.stacks[winner] += game.pot;
+ game.potAwarded = true;
+ }
+ game.lastAction = `${winner} wins $${game.pot}`;
+ await persistPokerStacks(game);
+ await scheduleNextRound(game);
+}
+
+async function scheduleNextRound(game) {
+ clearTurnTimer(game);
+ if (game.nextRoundTimer || !pokerGames[game.lobbyId]) return;
+
+ await createPokerIntermission(game.lobbyId);
+ broadcastLobby(game.lobbyId);
+ broadcastPokerState(game.lobbyId);
+}
+
+async function maybeStartWaitingGame(lobbyId) {
+ const game = pokerGames[lobbyId];
+ if (!game || game.phase !== "waiting") return;
+ const playersWithChips = game.players.filter(player => (game.stacks[player] || 0) >= MINIMUM_STAKE);
+ if (playersWithChips.length >= 2) {
+ await createPokerIntermission(lobbyId);
+ broadcastPokerState(lobbyId);
+ }
+}
+
+function finishIfOnlyOneActive(game) {
+ const activePlayers = getActivePlayers(game);
+ if (activePlayers.length <= 1) {
+ clearTurnTimer(game);
+ if (activePlayers.length === 1) {
+ completeRoundWithWinner(game, activePlayers[0]).catch(error => {
+ console.error("Failed to complete poker round:", error);
+ });
+ } else {
+ game.phase = "complete";
+ game.currentPlayerIndex = -1;
+ game.lastAction = "Round ended";
+ }
+ return true;
+ }
+ return false;
+}
+
+function haveAllActivePlayersMatched(game) {
+ return getActivePlayers(game).every(player => (
+ game.acted[player] && (game.playerBets[player] === game.currentBet || (game.stacks[player] || 0) <= 0)
+ ));
+}
+
+function revealNextStreet(game) {
+ clearTurnTimer(game);
+ if (game.street === "preflop") {
+ game.communityCards.push(game.deck.pop(), game.deck.pop(), game.deck.pop());
+ resetBettingRound(game, "flop");
+ game.lastAction = "Flop revealed. Second betting round.";
+ return;
+ }
+
+ if (game.street === "flop") {
+ game.communityCards.push(game.deck.pop());
+ resetBettingRound(game, "turn");
+ game.lastAction = "Turn revealed. Third betting round.";
+ return;
+ }
+
+ if (game.street === "turn") {
+ game.communityCards.push(game.deck.pop());
+ resetBettingRound(game, "river");
+ game.lastAction = "River revealed. Final betting round.";
+ return;
+ }
+
+ startShowdown(game).catch(error => {
+ console.error("Failed to start poker showdown:", error);
+ });
+}
+
+function advanceTurn(game) {
+ clearTurnTimer(game);
+ if (finishIfOnlyOneActive(game)) return;
+
+ if (haveAllActivePlayersMatched(game)) {
+ revealNextStreet(game);
+ return;
+ }
+
+ for (let offset = 1; offset <= game.players.length; offset++) {
+ const nextIndex = (game.currentPlayerIndex + offset) % game.players.length;
+ const nextPlayer = game.players[nextIndex];
+ if (canActInBetting(game, nextPlayer)) {
+ game.currentPlayerIndex = nextIndex;
+ startTurnTimer(game);
+ return;
+ }
+ }
+}
+
+function getRankValue(rank) {
+ return {A: 14, K: 13, Q: 12, J: 11, "10": 10, "9": 9, "8": 8, "7": 7, "6": 6, "5": 5, "4": 4, "3": 3, "2": 2}[rank] || 0;
+}
+
+function getCombinations(cards, size, start = 0, current = [], result = []) {
+ if (current.length === size) {
+ result.push([...current]);
+ return result;
+ }
+
+ for (let index = start; index < cards.length; index++) {
+ current.push(cards[index]);
+ getCombinations(cards, size, index + 1, current, result);
+ current.pop();
+ }
+ return result;
+}
+
+function compareScores(a, b) {
+ for (let index = 0; index < Math.max(a.length, b.length); index++) {
+ const diff = (a[index] || 0) - (b[index] || 0);
+ if (diff !== 0) return diff;
+ }
+ return 0;
+}
+
+function evaluateFiveCardHand(cards) {
+ const values = cards.map(card => getRankValue(card.rank)).sort((a, b) => b - a);
+ const suits = cards.map(card => card.suit);
+ const isFlush = suits.every(suit => suit === suits[0]);
+ const uniqueValues = [...new Set(values)].sort((a, b) => b - a);
+ const straightValues = uniqueValues.includes(14)
+ ? [...uniqueValues, 1]
+ : uniqueValues;
+ let straightHigh = 0;
+
+ for (let index = 0; index <= straightValues.length - 5; index++) {
+ const run = straightValues.slice(index, index + 5);
+ if (run[0] - run[4] === 4) {
+ straightHigh = run[0];
+ break;
+ }
+ }
+
+ const counts = values.reduce((acc, value) => {
+ acc[value] = (acc[value] || 0) + 1;
+ return acc;
+ }, {});
+ const groups = Object.entries(counts)
+ .map(([value, count]) => ({value: Number(value), count}))
+ .sort((a, b) => b.count - a.count || b.value - a.value);
+
+ const sortedCards = [...cards].sort((a, b) => getRankValue(b.rank) - getRankValue(a.rank));
+
+ if (isFlush && straightHigh === 14) return {rank: 9, name: "Royal Flush", score: [9, 14], comboCards: sortedCards};
+ if (isFlush && straightHigh) return {rank: 8, name: "Straight Flush", score: [8, straightHigh], comboCards: sortedCards};
+ if (groups[0].count === 4) {
+ const comboCards = [
+ ...sortedCards.filter(card => getRankValue(card.rank) === groups[0].value),
+ ...sortedCards.filter(card => getRankValue(card.rank) !== groups[0].value).slice(0, 1)
+ ];
+ return {rank: 7, name: "Four of a Kind", score: [7, groups[0].value, groups[1].value], comboCards};
+ }
+ if (groups[0].count === 3 && groups[1].count === 2) {
+ const comboCards = [
+ ...sortedCards.filter(card => getRankValue(card.rank) === groups[0].value),
+ ...sortedCards.filter(card => getRankValue(card.rank) === groups[1].value)
+ ];
+ return {rank: 6, name: "Full House", score: [6, groups[0].value, groups[1].value], comboCards};
+ }
+ if (isFlush) return {rank: 5, name: "Flush", score: [5, ...values], comboCards: sortedCards};
+ if (straightHigh) return {rank: 4, name: "Straight", score: [4, straightHigh], comboCards: sortedCards};
+ if (groups[0].count === 3) {
+ const kickers = groups.filter(group => group.count === 1).map(group => group.value);
+ const comboCards = [
+ ...sortedCards.filter(card => getRankValue(card.rank) === groups[0].value),
+ ...sortedCards.filter(card => getRankValue(card.rank) !== groups[0].value)
+ ];
+ return {rank: 3, name: "Three of a Kind", score: [3, groups[0].value, ...kickers], comboCards};
+ }
+ if (groups[0].count === 2 && groups[1].count === 2) {
+ const pairValues = groups.filter(group => group.count === 2).map(group => group.value);
+ const kicker = groups.find(group => group.count === 1).value;
+ const comboCards = [
+ ...sortedCards.filter(card => pairValues.includes(getRankValue(card.rank))),
+ ...sortedCards.filter(card => !pairValues.includes(getRankValue(card.rank)))
+ ];
+ return {rank: 2, name: "Two Pair", score: [2, ...pairValues, kicker], comboCards};
+ }
+ if (groups[0].count === 2) {
+ const kickers = groups.filter(group => group.count === 1).map(group => group.value);
+ const comboCards = [
+ ...sortedCards.filter(card => getRankValue(card.rank) === groups[0].value),
+ ...sortedCards.filter(card => getRankValue(card.rank) !== groups[0].value)
+ ];
+ return {rank: 1, name: "Pair", score: [1, groups[0].value, ...kickers], comboCards};
+ }
+ return {rank: 0, name: "High Card", score: [0, ...values], comboCards: sortedCards};
+}
+
+function evaluateBestHand(cards) {
+ return getCombinations(cards, 5)
+ .map(evaluateFiveCardHand)
+ .sort((a, b) => compareScores(b.score, a.score))[0];
+}
+
+async function startShowdown(game) {
+ const activePlayers = getActivePlayers(game);
+ const evaluations = activePlayers.map(player => ({
+ player,
+ hand: game.hands[player],
+ result: evaluateBestHand([...game.hands[player], ...game.communityCards])
+ }));
+ const rankedResults = [...evaluations].sort((a, b) => compareScores(b.result.score, a.result.score));
+ const bettorIndex = game.lastBettor ? game.players.indexOf(game.lastBettor) : 0;
+ const startIndex = bettorIndex >= 0 ? bettorIndex : 0;
+ const revealOrder = [...evaluations].sort((a, b) => {
+ const aIndex = game.players.indexOf(a.player);
+ const bIndex = game.players.indexOf(b.player);
+ return ((aIndex - startIndex + game.players.length) % game.players.length)
+ - ((bIndex - startIndex + game.players.length) % game.players.length);
+ });
+
+ game.phase = "showdown";
+ game.currentPlayerIndex = -1;
+ game.showdown = revealOrder;
+ game.winner = rankedResults[0]?.player || null;
+ if (game.winner && !game.potAwarded) {
+ game.stacks[game.winner] += game.pot;
+ game.potAwarded = true;
+ }
+ game.lastAction = game.winner
+ ? `Showdown: ${game.winner} wins $${game.pot} with ${rankedResults[0].result.name}`
+ : "Showdown ended";
+ await persistPokerStacks(game);
+ await scheduleNextRound(game);
+}
+
+function createPokerState(game, username) {
+ return {
+ type: "poker:state",
+ lobbyId: game.lobbyId,
+ username,
+ players: game.players,
+ round: game.round,
+ pot: game.pot,
+ bet: game.bet,
+ currentBet: game.currentBet,
+ communityCards: game.communityCards,
+ hand: game.hands[username] || [],
+ currentPlayer: getCurrentPlayer(game),
+ isYourTurn: getCurrentPlayer(game) === username,
+ folded: game.folded,
+ queue: getQueue(game),
+ street: game.street,
+ phase: game.phase,
+ lastAction: game.lastAction,
+ contributions: game.contributions,
+ yourContribution: game.contributions[username] || 0,
+ stacks: game.stacks,
+ yourStack: game.stacks[username] || 0,
+ isSpectator: isSpectator(game, username),
+ playablePlayers: getPlayablePlayers(game),
+ canLeaveTable: true,
+ callAmount: getCallAmount(game, username),
+ minimumStake: game.minimumStake || MINIMUM_STAKE,
+ nextRoundStartsAt: game.nextRoundStartsAt,
+ turnEndsAt: game.turnEndsAt,
+ turnTimeoutMs: TURN_TIMEOUT_MS,
+ winner: game.winner,
+ showdown: game.phase === "showdown" || game.phase === "intermission" ? game.showdown : null
+ };
+}
+
+function broadcastPokerState(lobbyId) {
+ const game = pokerGames[lobbyId];
+ if (!game || !lobbySockets[lobbyId]) return;
+
+ for (const socket of lobbySockets[lobbyId]) {
+ sendSocketMessage(socket, createPokerState(game, socket.user.username));
+ }
+}
+
+function canLeavePokerTable(lobbyId, username) {
+ return Boolean(lobbyId && username);
+}
+
+async function removePlayerFromPokerGame(lobbyId, username) {
+ const game = pokerGames[lobbyId];
+ if (!game) return;
+
+ const remainingStack = game.stacks[username];
+ if (Number.isFinite(remainingStack)) {
+ await setUserMoney(username, remainingStack);
+ }
+
+ const index = game.players.indexOf(username);
+ if (index > -1) {
+ game.players.splice(index, 1);
+ }
+ delete game.hands[username];
+ delete game.folded[username];
+ delete game.acted[username];
+ delete game.contributions[username];
+ delete game.playerBets[username];
+ delete game.stacks[username];
+
+ if (game.players.length === 0) {
+ if (game.nextRoundTimer) {
+ clearTimeout(game.nextRoundTimer);
+ }
+ clearTurnTimer(game);
+ delete pokerGames[lobbyId];
+ return;
+ }
+
+ if (game.currentPlayerIndex >= game.players.length) {
+ game.currentPlayerIndex = 0;
+ }
+ game.dealerIndex = Math.min(game.dealerIndex, game.players.length - 1);
+ if (game.turnTimerPlayer === username) {
+ clearTurnTimer(game);
+ if (game.phase === "betting" && !haveAllActivePlayersMatched(game)) {
+ startTurnTimer(game);
+ }
+ }
+ if (finishIfOnlyOneActive(game)) return;
+ if (game.phase === "betting" && haveAllActivePlayersMatched(game)) {
+ revealNextStreet(game);
+ }
+}
+
+async function ensurePlayerInPokerGame(lobbyId, username) {
+ const game = pokerGames[lobbyId];
+ if (!game || !username) return;
+
+ if (!game.players.includes(username)) {
+ game.players.push(username);
+ }
+
+ if (!Number.isFinite(game.stacks[username])) {
+ game.stacks[username] = await getInitialPokerStack(username, {});
+ }
+ if (!Object.prototype.hasOwnProperty.call(game.folded, username)) {
+ game.folded[username] = true;
+ }
+ if (!Object.prototype.hasOwnProperty.call(game.acted, username)) {
+ game.acted[username] = true;
+ }
+ if (!Object.prototype.hasOwnProperty.call(game.contributions, username)) {
+ game.contributions[username] = 0;
+ }
+ if (!Object.prototype.hasOwnProperty.call(game.playerBets, username)) {
+ game.playerBets[username] = 0;
+ }
+}
+
+function applyPokerAction(game, username, action, payload = {}, automatic = false) {
+ if (game.phase !== "betting") {
+ return {error: "This round is already finished"};
+ }
+
+ if (isSpectator(game, username)) {
+ return {error: "You are out of chips and can only spectate"};
+ }
+
+ if (getCurrentPlayer(game) !== username) {
+ return {error: "It is not your turn"};
+ }
+
+ if (game.folded[username]) {
+ return {error: "You already folded"};
+ }
+
+ if (action === "bet") {
+ const amount = Math.max(0, Number(payload.amount) || 0);
+ if (amount > game.stacks[username]) {
+ return {error: "You do not have enough chips"};
+ }
+ if (amount > 0 && amount < MINIMUM_STAKE) {
+ return {error: `Minimum stake is $${MINIMUM_STAKE}`};
+ }
+ if (amount <= 0 && game.playerBets[username] < game.currentBet) {
+ return {error: `You need to match the current bet of $${game.currentBet}`};
+ }
+ if (game.playerBets[username] + amount < game.currentBet) {
+ return {error: `Minimum bet is $${game.currentBet - game.playerBets[username]} to call`};
+ }
+ clearTurnTimer(game);
+ game.acted[username] = true;
+ game.bet = amount;
+ game.currentBet = Math.max(game.currentBet, game.playerBets[username] + amount);
+ game.playerBets[username] += amount;
+ game.contributions[username] += amount;
+ game.stacks[username] -= amount;
+ game.pot += amount;
+ game.lastBettor = username;
+ game.lastAction = `${username} bet $${amount}`;
+ advanceTurn(game);
+ return {};
+ }
+
+ if (action === "fold") {
+ clearTurnTimer(game);
+ game.folded[username] = true;
+ game.acted[username] = true;
+ game.lastAction = `${username} folded`;
+ advanceTurn(game);
+ return {};
+ }
+
+ if (action === "hit") {
+ const callAmount = getCallAmount(game, username);
+ const paidAmount = Math.min(callAmount, game.stacks[username]);
+ clearTurnTimer(game);
+ if (paidAmount > 0) {
+ game.playerBets[username] += paidAmount;
+ game.contributions[username] += paidAmount;
+ game.stacks[username] -= paidAmount;
+ game.pot += paidAmount;
+ }
+ game.acted[username] = true;
+ game.lastAction = paidAmount > 0
+ ? `${username} ${paidAmount < callAmount ? "went all-in with" : automatic ? "auto-called" : "called"} $${paidAmount}`
+ : `${username} ${automatic ? "auto-checked" : "checked"}`;
+ advanceTurn(game);
+ return {};
+ }
+
+ return {error: "Unknown poker action"};
+}
+
+function sendSocketMessage(socket, payload) {
+ if (socket.readyState === WebSocket.OPEN) {
+ socket.send(JSON.stringify(payload));
+ }
+}
+
+function disconnectKey(lobbyId, username) {
+ return `${lobbyId}:${username}`;
+}
+
+function clearDisconnectTimer(lobbyId, username) {
+ const key = disconnectKey(lobbyId, username);
+ if (disconnectTimers[key]) {
+ clearTimeout(disconnectTimers[key]);
+ delete disconnectTimers[key];
+ }
+}
+
+function hasActiveLobbySocket(lobbyId, username) {
+ return [...(lobbySockets[lobbyId] || [])].some(socket => (
+ socket.user?.username === username && socket.readyState === WebSocket.OPEN
+ ));
+}
+
+async function leavePokerLobby(lobbyId, username) {
+ if (!lobbyId || !username || !playerMap[lobbyId]?.includes(username)) return false;
+
+ clearDisconnectTimer(lobbyId, username);
+ await leaveLobby(lobbyId, username);
+ await removePlayerFromPokerGame(lobbyId, username);
+ await deleteLobby(lobbyId);
+ await setUserLobby(username, null);
+ broadcastLobby(lobbyId);
+ broadcastPokerState(lobbyId);
+ return true;
+}
+
+function scheduleDisconnectLeave(lobbyId, username) {
+ if (!lobbyId || !username || hasActiveLobbySocket(lobbyId, username)) return;
+
+ clearDisconnectTimer(lobbyId, username);
+ disconnectTimers[disconnectKey(lobbyId, username)] = setTimeout(async () => {
+ delete disconnectTimers[disconnectKey(lobbyId, username)];
+ if (hasActiveLobbySocket(lobbyId, username)) return;
+
+ try {
+ await leavePokerLobby(lobbyId, username);
+ } catch (error) {
+ console.error("Failed to leave disconnected player:", error);
+ }
+ }, DISCONNECT_GRACE_MS);
+}
+
+function subscribeToLobby(socket, lobbyId) {
+ if (socket.lobbyId && lobbySockets[socket.lobbyId]) {
+ lobbySockets[socket.lobbyId].delete(socket);
+ }
+ socket.lobbyId = lobbyId;
+ if (!lobbySockets[lobbyId]) {
+ lobbySockets[lobbyId] = new Set();
+ }
+ lobbySockets[lobbyId].add(socket);
+ clearDisconnectTimer(lobbyId, socket.user.username);
+}
+
+function broadcastLobby(lobbyId) {
+ if (!lobbyId || !lobbySockets[lobbyId]) return;
+ const players = playerMap[lobbyId] || [];
+ const gameStarted = Boolean(pokerGames[lobbyId]);
+ const message = {
+ type: "lobby:state",
+ lobbyId,
+ players,
+ host: lobbyHosts[lobbyId] || players[0] || null,
+ isPublic: lobbyPrivacy[lobbyId] !== "private",
+ canStart: players.length >= 2,
+ gameStarted,
+ gameUrl: gameStarted ? `/poker.html?lobbyId=${encodeURIComponent(lobbyId)}` : null
+ };
+ for (const socket of lobbySockets[lobbyId]) {
+ sendSocketMessage(socket, message);
+ }
+}
+
+function createPublicLobbyList() {
+ return Object.entries(playerMap)
+ .filter(([lobbyId]) => lobbyPrivacy[lobbyId] !== "private")
+ .map(([lobbyId, players]) => ({
+ lobbyId,
+ host: lobbyHosts[lobbyId] || players[0] || null,
+ players: players.length,
+ gameStarted: Boolean(pokerGames[lobbyId])
+ }));
+}
+
+function broadcastToLobby(lobbyId, payload) {
+ if (!lobbyId || !lobbySockets[lobbyId]) return;
+ for (const socket of lobbySockets[lobbyId]) {
+ sendSocketMessage(socket, payload);
+ }
+}
async function createLobby() {
let response = await fetch("https://www.deckofcardsapi.com/api/deck/new/shuffle/?deck_count=1")
@@ -197,6 +1121,7 @@ app.post("/leave-lobby", (req, res) => {
playerMap[lobbyId] = playerMap[lobbyId].filter(player => player !== username);
if (playerMap[lobbyId].length === 0) {
delete playerMap[lobbyId];
+ delete lobbyPrivacy[lobbyId];
console.log(`Leere Lobby ${lobbyId} wurde gelöscht.`);
}
res.json({message: `Lobby ${lobbyId} aktualisiert.`});
@@ -207,6 +1132,7 @@ function removeEmptyLobbies(playerMap) {
for (const lobby in playerMap) {
if (playerMap[lobby].length === 0) {
delete playerMap[lobby];
+ delete lobbyPrivacy[lobby];
console.log(`Lobby ${lobby} wurde gelöscht.`);
}
}
@@ -214,7 +1140,14 @@ function removeEmptyLobbies(playerMap) {
async function leaveLobby(id, username) {
if (id && playerMap[id] && username) {
- playerMap[id].splice(playerMap[id].indexOf(username), 1);
+ const index = playerMap[id].indexOf(username);
+ if (index > -1) {
+ playerMap[id].splice(index, 1);
+ if (lobbyHosts[id] === username) {
+ lobbyHosts[id] = playerMap[id][0] || null;
+ }
+ broadcastLobby(id);
+ }
}
}
@@ -222,6 +1155,14 @@ async function deleteLobby(id) {
if (id && playerMap[id]) {
if (playerMap[id].length === 0) {
delete playerMap[id];
+ delete lobbySockets[id];
+ delete lobbyHosts[id];
+ delete lobbyPrivacy[id];
+ if (pokerGames[id]?.nextRoundTimer) {
+ clearTimeout(pokerGames[id].nextRoundTimer);
+ }
+ clearTurnTimer(pokerGames[id]);
+ delete pokerGames[id];
}
}
}
@@ -231,7 +1172,7 @@ async function joinLobby(lobbyId, player_name) {
}
app.get("/lobby/create", authenticateToken, async (req, res) => {
- const username = req.query.username;
+ const username = req.user.username;
if (!username) {
return res.status(400).send("Username is required");
}
@@ -242,20 +1183,17 @@ app.get("/lobby/create", authenticateToken, async (req, res) => {
if (!playerMap[lobbyId].includes(username)) {
playerMap[lobbyId].push(username);
}
- const users = get_data();
- const user = users.find(u => u.username === username);
- if (user) {
- user.lobbyId = lobbyId;
- save_data(users);
- }
+ lobbyHosts[lobbyId] = username;
+ lobbyPrivacy[lobbyId] = req.query.private === "true" ? "private" : "public";
+ broadcastLobby(lobbyId);
+ await setUserLobby(username, lobbyId);
res.redirect(`/lobby/join?lobbyId=${lobbyId}&username=${username}`);
})
app.get("/lobby/join", authenticateToken, async (req, res) => {
let lobbyId = req.query.lobbyId;
- let username = req.query.username;
- const users = get_data();
- const user = users.find(u => u.username === username);
+ let username = req.user.username;
+ const user = await findUserByUsername(username);
if (!user) {
return res.status(401).json({error: "User doesn't exist"});
}
@@ -264,8 +1202,11 @@ app.get("/lobby/join", authenticateToken, async (req, res) => {
if (!playerMap[lobbyId].includes(username)) {
playerMap[lobbyId].push(username);
}
- user.lobbyId = lobbyId;
- save_data(users);
+ await ensurePlayerInPokerGame(lobbyId, username);
+ await maybeStartWaitingGame(lobbyId);
+ broadcastLobby(lobbyId);
+ broadcastPokerState(lobbyId);
+ await setUserLobby(username, lobbyId);
res.json({lobbyId, username});
} else {
console.log("Lobby with " + lobbyId + " not found.");
@@ -275,18 +1216,15 @@ app.get("/lobby/join", authenticateToken, async (req, res) => {
app.delete("/lobby/leave", authenticateToken, async (req, res) => {
const lobbyId = req.query.lobbyId;
- const username = req.query.username;
+ const username = req.user.username;
if (!lobbyId || !username) {
return res.status(400).json({error: "Missing lobbyId or username"});
}
if (lobbyId in playerMap) {
- const index = playerMap[lobbyId].indexOf(username);
- if (index > -1) {
- playerMap[lobbyId].splice(index, 1);
+ if (await leavePokerLobby(lobbyId, username)) {
return res.json({message: "Successfully left lobby"});
- } else {
- return res.status(404).json({error: "User not in lobby"});
}
+ return res.status(404).json({error: "User not in lobby"});
}
res.status(404).json({error: "Lobby not found"});
});
@@ -297,6 +1235,181 @@ app.get("/lobby/players", authenticateToken, async (req, res) => {
res.json({players});
});
-app.listen(port, () => {
- console.log("Server is running on port " + port);
-});
\ No newline at end of file
+app.get("/lobby/public", authenticateToken, async (req, res) => {
+ res.json({lobbies: createPublicLobbyList()});
+});
+
+wss.on("connection", async (socket, request) => {
+ const cookies = parseCookieHeader(request.headers.cookie);
+ const token = request.headers.authorization?.split(" ")[1] || cookies.authorization;
+ const user = await verifyAuthToken(token);
+
+ if (!user) {
+ sendSocketMessage(socket, {type: "auth:error", message: "Authentication failed"});
+ socket.close(1008, "Authentication failed");
+ return;
+ }
+
+ socket.user = user;
+ sendSocketMessage(socket, {type: "auth:success", username: user.username});
+ const storedUser = await findUserByUsername(user.username);
+ if (storedUser?.lobbyId && playerMap[storedUser.lobbyId]?.includes(user.username)) {
+ subscribeToLobby(socket, storedUser.lobbyId);
+ broadcastLobby(storedUser.lobbyId);
+ }
+
+ socket.on("message", async (rawMessage) => {
+ let message;
+ try {
+ message = JSON.parse(rawMessage.toString());
+ } catch {
+ sendSocketMessage(socket, {type: "error", message: "Invalid JSON message"});
+ return;
+ }
+
+ try {
+ if (message.type === "lobby:create") {
+ const lobbyId = await createLobby();
+ playerMap[lobbyId] = playerMap[lobbyId] || [];
+ if (!playerMap[lobbyId].includes(user.username)) {
+ playerMap[lobbyId].push(user.username);
+ }
+ lobbyHosts[lobbyId] = user.username;
+ lobbyPrivacy[lobbyId] = message.private ? "private" : "public";
+
+ await setUserLobby(user.username, lobbyId);
+
+ subscribeToLobby(socket, lobbyId);
+ await ensurePlayerInPokerGame(lobbyId, user.username);
+ await maybeStartWaitingGame(lobbyId);
+ broadcastLobby(lobbyId);
+ broadcastPokerState(lobbyId);
+ return;
+ }
+
+ if (message.type === "lobby:join") {
+ const lobbyId = message.lobbyId;
+ if (!lobbyId || !playerMap[lobbyId]) {
+ sendSocketMessage(socket, {type: "lobby:error", message: "Lobby not found"});
+ return;
+ }
+
+ await joinLobby(lobbyId, user.username);
+ if (!playerMap[lobbyId].includes(user.username)) {
+ playerMap[lobbyId].push(user.username);
+ }
+ await ensurePlayerInPokerGame(lobbyId, user.username);
+ await maybeStartWaitingGame(lobbyId);
+
+ await setUserLobby(user.username, lobbyId);
+
+ subscribeToLobby(socket, lobbyId);
+ broadcastLobby(lobbyId);
+ broadcastPokerState(lobbyId);
+ return;
+ }
+
+ if (message.type === "lobby:leave") {
+ const lobbyId = message.lobbyId || socket.lobbyId;
+ await leavePokerLobby(lobbyId, user.username);
+ if (lobbySockets[lobbyId]) {
+ lobbySockets[lobbyId].delete(socket);
+ }
+ socket.lobbyId = null;
+ sendSocketMessage(socket, {type: "lobby:left", lobbyId});
+ broadcastPokerState(lobbyId);
+ return;
+ }
+
+ if (message.type === "poker:start") {
+ const lobbyId = message.lobbyId || socket.lobbyId;
+ const players = playerMap[lobbyId] || [];
+ if (!lobbyId || !playerMap[lobbyId]) {
+ sendSocketMessage(socket, {type: "poker:error", message: "Lobby not found"});
+ return;
+ }
+ if (lobbyHosts[lobbyId] !== user.username) {
+ sendSocketMessage(socket, {type: "poker:error", message: "Only the lobby host can start the game"});
+ return;
+ }
+ if (players.length < 2) {
+ sendSocketMessage(socket, {type: "poker:error", message: "At least 2 players are needed to start"});
+ return;
+ }
+
+ await createPokerIntermission(lobbyId);
+ broadcastLobby(lobbyId);
+ broadcastPokerState(lobbyId);
+ broadcastToLobby(lobbyId, {
+ type: "poker:started",
+ lobbyId,
+ url: `/poker.html?lobbyId=${encodeURIComponent(lobbyId)}`
+ });
+ return;
+ }
+
+ if (message.type === "poker:join") {
+ const lobbyId = message.lobbyId;
+ if (!lobbyId || !playerMap[lobbyId] || !playerMap[lobbyId].includes(user.username)) {
+ sendSocketMessage(socket, {type: "poker:error", message: "Poker table not found"});
+ return;
+ }
+
+ subscribeToLobby(socket, lobbyId);
+ if (!pokerGames[lobbyId]) {
+ await createPokerIntermission(lobbyId);
+ }
+ await ensurePlayerInPokerGame(lobbyId, user.username);
+ await maybeStartWaitingGame(lobbyId);
+ broadcastPokerState(lobbyId);
+ return;
+ }
+
+ if (message.type === "poker:action") {
+ if (!socket.lobbyId) {
+ sendSocketMessage(socket, {type: "poker:error", message: "Join a lobby before sending poker actions"});
+ return;
+ }
+ const game = pokerGames[socket.lobbyId];
+ if (!game) {
+ sendSocketMessage(socket, {type: "poker:error", message: "Poker game not found"});
+ return;
+ }
+
+ const result = applyPokerAction(game, user.username, message.action, message.payload);
+ if (result.error) {
+ sendSocketMessage(socket, {type: "poker:error", message: result.error});
+ return;
+ }
+
+ broadcastPokerState(socket.lobbyId);
+ return;
+ }
+
+ sendSocketMessage(socket, {type: "error", message: "Unknown message type"});
+ } catch (error) {
+ console.error("WebSocket message error:", error);
+ sendSocketMessage(socket, {type: "error", message: "WebSocket request failed"});
+ }
+ });
+
+ socket.on("close", async () => {
+ if (!socket.lobbyId) return;
+ const lobbyId = socket.lobbyId;
+ if (lobbySockets[lobbyId]) {
+ lobbySockets[lobbyId].delete(socket);
+ }
+ scheduleDisconnectLeave(lobbyId, socket.user.username);
+ });
+});
+
+initializeDatabase()
+ .then(() => {
+ server.listen(port, () => {
+ console.log("Server is running on port " + port);
+ });
+ })
+ .catch((error) => {
+ console.error("Failed to initialize database:", error);
+ process.exit(1);
+ });
diff --git a/package.json b/package.json
index 4d40208..9a066b6 100644
--- a/package.json
+++ b/package.json
@@ -12,11 +12,15 @@
},
"private": true,
"dependencies": {
+ "bcrypt": "^6.0.0",
"cookie-parser": "^1.4.7",
"dotenv": "^16.5.0",
"express": "^5.1.0",
"jsonwebtoken": "^9.0.2",
- "swagger-ui-express": "^5.0.1"
+ "sqlite": "^5.1.1",
+ "sqlite3": "^6.0.1",
+ "swagger-ui-express": "^5.0.1",
+ "ws": "^8.20.0"
},
"devDependencies": {
"swagger-autogen": "^2.23.7"
diff --git a/public/frontend_home.js b/public/frontend_home.js
index b91cb35..81ddd74 100644
--- a/public/frontend_home.js
+++ b/public/frontend_home.js
@@ -3,50 +3,204 @@ document.addEventListener("DOMContentLoaded", async () => {
const balance = document.getElementById("balance");
const hostButton = document.getElementById("host");
const joinButton = document.getElementById("join");
+ const hostForm = document.getElementById("host-form");
+ const createLobbyButton = document.getElementById("create-lobby");
+ const lobbyVisibilityInputs = document.querySelectorAll("input[name='lobby-visibility']");
const joinForm = document.getElementById("join-form");
const joinCodeInput = document.getElementById("join-code");
const joinSubmit = document.getElementById("submit-join");
+ const refreshLobbies = document.getElementById("refresh-lobbies");
+ const publicLobbies = document.getElementById("public-lobbies");
const leaveButton = document.getElementById("leave-button");
- let playerUpdateInterval = null;
+ const joinError = document.getElementById("join-error");
+ const lobbyRoom = document.getElementById("lobby-room");
+ const lobbyIdDisplay = document.getElementById("lobby-id");
+ const playerList = document.getElementById("player-list");
+ const startGameButton = document.getElementById("start-game");
+ let socket = null;
+ let socketReady = null;
+ let currentUsername = null;
+ let currentLobbyId = null;
- const startPlayerUpdates = (lobbyId) => {
- if (playerUpdateInterval) {
- clearInterval(playerUpdateInterval);
- }
-
- playerUpdateInterval = setInterval(async () => {
- const res = await fetch(`/lobby/players?lobbyId=${lobbyId}`);
- const data = await res.json();
- const playerList = document.getElementById("player-list");
- playerList.innerHTML = data.players.map(p => `
${p}
`).join("") || "No players yet
";
- }, 3000);
+ const showJoinError = (message) => {
+ joinError.textContent = message;
+ joinError.classList.remove("hidden");
};
- leaveButton.addEventListener("click", async () => {
- const username = await getUsernameFromToken();
- const lobbyId = document.getElementById("lobby-id").textContent;
- try {
- const res = await fetch(`/lobby/leave?lobbyId=${lobbyId}&username=${username}`, {
- method: "DELETE",
- });
- if (res.ok) {
- // Stoppe das Interval beim Verlassen
- if (playerUpdateInterval) {
- clearInterval(playerUpdateInterval);
- playerUpdateInterval = null;
- }
- document.getElementById("lobby-room").classList.add("hidden");
- document.getElementById("player-list").innerHTML = "Waiting for players...
";
- document.getElementById("lobby-id").textContent = "N/A";
- } else {
- const err = await res.json();
- alert("Failed to leave lobby: " + (err.error || "Unknown error"));
- }
- } catch (err) {
- console.error("Error leaving lobby:", err);
- alert("Could not leave lobby.");
+ const clearJoinError = () => {
+ joinError.textContent = "";
+ joinError.classList.add("hidden");
+ };
+
+ const renderPlayers = (players) => {
+ playerList.innerHTML = "";
+
+ if (!players.length) {
+ playerList.innerHTML = "No players yet
";
+ return;
}
- });
+
+ for (const player of players) {
+ const entry = document.createElement("p");
+ entry.textContent = player;
+ playerList.appendChild(entry);
+ }
+ };
+
+ const updateStartButton = (host, canStart) => {
+ const isHost = currentUsername && host === currentUsername;
+ startGameButton.classList.toggle("hidden", !isHost);
+ startGameButton.disabled = !isHost || !canStart;
+ startGameButton.textContent = canStart ? "Start Game" : "Waiting for 2 players";
+ };
+
+ const showLobby = (lobbyId, players = [], host = null, canStart = false) => {
+ currentLobbyId = lobbyId;
+ lobbyIdDisplay.textContent = lobbyId;
+ lobbyRoom.classList.remove("hidden");
+ joinForm.classList.add("hidden");
+ hostForm.classList.add("hidden");
+ renderPlayers(players);
+ updateStartButton(host, canStart);
+ };
+
+ const joinLobby = (lobbyId) => {
+ if (!currentUsername) {
+ showJoinError("User not authenticated.");
+ return Promise.reject(new Error("User not authenticated"));
+ }
+ return sendSocketMessage({type: "lobby:join", lobbyId});
+ };
+
+ const renderPublicLobbies = (lobbies = []) => {
+ publicLobbies.innerHTML = "";
+
+ if (!lobbies.length) {
+ publicLobbies.innerHTML = "No public lobbies found
";
+ return;
+ }
+
+ lobbies.forEach((lobby) => {
+ const row = document.createElement("div");
+ row.className = "public-lobby";
+
+ const info = document.createElement("div");
+ info.className = "public-lobby-info";
+
+ const title = document.createElement("strong");
+ title.textContent = lobby.host ? `${lobby.host}'s lobby` : "Public lobby";
+ info.appendChild(title);
+
+ const meta = document.createElement("span");
+ meta.textContent = `${lobby.players} player${lobby.players === 1 ? "" : "s"} - ${lobby.gameStarted ? "Game running" : "Waiting"}`;
+ info.appendChild(meta);
+
+ const join = document.createElement("button");
+ join.type = "button";
+ join.textContent = "Join";
+ join.addEventListener("click", () => {
+ clearJoinError();
+ joinLobby(lobby.lobbyId).catch(() => showJoinError("Could not join lobby."));
+ });
+
+ row.appendChild(info);
+ row.appendChild(join);
+ publicLobbies.appendChild(row);
+ });
+ };
+
+ const loadPublicLobbies = async () => {
+ try {
+ publicLobbies.innerHTML = "Loading lobbies...
";
+ const res = await fetch("/lobby/public");
+ if (!res.ok) {
+ throw new Error("Failed to load lobbies");
+ }
+ const data = await res.json();
+ renderPublicLobbies(data.lobbies || []);
+ } catch (error) {
+ publicLobbies.innerHTML = "Could not load lobbies
";
+ }
+ };
+
+ const hideLobby = () => {
+ currentLobbyId = null;
+ lobbyRoom.classList.add("hidden");
+ lobbyIdDisplay.textContent = "N/A";
+ playerList.innerHTML = "Waiting for players...
";
+ startGameButton.classList.add("hidden");
+ startGameButton.disabled = true;
+ };
+
+ const sendSocketMessage = async (message) => {
+ const ws = await connectSocket();
+ ws.send(JSON.stringify(message));
+ };
+
+ const connectSocket = () => {
+ if (socket && socket.readyState === WebSocket.OPEN) {
+ return Promise.resolve(socket);
+ }
+
+ if (socketReady) {
+ return socketReady;
+ }
+
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+ socket = new WebSocket(`${protocol}//${window.location.host}`);
+
+ socketReady = new Promise((resolve, reject) => {
+ socket.addEventListener("open", () => resolve(socket), {once: true});
+ socket.addEventListener("error", () => reject(new Error("WebSocket connection failed")), {once: true});
+ });
+
+ socket.addEventListener("message", (event) => {
+ const message = JSON.parse(event.data);
+
+ if (message.type === "auth:error") {
+ showJoinError(message.message);
+ return;
+ }
+
+ if (message.type === "auth:success") {
+ currentUsername = message.username;
+ return;
+ }
+
+ if (message.type === "lobby:state") {
+ clearJoinError();
+ if (message.gameStarted) {
+ localStorage.setItem("activeLobbyId", message.lobbyId);
+ window.location.href = message.gameUrl || `/poker.html?lobbyId=${encodeURIComponent(message.lobbyId)}`;
+ return;
+ }
+ showLobby(message.lobbyId, message.players || [], message.host, Boolean(message.canStart));
+ return;
+ }
+
+ if (message.type === "poker:started") {
+ localStorage.setItem("activeLobbyId", message.lobbyId);
+ window.location.href = message.url || `/poker.html?lobbyId=${encodeURIComponent(message.lobbyId)}`;
+ return;
+ }
+
+ if (message.type === "lobby:left") {
+ hideLobby();
+ return;
+ }
+
+ if (message.type === "lobby:error" || message.type === "error" || message.type === "poker:error") {
+ showJoinError(message.message || "WebSocket request failed");
+ }
+ });
+
+ socket.addEventListener("close", () => {
+ socket = null;
+ socketReady = null;
+ });
+
+ return socketReady;
+ };
const getUsernameFromToken = async () => {
try {
@@ -54,24 +208,35 @@ document.addEventListener("DOMContentLoaded", async () => {
const data = await res.json();
return data.user?.username;
} catch (err) {
- console.error("Failed to get username from token");
+ console.error("Failed to get username from token", err);
return null;
}
};
try {
- const balanceRes = await fetch('/balance', {method: "GET"});
+ await connectSocket();
+ currentUsername = await getUsernameFromToken();
+ } catch (error) {
+ showJoinError("Real-time poker connection failed.");
+ }
+
+ try {
+ const balanceRes = await fetch("/balance", {method: "GET"});
if (balanceRes.ok) {
const balanceData = await balanceRes.json();
- balance.innerHTML = balanceData.balance;
+ balance.textContent = balanceData.balance;
}
} catch (error) {
console.log("Balance error:", error);
}
+
logoutButton.addEventListener("click", async (e) => {
e.preventDefault();
try {
- const logoutRes = await fetch('/logout', {method: "DELETE"});
+ if (socket && socket.readyState === WebSocket.OPEN) {
+ socket.close();
+ }
+ const logoutRes = await fetch("/logout", {method: "DELETE"});
if (logoutRes.ok) {
document.cookie = "authorization=; Max-Age=0; path=/;";
window.location.href = "/login.html";
@@ -80,74 +245,80 @@ document.addEventListener("DOMContentLoaded", async () => {
console.log("Logout error:", err);
}
});
- // Im hostButton Event Listener
+
hostButton.addEventListener("click", async (e) => {
e.preventDefault();
- const username = await getUsernameFromToken();
- if (!username) return alert("User not authenticated");
- try {
- const res = await fetch(`/lobby/create?username=${username}`);
- if (res.redirected) {
- const joinURL = new URL(res.url);
- const lobbyId = joinURL.searchParams.get("lobbyId");
- const joinRes = await fetch(joinURL.href);
- const {lobbyId: finalLobbyId} = await joinRes.json();
- document.getElementById("lobby-id").textContent = finalLobbyId;
- document.getElementById("lobby-room").classList.remove("hidden");
- // ... bisheriger Code bis zum Interval ...
- startPlayerUpdates(finalLobbyId);
- }
- } catch (err) {
- console.error("Lobby create/join error:", err);
- }
+ clearJoinError();
+ hostForm.classList.toggle("hidden");
+ joinForm.classList.add("hidden");
});
+
+ createLobbyButton.addEventListener("click", async (e) => {
+ e.preventDefault();
+ clearJoinError();
+ if (!currentUsername) {
+ showJoinError("User not authenticated.");
+ return;
+ }
+ const selectedVisibility = [...lobbyVisibilityInputs].find(input => input.checked)?.value || "public";
+ sendSocketMessage({type: "lobby:create", private: selectedVisibility === "private"}).catch(() => {
+ showJoinError("Could not create lobby.");
+ });
+ });
+
joinButton.addEventListener("click", (e) => {
e.preventDefault();
- if (joinForm.classList.contains("hidden")) {
- joinForm.classList.remove("hidden");
+ joinForm.classList.toggle("hidden");
+ hostForm.classList.add("hidden");
+ if (!joinForm.classList.contains("hidden")) {
joinCodeInput.focus();
- } else {
- joinForm.classList.add("hidden");
+ loadPublicLobbies();
}
});
- // Im joinSubmit Event Listener
+
+ refreshLobbies.addEventListener("click", (e) => {
+ e.preventDefault();
+ loadPublicLobbies();
+ });
+
joinSubmit.addEventListener("click", async (e) => {
e.preventDefault();
+ clearJoinError();
+
const lobbyId = joinCodeInput.value.trim();
- const joinError = document.getElementById("join-error");
- joinError.classList.add("hidden"); // hide any previous error
- joinError.textContent = "";
if (!lobbyId) {
- joinError.textContent = "Please enter a lobby ID.";
- joinError.classList.remove("hidden");
+ showJoinError("Please enter a lobby ID.");
return;
}
- const username = await getUsernameFromToken();
- if (!username) {
- joinError.textContent = "User not authenticated.";
- joinError.classList.remove("hidden");
+
+ if (!currentUsername) {
+ showJoinError("User not authenticated.");
return;
}
- try {
- const joinRes = await fetch(`/lobby/join?lobbyId=${lobbyId}&username=${username}`);
- if (joinRes.ok) {
- const {lobbyId: finalLobbyId} = await joinRes.json();
- document.getElementById("lobby-id").textContent = finalLobbyId;
- document.getElementById("lobby-room").classList.remove("hidden");
- joinForm.classList.add("hidden");
- joinCodeInput.value = "";
- // ... bisheriger Code bis zum Interval ...
- startPlayerUpdates(finalLobbyId);
- } else {
- const error = await joinRes.json();
- joinError.textContent = "Join failed: " + (error.error || "Unknown error");
- joinError.classList.remove("hidden");
- }
- } catch (err) {
- console.error("Join lobby error:", err);
- joinError.textContent = "Could not join lobby. Please try again.";
- joinError.classList.remove("hidden");
- }
+
+ joinLobby(lobbyId).then(() => {
+ joinForm.classList.add("hidden");
+ joinCodeInput.value = "";
+ }).catch(() => {
+ showJoinError("Could not join lobby. Please try again.");
+ });
+ });
+
+ leaveButton.addEventListener("click", (e) => {
+ e.preventDefault();
+ const lobbyId = lobbyIdDisplay.textContent;
+ if (!lobbyId || lobbyId === "N/A") return;
+ sendSocketMessage({type: "lobby:leave", lobbyId}).catch(() => {
+ showJoinError("Could not leave lobby.");
+ });
+ });
+
+ startGameButton.addEventListener("click", (e) => {
+ e.preventDefault();
+ if (!currentLobbyId || startGameButton.disabled) return;
+ sendSocketMessage({type: "poker:start", lobbyId: currentLobbyId}).catch(() => {
+ showJoinError("Could not start game.");
+ });
});
async function loadLeaderboard() {
@@ -164,7 +335,7 @@ document.addEventListener("DOMContentLoaded", async () => {
item.textContent = `${i + 1}. ${player.username} - $${player.money}`;
if (player.username === data.self.username) {
item.classList.add("you");
- item.textContent += " 👑";
+ item.textContent += " (you)";
}
list.appendChild(item);
});
@@ -179,4 +350,4 @@ document.addEventListener("DOMContentLoaded", async () => {
}
await loadLeaderboard();
-});
\ No newline at end of file
+});
diff --git a/public/frontend_poker.js b/public/frontend_poker.js
new file mode 100644
index 0000000..ae7f202
--- /dev/null
+++ b/public/frontend_poker.js
@@ -0,0 +1,342 @@
+document.addEventListener("DOMContentLoaded", () => {
+ const roundDisplay = document.getElementById("round");
+ const streetDisplay = document.getElementById("street");
+ const potDisplay = document.getElementById("pot");
+ const communityCards = document.getElementById("community-cards");
+ const playerCards = document.getElementById("player-cards");
+ const spectatorNote = document.getElementById("spectator-note");
+ const playerName = document.getElementById("player-name");
+ const currentBet = document.getElementById("current-bet");
+ const yourContribution = document.getElementById("your-contribution");
+ const yourStack = document.getElementById("your-stack");
+ const currentTurn = document.getElementById("current-turn");
+ const turnState = document.getElementById("turn-state");
+ const turnQueue = document.getElementById("turn-queue");
+ const lastAction = document.getElementById("last-action");
+ const showdownPanel = document.getElementById("showdown-panel");
+ const errorMessage = document.getElementById("error-message");
+ const leaveTable = document.getElementById("leave-table");
+ const betModal = document.getElementById("bet-modal");
+ const closeBet = document.getElementById("close-bet");
+ const betRange = document.getElementById("bet-range");
+ const betValue = document.getElementById("bet-value");
+ const decreaseBet = document.getElementById("decrease-bet");
+ const increaseBet = document.getElementById("increase-bet");
+ const confirmBet = document.getElementById("confirm-bet");
+ const actionButtons = document.querySelectorAll(".actions button");
+ const params = new URLSearchParams(window.location.search);
+ const lobbyId = params.get("lobbyId") || localStorage.getItem("activeLobbyId");
+ let socket = null;
+ let socketReady = null;
+ let isYourTurn = false;
+ let canLeaveTable = true;
+ let isSpectator = false;
+ let currentStackValue = 400;
+ let minimumStake = 10;
+ let callAmount = 0;
+ let countdownTimer = null;
+ const actionTooltips = {
+ hit: "Check or call if your bet already matches the table.",
+ fold: "Give up this hand and lose your current stake.",
+ bet: "Open the bet dialog to add chips to the pot."
+ };
+
+ actionButtons.forEach((button) => {
+ const tooltip = actionTooltips[button.dataset.action];
+ if (!tooltip) return;
+ button.title = tooltip;
+ button.setAttribute("aria-label", `${button.textContent.trim()}: ${tooltip}`);
+ });
+ closeBet.title = "Close the bet dialog.";
+ decreaseBet.title = "Decrease the bet amount.";
+ increaseBet.title = "Increase the bet amount.";
+ confirmBet.title = "Place the selected bet.";
+
+ const showError = (message) => {
+ errorMessage.textContent = message;
+ errorMessage.classList.remove("hidden");
+ };
+
+ const hideError = () => {
+ errorMessage.textContent = "";
+ errorMessage.classList.add("hidden");
+ };
+
+ const renderCard = (card, fallback) => {
+ const cardElement = document.createElement("div");
+ cardElement.className = "card";
+ if (!card) {
+ cardElement.classList.add("placeholder");
+ cardElement.textContent = fallback;
+ return cardElement;
+ }
+
+ cardElement.classList.toggle("red", card.suit === "H" || card.suit === "D");
+ cardElement.innerHTML = `${card.rank}${card.suit}`;
+ return cardElement;
+ };
+
+ const renderCards = (container, cards, count, fallbackPrefix) => {
+ container.innerHTML = "";
+ for (let index = 0; index < count; index++) {
+ container.appendChild(renderCard(cards[index], `${fallbackPrefix} ${index + 1}`));
+ }
+ };
+
+ const renderQueue = (queue = [], currentPlayer = null) => {
+ turnQueue.innerHTML = "";
+ queue.forEach((player, index) => {
+ const item = document.createElement("li");
+ item.textContent = index === 0 ? `${player} (now)` : player;
+ item.classList.toggle("active", player === currentPlayer);
+ turnQueue.appendChild(item);
+ });
+ };
+
+ const renderShowdown = (showdown = [], winner = null) => {
+ showdownPanel.innerHTML = "";
+ showdownPanel.classList.toggle("hidden", showdown.length === 0 && !winner);
+
+ if (winner) {
+ const heading = document.createElement("h2");
+ heading.textContent = `Winner: ${winner}`;
+ showdownPanel.appendChild(heading);
+ }
+
+ showdown.forEach((entry) => {
+ const row = document.createElement("div");
+ row.className = "showdown-row";
+
+ const name = document.createElement("span");
+ name.textContent = `${entry.player}: ${entry.result.name}`;
+ row.appendChild(name);
+
+ const cards = document.createElement("div");
+ cards.className = "mini-cards";
+ (entry.result.comboCards || entry.hand).forEach((card) => cards.appendChild(renderCard(card, "?")));
+ row.appendChild(cards);
+
+ showdownPanel.appendChild(row);
+ });
+ };
+
+ const sendSocketMessage = async (message) => {
+ const ws = await connectSocket();
+ ws.send(JSON.stringify(message));
+ };
+
+ const updateActionButtons = () => {
+ actionButtons.forEach((button) => {
+ button.disabled = !isYourTurn || isSpectator;
+ if (button.dataset.action === "hit") {
+ button.title = callAmount > 0
+ ? `Call with $${Math.min(callAmount, currentStackValue)}.`
+ : actionTooltips.hit;
+ }
+ });
+ confirmBet.disabled = !isYourTurn || isSpectator;
+ leaveTable.disabled = false;
+ leaveTable.title = "Leave table. Chips already in the pot are lost.";
+ };
+
+ const stopCountdown = () => {
+ if (countdownTimer) {
+ clearInterval(countdownTimer);
+ countdownTimer = null;
+ }
+ };
+
+ const renderIntermissionCountdown = (startsAt, baseMessage) => {
+ stopCountdown();
+ const updateCountdown = () => {
+ const seconds = Math.max(0, Math.ceil((Number(startsAt) - Date.now()) / 1000));
+ currentTurn.textContent = `${seconds}s`;
+ turnState.textContent = "Waiting for next round";
+ lastAction.textContent = baseMessage || `Next round starts in ${seconds}s. Minimum stake: $${minimumStake}.`;
+ if (seconds <= 0) {
+ stopCountdown();
+ }
+ };
+ updateCountdown();
+ countdownTimer = setInterval(updateCountdown, 1000);
+ };
+
+ const renderTurnCountdown = (startsAt, currentPlayer) => {
+ stopCountdown();
+ const updateCountdown = () => {
+ const seconds = Math.max(0, Math.ceil((Number(startsAt) - Date.now()) / 1000));
+ currentTurn.textContent = `${currentPlayer || "Player"} (${seconds}s)`;
+ turnState.textContent = isYourTurn
+ ? `Your decision (${seconds}s)`
+ : `Waiting for other player (${seconds}s)`;
+ if (seconds <= 0) {
+ stopCountdown();
+ }
+ };
+ updateCountdown();
+ countdownTimer = setInterval(updateCountdown, 1000);
+ };
+
+ const connectSocket = () => {
+ if (socket && socket.readyState === WebSocket.OPEN) {
+ return Promise.resolve(socket);
+ }
+
+ if (socketReady) {
+ return socketReady;
+ }
+
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+ socket = new WebSocket(`${protocol}//${window.location.host}`);
+
+ socketReady = new Promise((resolve, reject) => {
+ socket.addEventListener("open", () => resolve(socket), {once: true});
+ socket.addEventListener("error", () => reject(new Error("WebSocket connection failed")), {once: true});
+ });
+
+ socket.addEventListener("message", (event) => {
+ const message = JSON.parse(event.data);
+
+ if (message.type === "poker:state") {
+ hideError();
+ isYourTurn = Boolean(message.isYourTurn);
+ canLeaveTable = Boolean(message.canLeaveTable);
+ isSpectator = Boolean(message.isSpectator);
+ currentStackValue = Number(message.yourStack ?? 0);
+ minimumStake = Number(message.minimumStake || 10);
+ callAmount = Number(message.callAmount || 0);
+ roundDisplay.textContent = message.round;
+ potDisplay.textContent = message.pot;
+ currentBet.textContent = message.currentBet ?? message.bet;
+ yourContribution.textContent = message.yourContribution || 0;
+ yourStack.textContent = currentStackValue;
+ streetDisplay.textContent = message.street || "Preflop";
+ playerName.textContent = message.username;
+ if (message.phase === "intermission" && message.nextRoundStartsAt) {
+ renderIntermissionCountdown(message.nextRoundStartsAt, message.lastAction);
+ } else if (message.phase === "betting" && message.turnEndsAt) {
+ renderTurnCountdown(message.turnEndsAt, message.currentPlayer);
+ lastAction.textContent = message.lastAction || "";
+ } else {
+ stopCountdown();
+ currentTurn.textContent = message.currentPlayer || "Round ended";
+ turnState.textContent = isSpectator
+ ? "Spectating"
+ : isYourTurn
+ ? "Your decision"
+ : message.phase === "betting" ? "Waiting for other player" : message.phase;
+ lastAction.textContent = message.lastAction || "";
+ }
+ renderCards(communityCards, message.communityCards || [], Math.max(3, message.communityCards?.length || 0), "Dealer Card");
+ renderCards(playerCards, message.hand || [], isSpectator ? 0 : 2, "Card");
+ spectatorNote.classList.toggle("hidden", !isSpectator);
+ renderQueue(message.queue || [], message.currentPlayer);
+ renderShowdown(message.showdown || [], message.winner);
+ updateActionButtons();
+ return;
+ }
+
+ if (message.type === "poker:action") {
+ return;
+ }
+
+ if (message.type === "lobby:left") {
+ localStorage.removeItem("activeLobbyId");
+ window.location.href = "/";
+ return;
+ }
+
+ if (message.type === "auth:error" || message.type === "poker:error" || message.type === "error") {
+ showError(message.message || "Poker connection failed");
+ }
+ });
+
+ socket.addEventListener("close", () => {
+ socket = null;
+ socketReady = null;
+ });
+
+ return socketReady;
+ };
+
+ const setBetValue = (value) => {
+ const nextValue = Math.max(Number(betRange.min), Math.min(Number(betRange.max), Number(value)));
+ betRange.value = nextValue;
+ betValue.textContent = nextValue;
+ };
+
+ const updateBetRange = () => {
+ const maxBet = Math.max(0, currentStackValue);
+ const requiredBet = Math.max(minimumStake, callAmount);
+ const minBet = maxBet > 0 ? Math.min(requiredBet, maxBet) : 0;
+ betRange.min = String(minBet);
+ betRange.max = String(maxBet);
+ betRange.step = String(minimumStake);
+ setBetValue(Math.max(minBet, Number(betRange.value) || minBet));
+ };
+
+ if (!lobbyId) {
+ showError("No active lobby found.");
+ isYourTurn = false;
+ updateActionButtons();
+ return;
+ }
+
+ updateActionButtons();
+
+ connectSocket()
+ .then(() => sendSocketMessage({type: "poker:join", lobbyId}))
+ .catch(() => showError("Could not connect to poker table."));
+
+ actionButtons.forEach((button) => {
+ button.addEventListener("click", () => {
+ const action = button.dataset.action;
+ if (isSpectator) {
+ showError("You are out of chips and can only spectate.");
+ return;
+ }
+ if (!isYourTurn) {
+ showError("Wait until it is your turn.");
+ return;
+ }
+ if (action === "bet") {
+ updateBetRange();
+ betModal.classList.remove("hidden");
+ return;
+ }
+
+ sendSocketMessage({type: "poker:action", action, payload: {lobbyId}}).catch(() => {
+ showError("Could not send poker action.");
+ });
+ });
+ });
+
+ leaveTable.addEventListener("click", () => {
+ sendSocketMessage({type: "lobby:leave", lobbyId}).catch(() => {
+ showError("Could not leave table.");
+ });
+ });
+
+ closeBet.addEventListener("click", () => betModal.classList.add("hidden"));
+ betRange.addEventListener("input", () => setBetValue(betRange.value));
+ decreaseBet.addEventListener("click", () => setBetValue(Number(betRange.value) - 10));
+ increaseBet.addEventListener("click", () => setBetValue(Number(betRange.value) + 10));
+ confirmBet.addEventListener("click", () => {
+ if (isSpectator) {
+ showError("You are out of chips and can only spectate.");
+ betModal.classList.add("hidden");
+ return;
+ }
+ if (!isYourTurn) {
+ showError("Wait until it is your turn.");
+ betModal.classList.add("hidden");
+ return;
+ }
+ sendSocketMessage({
+ type: "poker:action",
+ action: "bet",
+ payload: {amount: Number(betRange.value), lobbyId}
+ }).catch(() => showError("Could not place bet."));
+ betModal.classList.add("hidden");
+ });
+});
diff --git a/public/home.html b/public/home.html
index cf9cbf9..82b723c 100644
--- a/public/home.html
+++ b/public/home.html
@@ -25,9 +25,31 @@
-