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/promises"); const crypto = require("crypto"); const dotenv = require("dotenv").config(); const jwt = require("jsonwebtoken"); const cookieParser = require("cookie-parser"); 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; const authCookieOptions = { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "Strict", path: "/" }; app.use(express.json()); app.use("/swagger-ui", swaggerUi.serve, swaggerUi.setup(swaggerDocument)); app.use(cookieParser()); function logAuthEvent(level, message, details = {}) { const safeDetails = Object.entries(details).reduce((result, [key, value]) => { if (value !== undefined) result[key] = value; return result; }, {}); const logger = console[level] || console.log; logger(`[auth] ${message}`, safeDetails); } function getTokenLogDetails(token) { if (!token) { return { present: false }; } const decoded = jwt.decode(token) || {}; return { present: true, fingerprint: crypto.createHash("sha256").update(token).digest("hex").slice(0, 12), length: token.length, subject: decoded.username, issuedAt: decoded.iat ? new Date(decoded.iat * 1000).toISOString() : undefined, expiresAt: decoded.exp ? new Date(decoded.exp * 1000).toISOString() : undefined }; } function getAuthRequestDetails(request) { return { ip: request.ip, method: request.method, path: request.originalUrl || request.url, userAgent: request.get("user-agent"), hasBearerHeader: Boolean(request.headers.authorization), hasAuthCookie: Boolean(request.cookies?.authorization) }; } /* Here are Helper function ----------------------------------------------------------------------------------------------- */ 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"); 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); } } } async function getUsers() { return db.all("SELECT username, token, money, lobby_id AS lobbyId FROM 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) { logAuthEvent("info", "Generating access token", {username: user.username}); const token = jwt.sign(user, process.env.TOKEN_SECRET, {expiresIn: "1800s"}); logAuthEvent("info", "Generated access token", { username: user.username, token: getTokenLogDetails(token) }); return token; } function setAuthCookie(response, token) { logAuthEvent("info", "Setting authorization cookie", { secure: authCookieOptions.secure, sameSite: authCookieOptions.sameSite, httpOnly: authCookieOptions.httpOnly, token: getTokenLogDetails(token) }); response.cookie("authorization", token, authCookieOptions); } function normalizeUsername(username) { return typeof username === "string" ? username.trim().toLowerCase() : ""; } function isValidPassword(password) { return typeof password === "string" && password.length > 0; } 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) { logAuthEvent("warn", "Token verification skipped because token is missing"); return null; } logAuthEvent("info", "Verifying access token", {token: getTokenLogDetails(token)}); try { const decoded = jwt.verify(token, process.env.TOKEN_SECRET); logAuthEvent("info", "JWT signature verified", { username: decoded.username, token: getTokenLogDetails(token) }); const foundUser = await findUserByUsername(decoded.username); if (!foundUser) { logAuthEvent("warn", "Token verification failed because user was not found", { username: decoded.username, token: getTokenLogDetails(token) }); return null; } if (foundUser.token !== token) { logAuthEvent("warn", "Token verification failed because stored token does not match", { username: decoded.username, incomingToken: getTokenLogDetails(token), storedToken: getTokenLogDetails(foundUser.token) }); return null; } logAuthEvent("info", "Token verified against stored user session", { username: decoded.username, token: getTokenLogDetails(token) }); return decoded; } catch (error) { logAuthEvent("warn", "Token verification failed", { error: error.message, name: error.name, token: getTokenLogDetails(token) }); return null; } } async function authenticateToken(request, response, next) { /* #swagger.security = [{ "bearerAuth": [] }] */ const token = getAuthTokenFromRequest(request); logAuthEvent("info", "Authenticating request", { ...getAuthRequestDetails(request), token: getTokenLogDetails(token) }); if (token == null) { logAuthEvent("warn", "Authentication failed because token is missing", getAuthRequestDetails(request)); return response.sendStatus(401); } const user = await verifyAuthToken(token); if (!user) { logAuthEvent("warn", "Authentication failed because token is invalid or revoked", { ...getAuthRequestDetails(request), token: getTokenLogDetails(token) }); return response.status(403).json({error: "Token has been revoked or is invalid"}); } request.user = user; logAuthEvent("info", "Request authenticated", { ...getAuthRequestDetails(request), username: user.username, token: getTokenLogDetails(token) }); next(); } /* Here is the the Login logic ----------------------------------------------------------------------------------------------- */ app.post("/register", async (request, response) => { let {username, password} = request.body; logAuthEvent("info", "Register request received", { ...getAuthRequestDetails(request), username: normalizeUsername(username), hasPassword: isValidPassword(password) }); username = normalizeUsername(username); if (!username || !isValidPassword(password)) { logAuthEvent("warn", "Register request rejected because credentials are incomplete", { username, hasPassword: isValidPassword(password) }); return response.status(400).json({error: "Username and password are required"}); } const existingUser = await findUserByUsername(username); if (existingUser) { logAuthEvent("warn", "Register request rejected because username already exists", {username}); return response.status(400).json({error: "Username already exists"}); } const token = generateAccessToken({username}); await saveUser({username, password, token, money: 200}); logAuthEvent("info", "Registered user and stored initial token", { username, token: getTokenLogDetails(token) }); setAuthCookie(response, token); logAuthEvent("info", "Register request completed", {username}); response.json({username, token}); }); 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, money: u.money, })); const username = req.user.username; const position = sorted.findIndex(u => u.username === username) + 1; const self = sorted.find(u => u.username === username); res.json({ leaderboard: top10, self: { username: self.username, money: self.money, position } }); }); app.get("/login", (request, response) => { response.sendFile(path.join(__dirname, "./public/login.html")); }) app.post("/login", async (request, response) => { let {username, password} = request.body; logAuthEvent("info", "Login request received", { ...getAuthRequestDetails(request), username: normalizeUsername(username), hasPassword: isValidPassword(password) }); username = normalizeUsername(username); if (!username || !isValidPassword(password)) { logAuthEvent("warn", "Login request rejected because credentials are incomplete", { username, hasPassword: isValidPassword(password) }); return response.status(400).json({error: "Username and password are required"}); } const user = await findUserByUsername(username); if (!user) { logAuthEvent("warn", "Login request rejected because user was not found", {username}); return response.status(401).json({error: "Invalid credentials"}); } logAuthEvent("info", "Login user record found", { username, existingToken: getTokenLogDetails(user.token) }); const passwordMatches = await bcrypt.compare(password, user.passwordHash); if (!passwordMatches) { logAuthEvent("warn", "Login request rejected because password did not match", {username}); return response.status(401).json({error: "Invalid password"}); } logAuthEvent("info", "Login password matched", {username}); const token = generateAccessToken({username}); await setUserToken(username, token); logAuthEvent("info", "Stored new login token", { username, token: getTokenLogDetails(token) }); setAuthCookie(response, token); logAuthEvent("info", "Login request completed", { username, token: getTokenLogDetails(token) }); response.json({username, token}); }); app.get("/", async (req, res) => { const token = req.headers.authorization?.split(" ")[1] || req.cookies.authorization; if (!token) { logAuthEvent("warn", "Home request rejected because token is missing", getAuthRequestDetails(req)); return res.sendFile(path.join(__dirname, "./public/unauthorized.html")); } logAuthEvent("info", "Home request token received", { ...getAuthRequestDetails(req), token: getTokenLogDetails(token) }); const user = await verifyAuthToken(token); if (!user) { logAuthEvent("warn", "Home request rejected because token is invalid", { ...getAuthRequestDetails(req), token: getTokenLogDetails(token) }); return res.sendFile(path.join(__dirname, "./public/unauthorized.html")); } req.user = user; logAuthEvent("info", "Home request authorized", { ...getAuthRequestDetails(req), username: user.username, token: getTokenLogDetails(token) }); res.sendFile(path.join(__dirname, "./public/home.html")); }); app.get("/balance", authenticateToken, async (request, response) => { /* #swagger.security = [{ "bearerAuth": [] }] */ const username = request.user.username; const me = await findUserByUsername(username); if (!me) { return response.status(404).json({error: "User not found"}); } response.status(200).json({balance: me.money}); }); app.post("/verify", authenticateToken, (request, response) => { response.json({valid: true, user: request.user}); }); app.delete("/logout", authenticateToken, async (request, response) => { const username = request.user.username; logAuthEvent("info", "Logout request received", { ...getAuthRequestDetails(request), username }); const user = await findUserByUsername(username); if (!user) { logAuthEvent("warn", "Logout request rejected because user was not found", {username}); return response.status(404).json({error: "User not found"}); } const lobbyId = user.lobbyId; await leavePokerLobby(lobbyId, username); await setUserToken(username, null); logAuthEvent("info", "Cleared stored user token during logout", { username, previousToken: getTokenLogDetails(user.token) }); response.clearCookie("authorization", authCookieOptions); logAuthEvent("info", "Logout request completed", {username}); response.json({message: "Logged out successfully."}); }); 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 bestScore = rankedResults[0]?.result.score; const winners = bestScore ? rankedResults.filter(entry => compareScores(entry.result.score, bestScore) === 0) : []; 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 = winners.length ? winners.map(entry => entry.player).join(", ") : null; if (winners.length && !game.potAwarded) { const baseShare = Math.floor(game.pot / winners.length); let remainder = game.pot % winners.length; for (const entry of winners) { const extraChip = remainder > 0 ? 1 : 0; game.stacks[entry.player] += baseShare + extraChip; remainder -= extraChip; } game.potAwarded = true; } game.lastAction = game.winner ? `Showdown: ${game.winner} ${winners.length === 1 ? "wins" : "split"} $${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 lobbyId; do { lobbyId = crypto.randomBytes(4).toString("hex"); } while (playerMap[lobbyId]); return lobbyId; } app.post("/leave-lobby", authenticateToken, async (req, res) => { const {lobbyId} = req.body; const username = req.user.username; if (!lobbyId || !username) { return res.status(400).json({error: "Lobby ID und Username sind erforderlich!"}); } if (!playerMap[lobbyId]) { return res.status(404).json({error: "Lobby nicht gefunden!"}); } if (await leavePokerLobby(lobbyId, username)) { return res.json({message: `Lobby ${lobbyId} aktualisiert.`}); } return res.status(404).json({error: "User not in lobby"}); }); setInterval(() => removeEmptyLobbies(playerMap), 10000); function removeEmptyLobbies(playerMap) { for (const lobby in playerMap) { if (playerMap[lobby].length === 0) { deleteLobby(lobby).catch(error => { console.error("Failed to delete empty lobby:", error); }); console.log(`Lobby ${lobby} wurde gelöscht.`); } } } async function leaveLobby(id, username) { if (id && playerMap[id] && username) { 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); } } } 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]; } } } async function joinLobby(lobbyId, playerName) { if (!lobbyId || !playerName || !playerMap[lobbyId]) return false; if (!playerMap[lobbyId].includes(playerName)) { playerMap[lobbyId].push(playerName); } return true; } app.get("/lobby/create", authenticateToken, async (req, res) => { const username = req.user.username; if (!username) { return res.status(400).send("Username is required"); } let lobbyId = await createLobby(); if (!playerMap[lobbyId]) { playerMap[lobbyId] = []; } if (!playerMap[lobbyId].includes(username)) { playerMap[lobbyId].push(username); } 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.user.username; const user = await findUserByUsername(username); if (!user) { return res.status(401).json({error: "User doesn't exist"}); } if (lobbyId in playerMap) { await joinLobby(lobbyId, user.username); 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."); return res.status(404).json({error: "Lobby not found"}); } }); app.delete("/lobby/leave", authenticateToken, async (req, res) => { const lobbyId = req.query.lobbyId; const username = req.user.username; if (!lobbyId || !username) { return res.status(400).json({error: "Missing lobbyId or username"}); } if (lobbyId in playerMap) { if (await leavePokerLobby(lobbyId, username)) { return res.json({message: "Successfully left lobby"}); } return res.status(404).json({error: "User not in lobby"}); } res.status(404).json({error: "Lobby not found"}); }); app.get("/lobby/players", authenticateToken, async (req, res) => { let lobbyId = req.query.lobbyId; let players = playerMap[lobbyId] || []; res.json({players}); }); 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); 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); });