try to fix this spaghetti of a code

This commit is contained in:
Frederik Spirgi
2026-09-09 10:32:54 +02:00
parent a74a9995cd
commit 9e099a41db
15 changed files with 576 additions and 234 deletions
+5
View File
@@ -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
+43 -2
View File
@@ -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 <http://localhost:42069/login>.
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 <token>`; 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.
+184 -201
View File
@@ -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});
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
};
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;
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,8 +1560,23 @@ 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"});
});
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, () => {
@@ -1620,3 +1587,19 @@ initializeDatabase()
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};
+147
View File
@@ -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};
+6 -2
View File
@@ -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",
+4 -3
View File
@@ -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) {
+18 -6
View File
@@ -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));
};
+1
View File
@@ -2,6 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Home</title>
<link rel="stylesheet" href="styles_home.css">
<link rel="icon" href="gambling.png" type="image/x-icon">
+1
View File
@@ -2,6 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login</title>
<link rel="stylesheet" href="styles_login.css">
<link rel="icon" href="gambling.png" type="image/x-icon">
+3 -3
View File
@@ -36,7 +36,7 @@
</section>
<section class="actions" aria-label="Poker actions">
<button data-action="hit">Hit</button>
<button data-action="hit">Check</button>
<button data-action="fold">Fold</button>
<button data-action="bet">Bet</button>
</section>
@@ -49,9 +49,9 @@
<div id="spectator-note" class="spectator-note hidden">Spectating</div>
<div class="player-status">
<span id="player-name">Player</span>
<span>Your Bet: $<strong id="current-bet">0</strong></span>
<span>Table Bet: $<strong id="current-bet">0</strong></span>
<span>Your Total: $<strong id="your-contribution">0</strong></span>
<span>Your Stack: $<strong id="your-stack">400</strong></span>
<span>Your Stack: $<strong id="your-stack">0</strong></span>
<span id="last-action">Game starting</span>
</div>
</section>
+1
View File
@@ -2,6 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Access Denied</title>
<style>
body {
+1 -1
View File
@@ -5,7 +5,7 @@ const doc = {
title: 'GAMBLE!',
description: 'Gambling API'
},
host: 'localhost:25566',
servers: [{url: '/'}],
components: {
securitySchemes:{
bearerAuth: {
+1 -1
View File
@@ -7,7 +7,7 @@
},
"servers": [
{
"url": "http://localhost:25565/"
"url": "/"
}
],
"paths": {
+36
View File
@@ -0,0 +1,36 @@
const {test} = require("node:test");
const assert = require("node:assert/strict");
const {createDeck, evaluateBestHand, compareScores, calculatePayouts} = require("../lib/poker");
const cards = text => text.split(" ").map(card => ({rank: card.slice(0, -1), suit: card.slice(-1)}));
test("deck contains 52 distinct cards", () => {
for (let i = 0; i < 20; i++) {
const deck = createDeck();
assert.equal(deck.length, 52);
assert.equal(new Set(deck.map(card => card.rank + card.suit)).size, 52);
}
});
test("evaluates wheel, royal flush, and best full house from seven cards", () => {
assert.deepEqual(evaluateBestHand(cards("AS 2H 3D 4C 5S KH QD")).score, [4, 5]);
assert.deepEqual(evaluateBestHand(cards("AS KS QS JS 10S 2H 3D")).score, [9, 14]);
assert.deepEqual(evaluateBestHand(cards("AS AH AD KS KH KD 2D")).score, [6, 14, 13]);
assert.ok(compareScores([1, 10, 14, 8, 7], [1, 10, 13, 12, 11]) > 0);
});
test("short all-in wins only main pot; remaining players contest side pot", () => {
const payouts = calculatePayouts({a: 20, b: 100, c: 100}, [
{player: "a", result: {score: [8]}},
{player: "b", result: {score: [4]}},
{player: "c", result: {score: [1]}}
], ["a", "b", "c"]);
assert.deepEqual({...payouts}, {a: 60, b: 160});
});
test("folded contributions count, ties preserve every chip, unmatched bets return", () => {
const payouts = calculatePayouts({a: 5, b: 5, folded: 5}, [
{player: "a", result: {score: [1, 14]}},
{player: "b", result: {score: [1, 14]}}
], ["b", "a"]);
assert.deepEqual({...payouts}, {b: 8, a: 7});
assert.deepEqual({...calculatePayouts({a: 20, b: 100}, [
{player: "a", result: {score: [8]}}, {player: "b", result: {score: [1]}}
], ["a", "b"])}, {a: 40, b: 80});
});
+110
View File
@@ -0,0 +1,110 @@
const {test, before, after} = require("node:test");
const assert = require("node:assert/strict");
const {once} = require("node:events");
const {WebSocket} = require("ws");
process.env.DATABASE_PATH = ":memory:";
process.env.TOKEN_SECRET = "isolated-test-secret";
const api = require("../gambling");
let base;
const sessions = Object.create(null);
before(async () => {
await api.initializeDatabase({migrate: false});
api.server.listen(0, "127.0.0.1");
await once(api.server, "listening");
base = `http://127.0.0.1:${api.server.address().port}`;
});
after(() => api.close());
async function request(route, {user, method = "GET", body} = {}) {
return fetch(base + route, {method, headers: {
...(body !== undefined ? {"Content-Type": "application/json"} : {}),
...(user ? {Authorization: `Bearer ${sessions[user]}`} : {})
}, body: body === undefined ? undefined : JSON.stringify(body)});
}
function nextMessage(socket, type) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {socket.off("message", listener); reject(new Error(`Timeout: ${type}`));}, 3000);
function listener(raw) {
const value = JSON.parse(raw);
if (value.type !== type) return;
clearTimeout(timer); socket.off("message", listener); resolve(value);
}
socket.on("message", listener);
});
}
test("HTTP authentication and WebSocket lobby lifecycle", async () => {
assert.equal((await request("/balance")).status, 401);
assert.equal((await request("/register", {method: "POST"})).status, 400);
for (const user of ["alice", "bob", "__proto__"]) {
const response = await request("/register", {method: "POST", body: {username: user, password: "test-password"}});
assert.equal(response.status, 200);
sessions[user] = (await response.json()).token;
}
assert.equal((await request("/balance", {user: "alice"})).status, 200);
assert.equal((await request("/register", {method: "POST", body: {username: "long", password: "x".repeat(73)}})).status, 400);
const oldToken = sessions.alice;
const login = await request("/login", {method: "POST", body: {username: " ALICE ", password: "test-password"}});
sessions.alice = (await login.json()).token;
assert.notEqual(sessions.alice, oldToken);
assert.equal((await request("/lobby/join?lobbyId=__proto__", {user: "alice"})).status, 404);
const socket = new WebSocket(base.replace("http", "ws"), {headers: {Authorization: `Bearer ${sessions.alice}`, Cookie: "bad=%ZZ"}});
await nextMessage(socket, "auth:success");
let pending = nextMessage(socket, "lobby:state");
socket.send(JSON.stringify({type: "lobby:create"}));
const lobby = await pending;
assert.deepEqual(lobby.players, ["alice"]);
assert.equal((await request("/lobby/create", {user: "alice"})).status, 409);
assert.equal((await request(`/lobby/join?lobbyId=${lobby.lobbyId}`, {user: "bob"})).status, 200);
pending = nextMessage(socket, "poker:started");
socket.send(JSON.stringify({type: "poker:start"}));
await pending;
pending = nextMessage(socket, "poker:error");
socket.send(JSON.stringify({type: "poker:start"}));
assert.match((await pending).message, /already started/);
pending = nextMessage(socket, "error");
socket.send("null");
assert.match((await pending).message, /object/);
const closed = once(socket, "close");
await request("/logout", {user: "alice", method: "DELETE"});
socket.send(JSON.stringify({type: "lobby:create"}));
await closed;
assert.equal((await request("/balance", {user: "alice"})).status, 403);
});
test("leaving before the current seat preserves turn and independent lobby roster", async () => {
const id = "test-table";
api.playerMap[id] = ["alice", "bob", "__proto__"];
const game = await api.createPokerGame(id);
assert.equal(game.players[game.currentPlayerIndex], "bob");
assert.notEqual(game.players, api.playerMap[id]);
await api.removePlayerFromPokerGame(id, "alice");
assert.equal(game.players[game.currentPlayerIndex], "bob");
assert.equal(game.contributions.alice, 10);
api.clearTurnTimer(game);
});
test("fractional and nonnumeric bets do not mutate the pot", () => {
const game = api.pokerGames["test-table"];
const pot = game.pot;
for (const amount of [0.5, "10", null, Infinity, -1]) {
assert.match(api.applyPokerAction(game, "bob", "bet", {amount}).error, /whole number/);
assert.equal(game.pot, pot);
}
});
test("malformed cookie encoding is ignored", () => {
assert.equal(api.parseCookieHeader("bad=%ZZ; authorization=valid").authorization, "valid");
});
test("ante-only all-ins run out the board without waiting for a broke player", async () => {
const id = "all-in-table";
api.playerMap[id] = ["alice", "bob"];
api.pokerGames[id] = {round: 1, phase: "intermission", dealerIndex: 0, stacks: {alice: 10, bob: 10}};
const game = await api.createPokerGame(id);
assert.equal(game.round, 1);
assert.equal(game.communityCards.length, 5);
assert.equal(game.potAwarded, true);
assert.equal(game.stacks.alice + game.stacks.bob, 20);
// Allow the asynchronous persistence and intermission transition to complete.
for (let i = 0; i < 100 && api.pokerGames[id] === game; i++) {
await new Promise(resolve => setTimeout(resolve, 5));
}
assert.equal(api.pokerGames[id].phase, "intermission");
assert.equal(api.pokerGames[id].round, 2);
});