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/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..44a0a20 100644 --- a/gambling.js +++ b/gambling.js @@ -1,3 +1,4 @@ +const {createDeck, compareScores, evaluateBestHand, calculatePayouts} = require("./lib/poker"); const express = require("express"); const app = express(); const http = require("http"); @@ -8,7 +9,7 @@ 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(); +require("dotenv").config(); const jwt = require("jsonwebtoken"); const cookieParser = require("cookie-parser"); const bcrypt = require("bcrypt"); @@ -16,7 +17,7 @@ const sqlite3 = require("sqlite3"); const {open} = require("sqlite"); const port = process.env.PORT || 42069; const server = http.createServer(app); -const wss = new WebSocketServer({server}); +const wss = new WebSocketServer({server, maxPayload: 16 * 1024}); let db; const authCookieOptions = { httpOnly: true, @@ -33,13 +34,14 @@ 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 (!token) { + if (typeof token !== "string" || !token) { return { present: false }; @@ -51,8 +53,8 @@ function getTokenLogDetails(token) { 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 + issuedAt: decoded.iat ? String(decoded.iat) : undefined, + expiresAt: decoded.exp ? String(decoded.exp) : undefined }; } @@ -86,9 +88,9 @@ function getAuthCookieOptions(request) { Here are Helper function ----------------------------------------------------------------------------------------------- */ -async function initializeDatabase() { +async function initializeDatabase({migrate = true} = {}) { db = await open({ - filename: path.join(__dirname, "users.sqlite"), + filename: process.env.DATABASE_PATH || path.join(__dirname, "users.sqlite"), driver: sqlite3.Database }); @@ -102,7 +104,8 @@ async function initializeDatabase() { ) `); - await migrateJsonUsers(); + if (migrate) await migrateJsonUsers(); + await db.run("UPDATE users SET lobby_id = NULL"); } async function migrateJsonUsers() { @@ -177,7 +180,7 @@ async function setUserMoney(username, money) { function generateAccessToken(user) { logAuthEvent("info", "Generating access token", {username: user.username}); - const token = jwt.sign(user, process.env.TOKEN_SECRET, {expiresIn: "1800s"}); + 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) @@ -202,7 +205,7 @@ function normalizeUsername(username) { } function isValidPassword(password) { - return typeof password === "string" && password.length > 0; + return typeof password === "string" && password.length > 0 && Buffer.byteLength(password, "utf8") <= 72; } function parseCookieHeader(cookieHeader = "") { @@ -210,10 +213,11 @@ function parseCookieHeader(cookieHeader = "") { const separatorIndex = cookie.indexOf("="); if (separatorIndex === -1) return cookies; const key = cookie.slice(0, separatorIndex).trim(); - const value = decodeURIComponent(cookie.slice(separatorIndex + 1).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) { @@ -221,7 +225,7 @@ function getAuthTokenFromRequest(request) { } async function verifyAuthToken(token) { - if (!token) { + if (typeof token !== "string" || !token) { logAuthEvent("warn", "Token verification skipped because token is missing"); return null; } @@ -298,19 +302,19 @@ async function authenticateToken(request, response, next) { */ app.post("/register", async (request, response) => { - let {username, password} = request.body; + 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)) { + 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 and password are required"}); + return response.status(400).json({error: "Username (1-40 characters) and password (1-72 bytes) are required"}); } const existingUser = await findUserByUsername(username); if (existingUser) { @@ -318,7 +322,12 @@ app.post("/register", async (request, response) => { return response.status(400).json({error: "Username already exists"}); } const token = generateAccessToken({username}); - await saveUser({username, password, token, money: 200}); + 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) @@ -353,19 +362,19 @@ app.get("/login", (request, response) => { }) app.post("/login", async (request, response) => { - let {username, password} = request.body; + 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)) { + 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 and password are required"}); + return response.status(400).json({error: "Username (1-40 characters) and password (1-72 bytes) are required"}); } const user = await findUserByUsername(username); if (!user) { @@ -379,7 +388,7 @@ app.post("/login", async (request, response) => { 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"}); + return response.status(401).json({error: "Invalid credentials"}); } logAuthEvent("info", "Login password matched", {username}); const token = generateAccessToken({username}); @@ -464,31 +473,19 @@ app.delete("/logout", authenticateToken, async (request, response) => { response.json({message: "Logged out successfully."}); }); -const playerMap = {}; -const lobbySockets = {}; -const lobbyHosts = {}; -const lobbyPrivacy = {}; -const pokerGames = {}; -const disconnectTimers = {}; +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; -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]; @@ -502,17 +499,17 @@ async function persistPokerStacks(game) { } async function createPokerGame(lobbyId) { - const players = playerMap[lobbyId] || []; + const players = [...(playerMap[lobbyId] || [])]; const existingGame = pokerGames[lobbyId]; if (existingGame?.nextRoundTimer) { clearTimeout(existingGame.nextRoundTimer); } - const roundNumber = existingGame ? existingGame.round + 1 : 1; + 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 = {}; + const stacks = Object.create(null); const playersWithChips = []; for (const player of players) { @@ -523,12 +520,12 @@ async function createPokerGame(lobbyId) { } const activePlayers = playersWithChips.length >= 2 ? playersWithChips : []; - const deck = createPlaceholderDeck(); - const hands = {}; - const folded = {}; - const acted = {}; - const contributions = {}; - const playerBets = {}; + 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) { @@ -570,7 +567,7 @@ async function createPokerGame(lobbyId) { playerBets, stacks, currentPlayerIndex: activePlayers.length >= 2 - ? getFirstActiveLeftOfDealer({players, folded, dealerIndex}) + ? getFirstActiveLeftOfDealer({players, folded, hands, stacks, dealerIndex}) : -1, lastAction: activePlayers.length >= 2 ? `Round ${roundNumber} started. $${MINIMUM_STAKE} minimum stake paid by each player.` @@ -587,24 +584,35 @@ async function createPokerGame(lobbyId) { minimumStake: MINIMUM_STAKE }; - startTurnTimer(pokerGames[lobbyId]); + if (activePlayers.length >= 2 && pokerGames[lobbyId].currentPlayerIndex === -1) { + revealNextStreet(pokerGames[lobbyId]); + } else { + startTurnTimer(pokerGames[lobbyId]); + } return pokerGames[lobbyId]; } -async function createPokerIntermission(lobbyId) { - const players = playerMap[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 + 1 : 1; + const roundNumber = existingGame ? existingGame.round + (existingGame.phase === "intermission" ? 0 : 1) : 1; const previousStacks = existingGame?.stacks || {}; - const stacks = {}; - const folded = {}; - const acted = {}; - const contributions = {}; - const playerBets = {}; + 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); @@ -631,7 +639,7 @@ async function createPokerIntermission(lobbyId) { bet: 0, dealerIndex: existingGame?.dealerIndex ?? 0, communityCards: [], - hands: {}, + hands: Object.create(null), folded, acted, contributions, @@ -654,9 +662,13 @@ async function createPokerIntermission(lobbyId) { pokerGames[lobbyId] = intermission; intermission.nextRoundTimer = setTimeout(async () => { if (!pokerGames[lobbyId] || !playerMap[lobbyId]?.length) return; - await createPokerGame(lobbyId); - broadcastLobby(lobbyId); - broadcastPokerState(lobbyId); + try { + await createPokerGame(lobbyId); + broadcastLobby(lobbyId); + broadcastPokerState(lobbyId); + } catch (error) { + console.error("Failed to start next round:", error); + } }, ROUND_INTERMISSION_MS); return intermission; @@ -779,7 +791,7 @@ async function completeRoundWithWinner(game, winner) { async function scheduleNextRound(game) { clearTurnTimer(game); - if (game.nextRoundTimer || !pokerGames[game.lobbyId]) return; + if (game.nextRoundTimer || pokerGames[game.lobbyId] !== game) return; await createPokerIntermission(game.lobbyId); broadcastLobby(game.lobbyId); @@ -868,112 +880,6 @@ function advanceTurn(game) { } } -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 => ({ @@ -1000,17 +906,20 @@ async function startShowdown(game) { 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; + 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.winner} ${winners.length === 1 ? "wins" : "split"} $${game.pot} with ${rankedResults[0].result.name}` + ? `Showdown: $${game.pot} distributed to ${game.winner}` : "Showdown ended"; await persistPokerStacks(game); await scheduleNextRound(game); @@ -1074,6 +983,7 @@ async function removePlayerFromPokerGame(lobbyId, username) { await setUserMoney(username, remainingStack); } + const currentPlayer = getCurrentPlayer(game); const index = game.players.indexOf(username); if (index > -1) { game.players.splice(index, 1); @@ -1081,7 +991,7 @@ async function removePlayerFromPokerGame(lobbyId, username) { delete game.hands[username]; delete game.folded[username]; delete game.acted[username]; - delete game.contributions[username]; + // Keep committed chips for side-pot accounting after this player folds/leaves. delete game.playerBets[username]; delete game.stacks[username]; @@ -1094,20 +1004,18 @@ async function removePlayerFromPokerGame(lobbyId, username) { return; } - if (game.currentPlayerIndex >= game.players.length) { - game.currentPlayerIndex = 0; - } + 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.turnTimerPlayer === username) { - clearTurnTimer(game); - if (game.phase === "betting" && !haveAllActivePlayersMatched(game)) { - startTurnTimer(game); - } - } + if (game.phase !== "betting") return; if (finishIfOnlyOneActive(game)) return; - if (game.phase === "betting" && haveAllActivePlayersMatched(game)) { + if (currentPlayer === username) { + advanceTurn(game); + } else if (haveAllActivePlayersMatched(game)) { revealNextStreet(game); } + } async function ensurePlayerInPokerGame(lobbyId, username) { @@ -1153,17 +1061,20 @@ function applyPokerAction(game, username, action, payload = {}, automatic = fals } if (action === "bet") { - const amount = Math.max(0, Number(payload.amount) || 0); + 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) { + 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) { + 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); @@ -1238,6 +1149,12 @@ 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); @@ -1312,7 +1229,21 @@ function broadcastToLobby(lobbyId, payload) { } } -async function createLobby() { +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"); @@ -1321,7 +1252,7 @@ async function createLobby() { } app.post("/leave-lobby", authenticateToken, async (req, res) => { - const {lobbyId} = req.body; + const {lobbyId} = req.body || {}; const username = req.user.username; if (!lobbyId || !username) { return res.status(400).json({error: "Lobby ID und Username sind erforderlich!"}); @@ -1334,7 +1265,8 @@ app.post("/leave-lobby", authenticateToken, async (req, res) => { } return res.status(404).json({error: "User not in lobby"}); }); -setInterval(() => removeEmptyLobbies(playerMap), 10000); +const cleanupTimer = setInterval(() => removeEmptyLobbies(playerMap), 10000); +cleanupTimer.unref(); function removeEmptyLobbies(playerMap) { for (const lobby in playerMap) { @@ -1378,6 +1310,7 @@ async function deleteLobby(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); } @@ -1389,7 +1322,8 @@ app.get("/lobby/create", authenticateToken, async (req, res) => { if (!username) { return res.status(400).send("Username is required"); } - let lobbyId = await createLobby(); + assertLobbyAvailable(username); + let lobbyId = createLobby(); if (!playerMap[lobbyId]) { playerMap[lobbyId] = []; } @@ -1449,7 +1383,15 @@ app.get("/lobby/public", authenticateToken, async (req, res) => { res.json({lobbies: createPublicLobbyList()}); }); -wss.on("connection", async (socket, request) => { +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); @@ -1461,13 +1403,8 @@ wss.on("connection", async (socket, request) => { } 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); - } - + if (socket.readyState !== WebSocket.OPEN) return; socket.on("message", async (rawMessage) => { let message; try { @@ -1478,8 +1415,17 @@ wss.on("connection", async (socket, request) => { } 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") { - const lobbyId = await createLobby(); + assertLobbyAvailable(user.username); + const lobbyId = createLobby(); playerMap[lobbyId] = playerMap[lobbyId] || []; if (!playerMap[lobbyId].includes(user.username)) { playerMap[lobbyId].push(user.username); @@ -1544,6 +1490,10 @@ wss.on("connection", async (socket, request) => { return; } + if (pokerGames[lobbyId] || startingLobbies.has(lobbyId)) { + sendSocketMessage(socket, {type: "poker:error", message: "Game already started"}); + return; + } await createPokerIntermission(lobbyId); broadcastLobby(lobbyId); broadcastPokerState(lobbyId); @@ -1564,7 +1514,8 @@ wss.on("connection", async (socket, request) => { subscribeToLobby(socket, lobbyId); if (!pokerGames[lobbyId]) { - await createPokerIntermission(lobbyId); + sendSocketMessage(socket, {type: "poker:error", message: "Host must start the game first"}); + return; } await ensurePlayerInPokerGame(lobbyId, user.username); await maybeStartWaitingGame(lobbyId); @@ -1596,10 +1547,11 @@ wss.on("connection", async (socket, request) => { sendSocketMessage(socket, {type: "error", message: "Unknown message type"}); } catch (error) { console.error("WebSocket message error:", error); - sendSocketMessage(socket, {type: "error", message: "WebSocket request failed"}); + 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; @@ -1608,15 +1560,46 @@ wss.on("connection", async (socket, request) => { } 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"}); }); -initializeDatabase() - .then(() => { - server.listen(port, () => { - console.log("Server is running on port " + port); +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); }); - }) - .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}; 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..1071270 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,13 @@ "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 --check gambling.js && node --check lib/poker.js && node --check public/frontend_home.js && node --check public/frontend_login.js && node --check public/frontend_poker.js && node --check public/matrix.js && node --check swagger.js", + "test": "npm run check && node --test" }, "repository": { "type": "git", diff --git a/public/frontend_home.js b/public/frontend_home.js index eb4316f..656134d 100644 --- a/public/frontend_home.js +++ b/public/frontend_home.js @@ -138,7 +138,7 @@ document.addEventListener("DOMContentLoaded", async () => { }; const connectSocket = () => { - if (socket && socket.readyState === WebSocket.OPEN) { + if (socket && socket.readyState === WebSocket.OPEN && socket.authenticated) { return Promise.resolve(socket); } @@ -158,6 +158,7 @@ document.addEventListener("DOMContentLoaded", async () => { const message = JSON.parse(event.data); if (message.type === "auth:success") { currentUsername = message.username; + socket.authenticated = true; resolve(socket); } if (message.type === "auth:error") { @@ -258,7 +259,7 @@ document.addEventListener("DOMContentLoaded", async () => { logoutButton.addEventListener("click", async (e) => { e.preventDefault(); try { - if (socket && socket.readyState === WebSocket.OPEN) { + if (socket && socket.readyState === WebSocket.OPEN && socket.authenticated) { socket.close(); } const logoutRes = await fetch("/logout", { @@ -266,7 +267,7 @@ document.addEventListener("DOMContentLoaded", async () => { credentials: "same-origin" }); if (logoutRes.ok) { - document.cookie = "authorization=; Max-Age=0; path=/;"; + localStorage.removeItem("activeLobbyId"); window.location.href = "/login.html"; } } catch (err) { diff --git a/public/frontend_poker.js b/public/frontend_poker.js index ae7f202..6cd683b 100644 --- a/public/frontend_poker.js +++ b/public/frontend_poker.js @@ -29,9 +29,8 @@ document.addEventListener("DOMContentLoaded", () => { let socket = null; let socketReady = 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; @@ -129,6 +128,7 @@ document.addEventListener("DOMContentLoaded", () => { actionButtons.forEach((button) => { button.disabled = !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; @@ -178,7 +178,7 @@ document.addEventListener("DOMContentLoaded", () => { }; const connectSocket = () => { - if (socket && socket.readyState === WebSocket.OPEN) { + if (socket && socket.readyState === WebSocket.OPEN && socket.authenticated) { return Promise.resolve(socket); } @@ -190,7 +190,16 @@ document.addEventListener("DOMContentLoaded", () => { socket = new WebSocket(`${protocol}//${window.location.host}`); socketReady = new Promise((resolve, reject) => { - socket.addEventListener("open", () => resolve(socket), {once: true}); + socket.addEventListener("message", (event) => { + const message = JSON.parse(event.data); + if (message.type === "auth:success") { + socket.authenticated = true; + resolve(socket); + } else { + reject(new Error(message.message || "Authentication failed")); + } + }, {once: true}); + socket.addEventListener("close", () => reject(new Error("Connection closed")), {once: true}); socket.addEventListener("error", () => reject(new Error("WebSocket connection failed")), {once: true}); }); @@ -200,7 +209,6 @@ document.addEventListener("DOMContentLoaded", () => { 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); @@ -252,6 +260,10 @@ document.addEventListener("DOMContentLoaded", () => { }); socket.addEventListener("close", () => { + isYourTurn = false; + stopCountdown(); + updateActionButtons(); + showError("Connection lost. Reload to reconnect to the table."); socket = null; socketReady = null; }); @@ -271,7 +283,7 @@ document.addEventListener("DOMContentLoaded", () => { const minBet = maxBet > 0 ? Math.min(requiredBet, maxBet) : 0; betRange.min = String(minBet); betRange.max = String(maxBet); - betRange.step = String(minimumStake); + betRange.step = "1"; setBetValue(Math.max(minBet, Number(betRange.value) || minBet)); }; diff --git a/public/home.html b/public/home.html index 82b723c..b7d61fc 100644 --- a/public/home.html +++ b/public/home.html @@ -2,6 +2,7 @@ + Home diff --git a/public/login.html b/public/login.html index 53f937d..07a4940 100644 --- a/public/login.html +++ b/public/login.html @@ -2,6 +2,7 @@ + Login diff --git a/public/poker.html b/public/poker.html index dd27b50..a7482fa 100644 --- a/public/poker.html +++ b/public/poker.html @@ -36,7 +36,7 @@
- +
@@ -49,9 +49,9 @@
Player - Your Bet: $0 + Table Bet: $0 Your Total: $0 - Your Stack: $400 + Your Stack: $0 Game starting
diff --git a/public/unauthorized.html b/public/unauthorized.html index 51ef9d9..78d9555 100644 --- a/public/unauthorized.html +++ b/public/unauthorized.html @@ -2,6 +2,7 @@ + Access Denied