const {createDeck, compareScores, evaluateBestHand, calculatePayouts} = require("./lib/poker"); 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"); 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 || 42069; const server = http.createServer(app); const wss = new WebSocketServer({server, maxPayload: 16 * 1024}); let db; const authCookieOptions = { httpOnly: true, sameSite: "Strict", path: "/" }; app.set("trust proxy", 1); 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; }, Object.create(null)); if (process.env.AUTH_DEBUG !== "true") return; const logger = console[level] || console.log; logger(`[auth] ${message}`, safeDetails); } function getTokenLogDetails(token) { if (typeof token !== "string" || !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 ? String(decoded.iat) : undefined, expiresAt: decoded.exp ? String(decoded.exp) : undefined }; } function getAuthRequestDetails(request) { return { ip: request.ip, method: request.method, path: request.originalUrl || request.url, userAgent: request.get("user-agent"), protocol: request.protocol, forwardedProto: request.get("x-forwarded-proto"), secure: request.secure, hasBearerHeader: Boolean(request.headers.authorization), hasAuthCookie: Boolean(request.cookies?.authorization) }; } function isSecureAuthRequest(request) { const forwardedProto = request.get("x-forwarded-proto"); return request.secure || forwardedProto?.split(",")[0].trim() === "https"; } function getAuthCookieOptions(request) { return { ...authCookieOptions, secure: isSecureAuthRequest(request) }; } /* Here are Helper function ----------------------------------------------------------------------------------------------- */ async function initializeDatabase({migrate = true} = {}) { db = await open({ filename: process.env.DATABASE_PATH || 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 ) `); if (migrate) await migrateJsonUsers(); await db.run("UPDATE users SET lobby_id = NULL"); } 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", jwtid: crypto.randomUUID()}); logAuthEvent("info", "Generated access token", { username: user.username, token: getTokenLogDetails(token) }); return token; } function setAuthCookie(request, response, token) { const cookieOptions = getAuthCookieOptions(request); logAuthEvent("info", "Setting authorization cookie", { ...getAuthRequestDetails(request), secure: cookieOptions.secure, sameSite: cookieOptions.sameSite, httpOnly: cookieOptions.httpOnly, token: getTokenLogDetails(token) }); response.cookie("authorization", token, cookieOptions); } function normalizeUsername(username) { return typeof username === "string" ? username.trim().toLowerCase() : ""; } function isValidPassword(password) { return typeof password === "string" && password.length > 0 && Buffer.byteLength(password, "utf8") <= 72; } 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(); let value; try { value = decodeURIComponent(cookie.slice(separatorIndex + 1).trim()); } catch { return cookies; } cookies[key] = value; return cookies; }, Object.create(null)); } function getAuthTokenFromRequest(request) { return request.headers.authorization?.split(" ")[1] || request.cookies.authorization; } async function verifyAuthToken(token) { if (typeof token !== "string" || !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 || username.length > 40 || !isValidPassword(password)) { logAuthEvent("warn", "Register request rejected because credentials are incomplete", { username, hasPassword: isValidPassword(password) }); return response.status(400).json({error: "Username (1-40 characters) and password (1-72 bytes) 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}); try { await saveUser({username, password, token, money: 200}); } catch (error) { if (error.code === "SQLITE_CONSTRAINT") return response.status(409).json({error: "Username already exists"}); throw error; } logAuthEvent("info", "Registered user and stored initial token", { username, token: getTokenLogDetails(token) }); setAuthCookie(request, 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 || username.length > 40 || !isValidPassword(password)) { logAuthEvent("warn", "Login request rejected because credentials are incomplete", { username, hasPassword: isValidPassword(password) }); return response.status(400).json({error: "Username (1-40 characters) and password (1-72 bytes) 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 credentials"}); } 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(request, 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", getAuthCookieOptions(request)); logAuthEvent("info", "Logout request completed", {username}); response.json({message: "Logged out successfully."}); }); const playerMap = Object.create(null); const lobbySockets = Object.create(null); const lobbyHosts = Object.create(null); const lobbyPrivacy = Object.create(null); const pokerGames = Object.create(null); const disconnectTimers = Object.create(null); const startingLobbies = new Map(); const ROUND_INTERMISSION_MS = 30000; const TURN_TIMEOUT_MS = 30000; const DISCONNECT_GRACE_MS = 10000; const MINIMUM_STAKE = 10; const STARTING_STACK = 200; 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 + (existingGame.phase === "intermission" ? 0 : 1) : 1; const previousStacks = existingGame?.stacks || {}; const dealerIndex = existingGame ? (existingGame.dealerIndex + 1) % Math.max(players.length, 1) : 0; const stacks = Object.create(null); 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 = createDeck(); const hands = Object.create(null); const folded = Object.create(null); const acted = Object.create(null); const contributions = Object.create(null); const playerBets = Object.create(null); 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, hands, stacks, 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 }; if (activePlayers.length >= 2 && pokerGames[lobbyId].currentPlayerIndex === -1) { revealNextStreet(pokerGames[lobbyId]); } else { startTurnTimer(pokerGames[lobbyId]); } return pokerGames[lobbyId]; } function createPokerIntermission(lobbyId) { if (startingLobbies.has(lobbyId)) return startingLobbies.get(lobbyId); const pending = initializePokerIntermission(lobbyId).finally(() => startingLobbies.delete(lobbyId)); startingLobbies.set(lobbyId, pending); return pending; } async function initializePokerIntermission(lobbyId) { const players = [...(playerMap[lobbyId] || [])]; const existingGame = pokerGames[lobbyId]; if (existingGame?.nextRoundTimer) { clearTimeout(existingGame.nextRoundTimer); } const roundNumber = existingGame ? existingGame.round + (existingGame.phase === "intermission" ? 0 : 1) : 1; const previousStacks = existingGame?.stacks || {}; const stacks = Object.create(null); const folded = Object.create(null); const acted = Object.create(null); const contributions = Object.create(null); const playerBets = Object.create(null); 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: Object.create(null), 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; try { await createPokerGame(lobbyId); broadcastLobby(lobbyId); broadcastPokerState(lobbyId); } catch (error) { console.error("Failed to start next round:", error); } }, 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] !== game) 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; } } } 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 seatOrder = game.players.slice(game.dealerIndex + 1).concat(game.players.slice(0, game.dealerIndex + 1)); const payouts = calculatePayouts(game.contributions, evaluations, seatOrder); for (const [player, amount] of Object.entries(payouts)) { if (game.players.includes(player)) { game.stacks[player] = (game.stacks[player] || 0) + amount; } else { await db.run("UPDATE users SET money = money + ? WHERE username = ?", amount, player); } } game.winner = Object.keys(payouts).filter(player => payouts[player] > 0).join(", "); game.potAwarded = true; } game.lastAction = game.winner ? `Showdown: $${game.pot} distributed to ${game.winner}` : "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 currentPlayer = getCurrentPlayer(game); 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]; // Keep committed chips for side-pot accounting after this player folds/leaves. 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; } game.currentPlayerIndex = currentPlayer === username ? (index - 1 + game.players.length) % game.players.length : game.players.indexOf(currentPlayer); game.dealerIndex = Math.min(game.dealerIndex, game.players.length - 1); if (game.phase !== "betting") return; if (finishIfOnlyOneActive(game)) return; if (currentPlayer === username) { advanceTurn(game); } else if (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 = payload?.amount; if (!Number.isSafeInteger(amount) || amount < 0) { return {error: "Bet must be a non-negative whole number"}; } if (amount > game.stacks[username]) { return {error: "You do not have enough chips"}; } if (amount > 0 && amount < MINIMUM_STAKE && amount !== game.stacks[username] && amount !== getCallAmount(game, username)) { 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 && amount !== game.stacks[username]) { 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); for (const socket of lobbySockets[lobbyId] || []) { if (socket.user?.username !== username) continue; lobbySockets[lobbyId].delete(socket); socket.lobbyId = null; sendSocketMessage(socket, {type: "lobby:left", lobbyId}); } 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); } } function assertLobbyAvailable(username, targetLobbyId = null) { const current = Object.keys(playerMap).find(id => playerMap[id].includes(username)); if (current && current !== targetLobbyId) { const error = new Error("Leave your current lobby before joining another"); error.status = 409; throw error; } if (targetLobbyId && playerMap[targetLobbyId]?.length >= 10 && !playerMap[targetLobbyId].includes(username)) { const error = new Error("Lobby is full (maximum 10 players)"); error.status = 409; throw error; } } 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"}); }); const cleanupTimer = setInterval(() => removeEmptyLobbies(playerMap), 10000); cleanupTimer.unref(); 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; assertLobbyAvailable(playerName, lobbyId); 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"); } assertLobbyAvailable(username); let lobbyId = 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", (socket, request) => { socket.on("error", error => console.error("WebSocket connection error:", error.message)); authenticateSocket(socket, request).catch(error => { console.error("WebSocket authentication error:", error); socket.close(1011, "Connection failed"); }); }); async function authenticateSocket(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; const storedUser = await findUserByUsername(user.username); if (socket.readyState !== WebSocket.OPEN) return; 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 || typeof message !== "object" || Array.isArray(message)) { sendSocketMessage(socket, {type: "error", message: "Message must be an object"}); return; } if (!await verifyAuthToken(token)) { socket.close(1008, "Session expired or revoked"); return; } if (message.type === "lobby:create") { assertLobbyAvailable(user.username); const lobbyId = 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; } if (pokerGames[lobbyId] || startingLobbies.has(lobbyId)) { sendSocketMessage(socket, {type: "poker:error", message: "Game already started"}); 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]) { sendSocketMessage(socket, {type: "poker:error", message: "Host must start the game first"}); return; } 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: error.status === 409 ? 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); }); sendSocketMessage(socket, {type: "auth:success", username: user.username}); if (storedUser?.lobbyId && playerMap[storedUser.lobbyId]?.includes(user.username)) { subscribeToLobby(socket, storedUser.lobbyId); broadcastLobby(storedUser.lobbyId); } } app.use((error, req, res, next) => { if (res.headersSent) return next(error); const status = error.status >= 400 && error.status < 500 ? error.status : 500; if (status === 500) console.error("Request failed:", error); res.status(status).json({error: status === 500 ? "Internal server error" : status === 409 ? error.message : "Invalid request body"}); }); if (require.main === module) { if (!process.env.TOKEN_SECRET) throw new Error("TOKEN_SECRET must be set in the environment or .env"); 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); }); } async function close() { clearInterval(cleanupTimer); for (const timer of Object.values(disconnectTimers)) clearTimeout(timer); for (const game of Object.values(pokerGames)) { clearTurnTimer(game); clearTimeout(game.nextRoundTimer); } for (const socket of wss.clients) socket.terminate(); await new Promise(resolve => wss.close(resolve)); if (server.listening) await new Promise(resolve => server.close(resolve)); if (db) await db.close(); } module.exports = {close, app, server, wss, initializeDatabase, createPokerGame, createPokerIntermission, applyPokerAction, removePlayerFromPokerGame, pokerGames, playerMap, clearTurnTimer, parseCookieHeader};