diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..747b6a3 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Generate a secret with: node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))" +TOKEN_SECRET=replace-with-a-random-secret +PORT=42069 +# DATABASE_PATH=users.sqlite +# AUTH_DEBUG=true diff --git a/.gitignore b/.gitignore index e403a8a..45fc044 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,9 @@ users.sqlite users.sqlite-shm users.sqlite-wal .env + +.npm-cache/ +.playwright-browsers/ +playwright-report/ +test-results/ +*.backup diff --git a/README.md b/README.md index 1475a64..10596ac 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,43 @@ -# Gambling_API -Funny ah gambling heheha +# Gambling API + +A small multiplayer poker prototype using Express, WebSockets, SQLite, and a vanilla JavaScript frontend. Accounts start with 200 virtual chips. + +## Run locally + +Use Node.js 24 (the version used for validation) and npm. + +1. Run `npm install`. +2. Copy `.env.example` to `.env` and replace `TOKEN_SECRET` with a generated secret. The generation command is in the example file. +3. Run `npm start` and open . +4. Register two accounts in separate browser profiles, create a lobby, join with the second account, and start the game as host. + +`PORT` overrides the listening port. `DATABASE_PATH` overrides the default `users.sqlite` path. `AUTH_DEBUG=true` enables verbose authentication diagnostics. SQLite files and `.env` are ignored by Git. + +## Commands + +- `npm start`: start the application. +- `npm run dev`: restart on source changes. +- `npm test`: syntax checks and Node regression tests. +- `npm run check`: syntax checks only. +- `npm run docs`: regenerate the HTTP OpenAPI document; browse `/swagger-ui` while the server is running. + +If the environment blocks test child processes, run `npm run check` followed by `node --test --test-isolation=none`. Tests use an in-memory database, a temporary listening port, and skip legacy data migration. + +## Current behavior + +Sessions last 30 minutes. Logging in replaces the previous session. HTTP clients can use the authorization cookie or `Authorization: Bearer `; browser WebSockets use the cookie. Clients must wait for `auth:success` before sending messages. + +Lobbies hold at most 10 players, and each account may join one lobby at a time. Private lobbies are hidden from the public list but remain joinable by code. The host starts play. A round begins after a 30-second intermission. Each participating player pays a 10-chip ante; players below that amount spectate. There are four betting streets, best-five-of-seven hand evaluation, and contribution-based side pots. `hit` is the legacy protocol name for check/call. Turns time out after 30 seconds and automatically check/call. Disconnects have a 10-second grace period before leaving. + +This is a simplified poker variant: it does not yet enforce standard blinds or minimum-raise/reopening rules. Leaving folds the hand. Unclaimable contribution layers are returned to their contributors. See the audit for settlement edge cases still needing work. + +Account records persist in SQLite. Lobby and hand state live in memory and are lost on restart. Startup clears stale lobby references. Legacy `data.json` accounts are imported if present; the source is emptied after import, so back it up before migration. + +## Project layout + +- `gambling.js`: HTTP routes, database access, authentication, lobby lifecycle, and poker state transitions. +- `lib/poker.js`: pure hand evaluation, deck generation, and side-pot calculations. +- `public/`: HTML, CSS, and browser scripts. +- `test/`: poker and HTTP/WebSocket regression tests. +- `swagger.js` / `swagger.json`: HTTP documentation generator and output. +- [AUDIT.md](AUDIT.md): findings, completed fixes, and prioritized follow-up work. diff --git a/gambling.js b/gambling.js index 10f23bb..dbd41f4 100644 --- a/gambling.js +++ b/gambling.js @@ -1,1622 +1,41 @@ -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 || 42069; -const server = http.createServer(app); -const wss = new WebSocketServer({server}); -let db; -const authCookieOptions = { - httpOnly: true, - sameSite: "Strict", - path: "/" -}; +const http = require('node:http'); +const {configuration} = require('./lib/config'); +const {createRepository} = require('./lib/repository'); +const {createSessionService} = require('./lib/sessions'); +const {createLobbyService} = require('./lib/lobbies'); +const {createHttpApp} = require('./lib/http'); +const {connectTransport} = require('./lib/transport'); -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; - }, {}); - 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 +async function createApplication(options = {}) { + const config = configuration(options); + const repository = options.repository || await createRepository(config.databasePath, options.repositoryHooks); + try { await repository.recover(); } catch (error) { await repository.close(); throw error; } + const sessions = createSessionService(repository, config); + const lobbies = createLobbyService(repository, config); + lobbies.events.on('failure', error => console.error('Table command failed; will retry:', error.message)); + const app = createHttpApp(repository, sessions, lobbies, config); + const server = http.createServer(app); + const transport = connectTransport(server, sessions, lobbies, config); + let closed = false; + return {app, server, repository, sessions, lobbies, config, + async listen(port = config.port, host = config.host) { + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, host, () => {server.off('error', reject); resolve();}); }); + return server.address(); + }, + async close() { + if (closed) return; closed = true; + const drained = server.listening ? new Promise(resolve => server.close(resolve)) : Promise.resolve(); + await transport.close(); await drained; + await sessions.close(); await lobbies.close(); await repository.close(); + } }; } - -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() { - 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(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; -} - -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(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 || !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(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 = {}; -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); - }); +if (require.main === module) { + require('dotenv').config(); + createApplication().then(async application => { + const address = await application.listen(); + console.log(`Server listening at http://${address.address}:${address.port}`); + for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => application.close().catch(error => {console.error(error); process.exitCode = 1;})); + }).catch(error => {console.error(error.message); process.exitCode = 1;}); +} +module.exports = {createApplication}; diff --git a/lib/poker.js b/lib/poker.js new file mode 100644 index 0000000..839c245 --- /dev/null +++ b/lib/poker.js @@ -0,0 +1,147 @@ +const {randomInt} = require("node:crypto"); + +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]; +} + +function createDeck() { + const deck = []; + for (const suit of ["S", "H", "D", "C"]) { + for (const rank of ["A", "K", "Q", "J", "10", "9", "8", "7", "6", "5", "4", "3", "2"]) { + deck.push({rank, suit}); + } + } + for (let i = deck.length - 1; i > 0; i--) { + const j = randomInt(i + 1); + [deck[i], deck[j]] = [deck[j], deck[i]]; + } + return deck; +} + +// Each contribution level forms a separate pot, including folded players' chips. +function calculatePayouts(contributions, evaluations, seatOrder) { + const payouts = Object.create(null); + const levels = [...new Set(Object.values(contributions).filter(amount => amount > 0))].sort((a, b) => a - b); + let previous = 0; + for (const level of levels) { + const contributors = Object.keys(contributions).filter(player => contributions[player] >= level); + const amount = (level - previous) * contributors.length; + previous = level; + const eligible = evaluations.filter(entry => contributors.includes(entry.player)); + if (!eligible.length) { + // No live hand can claim this layer: return each unmatched contribution. + for (const player of contributors) payouts[player] = (payouts[player] || 0) + amount / contributors.length; + continue; + } + const best = eligible.reduce((a, b) => compareScores(a.result.score, b.result.score) >= 0 ? a : b); + const winners = seatOrder.filter(player => eligible.some(entry => entry.player === player && compareScores(entry.result.score, best.result.score) === 0)); + const share = Math.floor(amount / winners.length); + let remainder = amount % winners.length; + for (const player of winners) payouts[player] = (payouts[player] || 0) + share + (remainder-- > 0 ? 1 : 0); + } + return payouts; +} + +module.exports = {createDeck, compareScores, evaluateFiveCardHand, evaluateBestHand, calculatePayouts}; diff --git a/package.json b/package.json index de9f181..615e3ef 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,16 @@ "name": "gambling_api", "version": "1.0.0", "description": "", - "main": "swagger.js", + "main": "gambling.js", "scripts": { - "test": "node --check gambling.js && node --check public/frontend_home.js && node --check public/frontend_login.js && node --check public/frontend_poker.js && node --check swagger.js" + "start": "node gambling.js", + "dev": "node --watch gambling.js", + "docs": "node swagger.js", + "check": "node scripts/check.js && node swagger.js --check", + "test": "npm run check && node --test --test-isolation=none test/*.test.js", + "test:e2e": "playwright test", + "migrate:legacy": "node scripts/migrate-legacy.js", + "audit:deps": "npm audit --audit-level=high" }, "repository": { "type": "git", @@ -23,6 +30,9 @@ "ws": "^8.20.0" }, "devDependencies": { - "swagger-autogen": "^2.23.7" + "@playwright/test": "^1.63.0" + }, + "engines": { + "node": ">=24" } } diff --git a/public/frontend_home.js b/public/frontend_home.js index eb4316f..dd7aebe 100644 --- a/public/frontend_home.js +++ b/public/frontend_home.js @@ -1,381 +1,68 @@ -document.addEventListener("DOMContentLoaded", async () => { - const logoutButton = document.getElementById("logout"); - const balance = document.getElementById("balance"); - const hostButton = document.getElementById("host"); - const joinButton = document.getElementById("join"); - const hostForm = document.getElementById("host-form"); - const createLobbyButton = document.getElementById("create-lobby"); - const lobbyVisibilityInputs = document.querySelectorAll("input[name='lobby-visibility']"); - const joinForm = document.getElementById("join-form"); - const joinCodeInput = document.getElementById("join-code"); - const joinSubmit = document.getElementById("submit-join"); - const refreshLobbies = document.getElementById("refresh-lobbies"); - const publicLobbies = document.getElementById("public-lobbies"); - const leaveButton = document.getElementById("leave-button"); - const joinError = document.getElementById("join-error"); - const lobbyRoom = document.getElementById("lobby-room"); - const lobbyIdDisplay = document.getElementById("lobby-id"); - const playerList = document.getElementById("player-list"); - const startGameButton = document.getElementById("start-game"); - let socket = null; - let socketReady = null; - let currentUsername = null; - let currentLobbyId = null; - - const showJoinError = (message) => { - joinError.textContent = message; - joinError.classList.remove("hidden"); - }; - - const clearJoinError = () => { - joinError.textContent = ""; - joinError.classList.add("hidden"); - }; - - const renderPlayers = (players) => { - playerList.innerHTML = ""; - - if (!players.length) { - playerList.innerHTML = "

No players yet

"; - return; - } - - for (const player of players) { - const entry = document.createElement("p"); - entry.textContent = player; - playerList.appendChild(entry); - } - }; - - const updateStartButton = (host, canStart) => { - const isHost = currentUsername && host === currentUsername; - startGameButton.classList.toggle("hidden", !isHost); - startGameButton.disabled = !isHost || !canStart; - startGameButton.textContent = canStart ? "Start Game" : "Waiting for 2 players"; - }; - - const showLobby = (lobbyId, players = [], host = null, canStart = false) => { - currentLobbyId = lobbyId; - lobbyIdDisplay.textContent = lobbyId; - lobbyRoom.classList.remove("hidden"); - joinForm.classList.add("hidden"); - hostForm.classList.add("hidden"); - renderPlayers(players); - updateStartButton(host, canStart); - }; - - const joinLobby = (lobbyId) => { - if (!currentUsername) { - showJoinError("User not authenticated."); - return Promise.reject(new Error("User not authenticated")); - } - return sendSocketMessage({type: "lobby:join", lobbyId}); - }; - - const renderPublicLobbies = (lobbies = []) => { - publicLobbies.innerHTML = ""; - - if (!lobbies.length) { - publicLobbies.innerHTML = "

No public lobbies found

"; - return; - } - - lobbies.forEach((lobby) => { - const row = document.createElement("div"); - row.className = "public-lobby"; - - const info = document.createElement("div"); - info.className = "public-lobby-info"; - - const title = document.createElement("strong"); - title.textContent = lobby.host ? `${lobby.host}'s lobby` : "Public lobby"; - info.appendChild(title); - - const meta = document.createElement("span"); - meta.textContent = `${lobby.players} player${lobby.players === 1 ? "" : "s"} - ${lobby.gameStarted ? "Game running" : "Waiting"}`; - info.appendChild(meta); - - const join = document.createElement("button"); - join.type = "button"; - join.textContent = "Join"; - join.addEventListener("click", () => { - clearJoinError(); - joinLobby(lobby.lobbyId).catch(() => showJoinError("Could not join lobby.")); - }); - - row.appendChild(info); - row.appendChild(join); - publicLobbies.appendChild(row); - }); - }; - - const loadPublicLobbies = async () => { - try { - publicLobbies.innerHTML = "

Loading lobbies...

"; - const res = await fetch("/lobby/public", {credentials: "same-origin"}); - if (!res.ok) { - throw new Error("Failed to load lobbies"); - } - const data = await res.json(); - renderPublicLobbies(data.lobbies || []); - } catch (error) { - publicLobbies.innerHTML = "

Could not load lobbies

"; - } - }; - +document.addEventListener('DOMContentLoaded', async () => { + const byId = id => document.getElementById(id); + const client = new RealtimeClient(); + let username; let lobbyId; + const error = message => {byId('join-error').textContent = message; byId('join-error').classList.toggle('hidden', !message);}; + const send = async message => {try {error(''); await client.send(message);} catch (problem) {error(problem.message);}}; const hideLobby = () => { - currentLobbyId = null; - lobbyRoom.classList.add("hidden"); - lobbyIdDisplay.textContent = "N/A"; - playerList.innerHTML = "

Waiting for players...

"; - startGameButton.classList.add("hidden"); - startGameButton.disabled = true; + lobbyId = null; localStorage.removeItem('activeLobbyId'); byId('lobby-room').classList.add('hidden'); + byId('host').disabled = false; byId('join').disabled = false; }; - - const sendSocketMessage = async (message) => { - const ws = await connectSocket(); - ws.send(JSON.stringify(message)); - }; - - const connectSocket = () => { - if (socket && socket.readyState === WebSocket.OPEN) { - return Promise.resolve(socket); - } - - if (socketReady) { - return socketReady; - } - - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - socket = new WebSocket(`${protocol}//${window.location.host}`); - - socketReady = new Promise((resolve, reject) => { - socket.addEventListener("error", () => reject(new Error("WebSocket connection failed")), {once: true}); - socket.addEventListener("close", (event) => { - reject(new Error(`WebSocket closed before authentication: ${event.reason || event.code}`)); - }, {once: true}); - socket.addEventListener("message", (event) => { - const message = JSON.parse(event.data); - if (message.type === "auth:success") { - currentUsername = message.username; - resolve(socket); - } - if (message.type === "auth:error") { - reject(new Error(message.message || "WebSocket authentication failed")); - } - }, {once: true}); - }); - - socket.addEventListener("message", (event) => { - const message = JSON.parse(event.data); - - if (message.type === "auth:error") { - showJoinError(message.message); - return; - } - - if (message.type === "auth:success") { - currentUsername = message.username; - return; - } - - if (message.type === "lobby:state") { - clearJoinError(); - if (message.gameStarted) { - localStorage.setItem("activeLobbyId", message.lobbyId); - window.location.href = message.gameUrl || `/poker.html?lobbyId=${encodeURIComponent(message.lobbyId)}`; - return; - } - showLobby(message.lobbyId, message.players || [], message.host, Boolean(message.canStart)); - return; - } - - if (message.type === "poker:started") { - localStorage.setItem("activeLobbyId", message.lobbyId); - window.location.href = message.url || `/poker.html?lobbyId=${encodeURIComponent(message.lobbyId)}`; - return; - } - - if (message.type === "lobby:left") { - hideLobby(); - return; - } - - if (message.type === "lobby:error" || message.type === "error" || message.type === "poker:error") { - showJoinError(message.message || "WebSocket request failed"); - } - }); - - socket.addEventListener("close", () => { - socket = null; - socketReady = null; - }); - - return socketReady; - }; - - const getUsernameFromToken = async () => { + async function refreshBalance() { try { - const res = await fetch("/verify", { - method: "POST", - credentials: "same-origin" - }); - if (!res.ok) { - console.error("Token verification failed", res.status, await res.text()); - return null; - } - const data = await res.json(); - return data.user?.username; - } catch (err) { - console.error("Failed to get username from token", err); - return null; - } - }; - - currentUsername = await getUsernameFromToken(); - if (!currentUsername) { - showJoinError("User not authenticated."); + const [balance, ranks] = await Promise.all([apiRequest('/balance'), apiRequest('/leaderboard')]); + byId('balance').textContent = balance.balance; + byId('leaderboard-list').replaceChildren(...ranks.leaderboard.map((player, index) => { + const item = document.createElement('li'); item.textContent = `${index + 1}. ${player.username}: ${player.money}`; + item.classList.toggle('you', player.username === username); return item; + })); + byId('your-rank').textContent = `Your rank: #${ranks.self.position}`; + } catch (problem) {error(problem.message);} } - - connectSocket().catch((error) => { - console.error("Home WebSocket connection failed:", error); - showJoinError("Real-time poker connection failed."); + client.addEventListener('status', event => {error(event.detail); byId('start-game').disabled = !client.ready || byId('start-game').dataset.allowed !== 'true';}); + client.addEventListener('message', event => { + const message = event.detail; + if (message.type === 'auth:success') username = message.username; + if (message.type === 'lobby:left') {hideLobby(); refreshBalance();} + if (message.type !== 'lobby:state') return; + lobbyId = message.lobbyId; + if (message.gameStarted) { + localStorage.setItem('activeLobbyId', lobbyId); + location.assign(`/poker.html?lobbyId=${encodeURIComponent(lobbyId)}`); return; + } + byId('lobby-id').textContent = lobbyId; byId('lobby-room').classList.remove('hidden'); + byId('host-form').classList.add('hidden'); byId('join-form').classList.add('hidden'); + byId('host').disabled = true; byId('join').disabled = true; + byId('player-list').replaceChildren(...message.players.map(player => {const item = document.createElement('p'); item.textContent = player; return item;})); + const allowed = message.host === username && message.canStart; + byId('start-game').dataset.allowed = String(allowed); + byId('start-game').disabled = !allowed; byId('start-game').classList.toggle('hidden', message.host !== username); }); - - try { - const balanceRes = await fetch("/balance", { - method: "GET", - credentials: "same-origin" - }); - if (balanceRes.ok) { - const balanceData = await balanceRes.json(); - balance.textContent = balanceData.balance; - } - } catch (error) { - console.log("Balance error:", error); - } - - logoutButton.addEventListener("click", async (e) => { - e.preventDefault(); + byId('host').addEventListener('click', () => {byId('host-form').classList.toggle('hidden'); byId('join-form').classList.add('hidden');}); + byId('create-lobby').addEventListener('click', () => send({type: 'lobby:create', private: document.querySelector('[name="lobby-visibility"]:checked').value === 'private'})); + async function loadLobbies() { try { - if (socket && socket.readyState === WebSocket.OPEN) { - socket.close(); + const {lobbies} = await apiRequest('/lobby/public'); + byId('public-lobbies').replaceChildren(); + if (!lobbies.length) {byId('public-lobbies').textContent = 'No public lobbies found'; return;} + for (const lobby of lobbies) { + const row = document.createElement('div'); row.className = 'public-lobby'; + const info = document.createElement('span'); info.textContent = `${lobby.host}: ${lobby.players}/10 players`; + const button = document.createElement('button'); button.textContent = 'Join'; button.type = 'button'; + button.addEventListener('click', () => send({type: 'lobby:join', lobbyId: lobby.lobbyId})); + row.append(info, button); byId('public-lobbies').append(row); } - const logoutRes = await fetch("/logout", { - method: "DELETE", - credentials: "same-origin" - }); - if (logoutRes.ok) { - document.cookie = "authorization=; Max-Age=0; path=/;"; - window.location.href = "/login.html"; - } - } catch (err) { - console.log("Logout error:", err); - } - }); - - hostButton.addEventListener("click", async (e) => { - e.preventDefault(); - clearJoinError(); - hostForm.classList.toggle("hidden"); - joinForm.classList.add("hidden"); - }); - - createLobbyButton.addEventListener("click", async (e) => { - e.preventDefault(); - clearJoinError(); - if (!currentUsername) { - showJoinError("User not authenticated."); - return; - } - const selectedVisibility = [...lobbyVisibilityInputs].find(input => input.checked)?.value || "public"; - sendSocketMessage({type: "lobby:create", private: selectedVisibility === "private"}).catch(() => { - showJoinError("Could not create lobby."); - }); - }); - - joinButton.addEventListener("click", (e) => { - e.preventDefault(); - joinForm.classList.toggle("hidden"); - hostForm.classList.add("hidden"); - if (!joinForm.classList.contains("hidden")) { - joinCodeInput.focus(); - loadPublicLobbies(); - } - }); - - refreshLobbies.addEventListener("click", (e) => { - e.preventDefault(); - loadPublicLobbies(); - }); - - joinSubmit.addEventListener("click", async (e) => { - e.preventDefault(); - clearJoinError(); - - const lobbyId = joinCodeInput.value.trim(); - if (!lobbyId) { - showJoinError("Please enter a lobby ID."); - return; - } - - if (!currentUsername) { - showJoinError("User not authenticated."); - return; - } - - joinLobby(lobbyId).then(() => { - joinForm.classList.add("hidden"); - joinCodeInput.value = ""; - }).catch(() => { - showJoinError("Could not join lobby. Please try again."); - }); - }); - - leaveButton.addEventListener("click", (e) => { - e.preventDefault(); - const lobbyId = lobbyIdDisplay.textContent; - if (!lobbyId || lobbyId === "N/A") return; - sendSocketMessage({type: "lobby:leave", lobbyId}).catch(() => { - showJoinError("Could not leave lobby."); - }); - }); - - startGameButton.addEventListener("click", (e) => { - e.preventDefault(); - if (!currentLobbyId || startGameButton.disabled) return; - sendSocketMessage({type: "poker:start", lobbyId: currentLobbyId}).catch(() => { - showJoinError("Could not start game."); - }); - }); - - async function loadLeaderboard() { - try { - const res = await fetch("/leaderboard", {credentials: "same-origin"}); - if (res.ok) { - const data = await res.json(); - const list = document.getElementById("leaderboard-list"); - const yourRank = document.getElementById("your-rank"); - - list.innerHTML = ""; - data.leaderboard.forEach((player, i) => { - const item = document.createElement("li"); - item.textContent = `${i + 1}. ${player.username} - $${player.money}`; - if (player.username === data.self.username) { - item.classList.add("you"); - item.textContent += " (you)"; - } - list.appendChild(item); - }); - - yourRank.textContent = `You are ranked #${data.self.position} with $${data.self.money}`; - } else { - console.error("Failed to load leaderboard."); - } - } catch (err) { - console.error("Leaderboard error:", err); - } + } catch (problem) {error(problem.message);} } - - await loadLeaderboard(); + byId('join').addEventListener('click', () => {byId('join-form').classList.toggle('hidden'); byId('host-form').classList.add('hidden'); byId('join-code').focus(); loadLobbies();}); + byId('refresh-lobbies').addEventListener('click', loadLobbies); + byId('submit-join').addEventListener('click', () => send({type: 'lobby:join', lobbyId: byId('join-code').value.trim().toLowerCase()})); + byId('leave-button').addEventListener('click', () => send({type: 'lobby:leave', lobbyId})); + byId('start-game').addEventListener('click', () => send({type: 'poker:start', lobbyId})); + byId('logout').addEventListener('click', async () => { + try {await apiRequest('/logout', {method: 'DELETE'}); client.close(); localStorage.removeItem('activeLobbyId'); location.assign('/login');} + catch (problem) {error(`Logout failed: ${problem.message}`);} + }); + try {username = (await apiRequest('/verify', {method: 'POST'})).user.username; await client.connect(); await refreshBalance();} + catch (problem) {error(problem.message);} }); diff --git a/public/frontend_login.js b/public/frontend_login.js index 0edd7bd..e97a251 100644 --- a/public/frontend_login.js +++ b/public/frontend_login.js @@ -1,109 +1,20 @@ -document.addEventListener("DOMContentLoaded", () => { - const form = document.querySelector("form"); - const registerButton = document.getElementById("register-button"); - const loginButton = document.getElementById("login-button"); - const authError = document.getElementById("auth-error"); - - form.addEventListener("submit", async (event) => { - event.preventDefault(); - await authenticate("/login", "Login successful. Loading Lobby..."); - }); - - registerButton.addEventListener("click", async () => { - await authenticate("/register", "Register successful. Loading Lobby..."); - }); - - async function authenticate(endpoint, successMessage) { - clearAuthError(); - setButtonsDisabled(true); - - const credentials = { - username: form.username.value, - password: form.password.value - }; - +document.addEventListener('DOMContentLoaded', () => { + const form = document.querySelector('form'); + const error = document.getElementById('auth-error'); + let pending = false; + async function authenticate(endpoint) { + if (pending || !form.reportValidity()) return; + pending = true; error.textContent = ''; error.classList.add('hidden'); + for (const button of form.querySelectorAll('button')) button.disabled = true; try { - const response = await fetch(endpoint, { - method: "POST", - headers: {"Content-Type": "application/json"}, - credentials: "same-origin", - body: JSON.stringify(credentials) - }); - - const data = await readJsonResponse(response); - if (!response.ok) { - showAuthError(data.error || "Authentication failed."); - return; - } - - const verified = await verifySession(); - if (!verified) { - showAuthError("Login succeeded, but the browser did not send the session cookie back to the server."); - return; - } - - showLoadingScreen(successMessage); - } catch (error) { - console.error("Authentication request failed:", error); - showAuthError("Server unreachable."); - } finally { - setButtonsDisabled(false); - } - } - - async function verifySession() { - const response = await fetch("/verify", { - method: "POST", - credentials: "same-origin" - }); - if (!response.ok) return false; - - const data = await readJsonResponse(response); - return Boolean(data.valid && data.user?.username); - } - - async function readJsonResponse(response) { - try { - return await response.json(); - } catch { - return {}; - } - } - - function showAuthError(message) { - authError.textContent = message; - authError.classList.remove("hidden"); - } - - function clearAuthError() { - authError.textContent = ""; - authError.classList.add("hidden"); - } - - function setButtonsDisabled(disabled) { - loginButton.disabled = disabled; - registerButton.disabled = disabled; - } - - function showLoadingScreen(message) { - const loadingScreen = document.getElementById("loading-screen"); - const loadingText = document.getElementById("loading-text"); - const progress = document.getElementById("progress"); - loadingText.textContent = message; - loadingScreen.classList.remove("hidden"); - let percent = 0; - const interval = setInterval(() => { - percent += Math.floor(Math.random() * 10) + 5; - if (percent >= 100) { - percent = 100; - progress.style.width = percent + "%"; - clearInterval(interval); - setTimeout(() => { - window.location.href = "/"; - }, 500); - } else { - progress.style.width = percent + "%"; - } - }, 200); + const response = await fetch(endpoint, {method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({username: form.username.value, password: form.password.value})}); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || 'Authentication failed'); + location.assign('/'); + } catch (problem) {error.textContent = problem.message; error.classList.remove('hidden');} + finally {pending = false; for (const button of form.querySelectorAll('button')) button.disabled = false;} } + form.addEventListener('submit', event => {event.preventDefault(); authenticate('/login');}); + document.getElementById('register-button').addEventListener('click', () => authenticate('/register')); }); diff --git a/public/frontend_poker.js b/public/frontend_poker.js index ae7f202..149f061 100644 --- a/public/frontend_poker.js +++ b/public/frontend_poker.js @@ -26,12 +26,14 @@ document.addEventListener("DOMContentLoaded", () => { const actionButtons = document.querySelectorAll(".actions button"); const params = new URLSearchParams(window.location.search); const lobbyId = params.get("lobbyId") || localStorage.getItem("activeLobbyId"); - let socket = null; - let socketReady = null; + const client = new RealtimeClient(); + let minimumRaise = 10; + let raisesAllowed = true; + let actionPending = false; + let previousFocus = null; let isYourTurn = false; - let canLeaveTable = true; let isSpectator = false; - let currentStackValue = 400; + let currentStackValue = 0; let minimumStake = 10; let callAmount = 0; let countdownTimer = null; @@ -121,22 +123,30 @@ document.addEventListener("DOMContentLoaded", () => { }; const sendSocketMessage = async (message) => { - const ws = await connectSocket(); - ws.send(JSON.stringify(message)); + message = {...message, lobbyId}; + if (message.payload?.amount !== undefined) message.payload = {amount: message.payload.amount}; + else delete message.payload; + actionPending = true; updateActionButtons(); + try { await client.send(message); } + finally { actionPending = false; updateActionButtons(); } + }; + const closeModal = () => { + betModal.classList.add("hidden"); + previousFocus?.focus(); }; - const updateActionButtons = () => { actionButtons.forEach((button) => { - button.disabled = !isYourTurn || isSpectator; + button.disabled = !client.ready || actionPending || !isYourTurn || isSpectator; if (button.dataset.action === "hit") { + button.textContent = callAmount > 0 ? "Call" : "Check"; button.title = callAmount > 0 ? `Call with $${Math.min(callAmount, currentStackValue)}.` : actionTooltips.hit; } }); - confirmBet.disabled = !isYourTurn || isSpectator; - leaveTable.disabled = false; - leaveTable.title = "Leave table. Chips already in the pot are lost."; + confirmBet.disabled = !client.ready || actionPending || !isYourTurn || isSpectator; + leaveTable.disabled = !client.ready || actionPending; + leaveTable.title = "Leave and fold. Your committed chips stay in this hand until settlement."; }; const stopCountdown = () => { @@ -177,34 +187,24 @@ document.addEventListener("DOMContentLoaded", () => { countdownTimer = setInterval(updateCountdown, 1000); }; - const connectSocket = () => { - if (socket && socket.readyState === WebSocket.OPEN) { - return Promise.resolve(socket); - } - - if (socketReady) { - return socketReady; - } - - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - socket = new WebSocket(`${protocol}//${window.location.host}`); - - socketReady = new Promise((resolve, reject) => { - socket.addEventListener("open", () => resolve(socket), {once: true}); - socket.addEventListener("error", () => reject(new Error("WebSocket connection failed")), {once: true}); - }); - - socket.addEventListener("message", (event) => { - const message = JSON.parse(event.data); - + client.addEventListener('status', event => { + if (event.detail) showError(event.detail); + if (!client.ready) {isYourTurn = false; stopCountdown(); closeModal();} + updateActionButtons(); + }); + client.addEventListener('message', event => { + const message = event.detail; if (message.type === "poker:state") { hideError(); isYourTurn = Boolean(message.isYourTurn); - canLeaveTable = Boolean(message.canLeaveTable); isSpectator = Boolean(message.isSpectator); currentStackValue = Number(message.yourStack ?? 0); minimumStake = Number(message.minimumStake || 10); callAmount = Number(message.callAmount || 0); + minimumRaise = message.minimumRaise || minimumStake; + raisesAllowed = message.canRaise !== false; + if (!isYourTurn || isSpectator) closeModal(); + else if (!betModal.classList.contains("hidden")) updateBetRange(); roundDisplay.textContent = message.round; potDisplay.textContent = message.pot; currentBet.textContent = message.currentBet ?? message.bet; @@ -249,15 +249,7 @@ document.addEventListener("DOMContentLoaded", () => { if (message.type === "auth:error" || message.type === "poker:error" || message.type === "error") { showError(message.message || "Poker connection failed"); } - }); - - socket.addEventListener("close", () => { - socket = null; - socketReady = null; - }); - - return socketReady; - }; + }); const setBetValue = (value) => { const nextValue = Math.max(Number(betRange.min), Math.min(Number(betRange.max), Number(value))); @@ -266,12 +258,14 @@ document.addEventListener("DOMContentLoaded", () => { }; const updateBetRange = () => { - const maxBet = Math.max(0, currentStackValue); - const requiredBet = Math.max(minimumStake, callAmount); + const maxBet = Math.max(0, raisesAllowed ? currentStackValue : Math.min(currentStackValue, callAmount)); + const requiredBet = raisesAllowed ? callAmount + minimumRaise : callAmount; const minBet = maxBet > 0 ? Math.min(requiredBet, maxBet) : 0; betRange.min = String(minBet); betRange.max = String(maxBet); - betRange.step = String(minimumStake); + betRange.step = "1"; + document.getElementById("range-min").textContent = `$${minBet}`; + document.getElementById("range-max").textContent = `$${maxBet}`; setBetValue(Math.max(minBet, Number(betRange.value) || minBet)); }; @@ -284,8 +278,8 @@ document.addEventListener("DOMContentLoaded", () => { updateActionButtons(); - connectSocket() - .then(() => sendSocketMessage({type: "poker:join", lobbyId})) + client.connect() + .then(() => client.send({type: "poker:join", lobbyId})) .catch(() => showError("Could not connect to poker table.")); actionButtons.forEach((button) => { @@ -301,7 +295,9 @@ document.addEventListener("DOMContentLoaded", () => { } if (action === "bet") { updateBetRange(); + previousFocus = document.activeElement; betModal.classList.remove("hidden"); + betRange.focus(); return; } @@ -317,7 +313,16 @@ document.addEventListener("DOMContentLoaded", () => { }); }); - closeBet.addEventListener("click", () => betModal.classList.add("hidden")); + closeBet.addEventListener("click", closeModal); + betModal.addEventListener("keydown", event => { + if (event.key === "Escape") {event.preventDefault(); closeModal();} + if (event.key === "Tab") { + const items = [...betModal.querySelectorAll("button:not(:disabled), input:not(:disabled)")]; + const first = items[0], last = items[items.length - 1]; + if (event.shiftKey && document.activeElement === first) {event.preventDefault(); last.focus();} + else if (!event.shiftKey && document.activeElement === last) {event.preventDefault(); first.focus();} + } + }); betRange.addEventListener("input", () => setBetValue(betRange.value)); decreaseBet.addEventListener("click", () => setBetValue(Number(betRange.value) - 10)); increaseBet.addEventListener("click", () => setBetValue(Number(betRange.value) + 10)); @@ -337,6 +342,6 @@ document.addEventListener("DOMContentLoaded", () => { action: "bet", payload: {amount: Number(betRange.value), lobbyId} }).catch(() => showError("Could not place bet.")); - betModal.classList.add("hidden"); + closeModal(); }); }); diff --git a/public/home.html b/public/home.html index 82b723c..afaef39 100644 --- a/public/home.html +++ b/public/home.html @@ -2,7 +2,9 @@ + Home + @@ -40,6 +42,7 @@ - +