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 # Gambling API
Funny ah gambling heheha
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.
+198 -215
View File
@@ -1,3 +1,4 @@
const {createDeck, compareScores, evaluateBestHand, calculatePayouts} = require("./lib/poker");
const express = require("express"); const express = require("express");
const app = express(); const app = express();
const http = require("http"); const http = require("http");
@@ -8,7 +9,7 @@ const path = require("path");
app.use(express.static(path.join(__dirname, "public"))); app.use(express.static(path.join(__dirname, "public")));
const fs = require("fs/promises"); const fs = require("fs/promises");
const crypto = require("crypto"); const crypto = require("crypto");
const dotenv = require("dotenv").config(); require("dotenv").config();
const jwt = require("jsonwebtoken"); const jwt = require("jsonwebtoken");
const cookieParser = require("cookie-parser"); const cookieParser = require("cookie-parser");
const bcrypt = require("bcrypt"); const bcrypt = require("bcrypt");
@@ -16,7 +17,7 @@ const sqlite3 = require("sqlite3");
const {open} = require("sqlite"); const {open} = require("sqlite");
const port = process.env.PORT || 42069; const port = process.env.PORT || 42069;
const server = http.createServer(app); const server = http.createServer(app);
const wss = new WebSocketServer({server}); const wss = new WebSocketServer({server, maxPayload: 16 * 1024});
let db; let db;
const authCookieOptions = { const authCookieOptions = {
httpOnly: true, httpOnly: true,
@@ -33,13 +34,14 @@ function logAuthEvent(level, message, details = {}) {
const safeDetails = Object.entries(details).reduce((result, [key, value]) => { const safeDetails = Object.entries(details).reduce((result, [key, value]) => {
if (value !== undefined) result[key] = value; if (value !== undefined) result[key] = value;
return result; return result;
}, {}); }, Object.create(null));
if (process.env.AUTH_DEBUG !== "true") return;
const logger = console[level] || console.log; const logger = console[level] || console.log;
logger(`[auth] ${message}`, safeDetails); logger(`[auth] ${message}`, safeDetails);
} }
function getTokenLogDetails(token) { function getTokenLogDetails(token) {
if (!token) { if (typeof token !== "string" || !token) {
return { return {
present: false present: false
}; };
@@ -51,8 +53,8 @@ function getTokenLogDetails(token) {
fingerprint: crypto.createHash("sha256").update(token).digest("hex").slice(0, 12), fingerprint: crypto.createHash("sha256").update(token).digest("hex").slice(0, 12),
length: token.length, length: token.length,
subject: decoded.username, subject: decoded.username,
issuedAt: decoded.iat ? new Date(decoded.iat * 1000).toISOString() : undefined, issuedAt: decoded.iat ? String(decoded.iat) : undefined,
expiresAt: decoded.exp ? new Date(decoded.exp * 1000).toISOString() : undefined expiresAt: decoded.exp ? String(decoded.exp) : undefined
}; };
} }
@@ -86,9 +88,9 @@ function getAuthCookieOptions(request) {
Here are Helper function ----------------------------------------------------------------------------------------------- Here are Helper function -----------------------------------------------------------------------------------------------
*/ */
async function initializeDatabase() { async function initializeDatabase({migrate = true} = {}) {
db = await open({ db = await open({
filename: path.join(__dirname, "users.sqlite"), filename: process.env.DATABASE_PATH || path.join(__dirname, "users.sqlite"),
driver: sqlite3.Database 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() { async function migrateJsonUsers() {
@@ -177,7 +180,7 @@ async function setUserMoney(username, money) {
function generateAccessToken(user) { function generateAccessToken(user) {
logAuthEvent("info", "Generating access token", {username: user.username}); 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", { logAuthEvent("info", "Generated access token", {
username: user.username, username: user.username,
token: getTokenLogDetails(token) token: getTokenLogDetails(token)
@@ -202,7 +205,7 @@ function normalizeUsername(username) {
} }
function isValidPassword(password) { 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 = "") { function parseCookieHeader(cookieHeader = "") {
@@ -210,10 +213,11 @@ function parseCookieHeader(cookieHeader = "") {
const separatorIndex = cookie.indexOf("="); const separatorIndex = cookie.indexOf("=");
if (separatorIndex === -1) return cookies; if (separatorIndex === -1) return cookies;
const key = cookie.slice(0, separatorIndex).trim(); 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; cookies[key] = value;
return cookies; return cookies;
}, {}); }, Object.create(null));
} }
function getAuthTokenFromRequest(request) { function getAuthTokenFromRequest(request) {
@@ -221,7 +225,7 @@ function getAuthTokenFromRequest(request) {
} }
async function verifyAuthToken(token) { async function verifyAuthToken(token) {
if (!token) { if (typeof token !== "string" || !token) {
logAuthEvent("warn", "Token verification skipped because token is missing"); logAuthEvent("warn", "Token verification skipped because token is missing");
return null; return null;
} }
@@ -298,19 +302,19 @@ async function authenticateToken(request, response, next) {
*/ */
app.post("/register", async (request, response) => { app.post("/register", async (request, response) => {
let {username, password} = request.body; let {username, password} = request.body || {};
logAuthEvent("info", "Register request received", { logAuthEvent("info", "Register request received", {
...getAuthRequestDetails(request), ...getAuthRequestDetails(request),
username: normalizeUsername(username), username: normalizeUsername(username),
hasPassword: isValidPassword(password) hasPassword: isValidPassword(password)
}); });
username = normalizeUsername(username); username = normalizeUsername(username);
if (!username || !isValidPassword(password)) { if (!username || username.length > 40 || !isValidPassword(password)) {
logAuthEvent("warn", "Register request rejected because credentials are incomplete", { logAuthEvent("warn", "Register request rejected because credentials are incomplete", {
username, username,
hasPassword: isValidPassword(password) 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); const existingUser = await findUserByUsername(username);
if (existingUser) { if (existingUser) {
@@ -318,7 +322,12 @@ app.post("/register", async (request, response) => {
return response.status(400).json({error: "Username already exists"}); return response.status(400).json({error: "Username already exists"});
} }
const token = generateAccessToken({username}); 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", { logAuthEvent("info", "Registered user and stored initial token", {
username, username,
token: getTokenLogDetails(token) token: getTokenLogDetails(token)
@@ -353,19 +362,19 @@ app.get("/login", (request, response) => {
}) })
app.post("/login", async (request, response) => { app.post("/login", async (request, response) => {
let {username, password} = request.body; let {username, password} = request.body || {};
logAuthEvent("info", "Login request received", { logAuthEvent("info", "Login request received", {
...getAuthRequestDetails(request), ...getAuthRequestDetails(request),
username: normalizeUsername(username), username: normalizeUsername(username),
hasPassword: isValidPassword(password) hasPassword: isValidPassword(password)
}); });
username = normalizeUsername(username); username = normalizeUsername(username);
if (!username || !isValidPassword(password)) { if (!username || username.length > 40 || !isValidPassword(password)) {
logAuthEvent("warn", "Login request rejected because credentials are incomplete", { logAuthEvent("warn", "Login request rejected because credentials are incomplete", {
username, username,
hasPassword: isValidPassword(password) 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); const user = await findUserByUsername(username);
if (!user) { if (!user) {
@@ -379,7 +388,7 @@ app.post("/login", async (request, response) => {
const passwordMatches = await bcrypt.compare(password, user.passwordHash); const passwordMatches = await bcrypt.compare(password, user.passwordHash);
if (!passwordMatches) { if (!passwordMatches) {
logAuthEvent("warn", "Login request rejected because password did not match", {username}); 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}); logAuthEvent("info", "Login password matched", {username});
const token = generateAccessToken({username}); const token = generateAccessToken({username});
@@ -464,31 +473,19 @@ app.delete("/logout", authenticateToken, async (request, response) => {
response.json({message: "Logged out successfully."}); response.json({message: "Logged out successfully."});
}); });
const playerMap = {}; const playerMap = Object.create(null);
const lobbySockets = {}; const lobbySockets = Object.create(null);
const lobbyHosts = {}; const lobbyHosts = Object.create(null);
const lobbyPrivacy = {}; const lobbyPrivacy = Object.create(null);
const pokerGames = {}; const pokerGames = Object.create(null);
const disconnectTimers = {}; const disconnectTimers = Object.create(null);
const startingLobbies = new Map();
const ROUND_INTERMISSION_MS = 30000; const ROUND_INTERMISSION_MS = 30000;
const TURN_TIMEOUT_MS = 30000; const TURN_TIMEOUT_MS = 30000;
const DISCONNECT_GRACE_MS = 10000; const DISCONNECT_GRACE_MS = 10000;
const MINIMUM_STAKE = 10; const MINIMUM_STAKE = 10;
const STARTING_STACK = 200; 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 = {}) { async function getInitialPokerStack(player, previousStacks = {}) {
if (Number.isFinite(previousStacks[player])) { if (Number.isFinite(previousStacks[player])) {
return previousStacks[player]; return previousStacks[player];
@@ -502,17 +499,17 @@ async function persistPokerStacks(game) {
} }
async function createPokerGame(lobbyId) { async function createPokerGame(lobbyId) {
const players = playerMap[lobbyId] || []; const players = [...(playerMap[lobbyId] || [])];
const existingGame = pokerGames[lobbyId]; const existingGame = pokerGames[lobbyId];
if (existingGame?.nextRoundTimer) { if (existingGame?.nextRoundTimer) {
clearTimeout(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 previousStacks = existingGame?.stacks || {};
const dealerIndex = existingGame const dealerIndex = existingGame
? (existingGame.dealerIndex + 1) % Math.max(players.length, 1) ? (existingGame.dealerIndex + 1) % Math.max(players.length, 1)
: 0; : 0;
const stacks = {}; const stacks = Object.create(null);
const playersWithChips = []; const playersWithChips = [];
for (const player of players) { for (const player of players) {
@@ -523,12 +520,12 @@ async function createPokerGame(lobbyId) {
} }
const activePlayers = playersWithChips.length >= 2 ? playersWithChips : []; const activePlayers = playersWithChips.length >= 2 ? playersWithChips : [];
const deck = createPlaceholderDeck(); const deck = createDeck();
const hands = {}; const hands = Object.create(null);
const folded = {}; const folded = Object.create(null);
const acted = {}; const acted = Object.create(null);
const contributions = {}; const contributions = Object.create(null);
const playerBets = {}; const playerBets = Object.create(null);
let pot = 0; let pot = 0;
for (const player of activePlayers) { for (const player of activePlayers) {
@@ -570,7 +567,7 @@ async function createPokerGame(lobbyId) {
playerBets, playerBets,
stacks, stacks,
currentPlayerIndex: activePlayers.length >= 2 currentPlayerIndex: activePlayers.length >= 2
? getFirstActiveLeftOfDealer({players, folded, dealerIndex}) ? getFirstActiveLeftOfDealer({players, folded, hands, stacks, dealerIndex})
: -1, : -1,
lastAction: activePlayers.length >= 2 lastAction: activePlayers.length >= 2
? `Round ${roundNumber} started. $${MINIMUM_STAKE} minimum stake paid by each player.` ? `Round ${roundNumber} started. $${MINIMUM_STAKE} minimum stake paid by each player.`
@@ -587,24 +584,35 @@ async function createPokerGame(lobbyId) {
minimumStake: MINIMUM_STAKE minimumStake: MINIMUM_STAKE
}; };
startTurnTimer(pokerGames[lobbyId]); if (activePlayers.length >= 2 && pokerGames[lobbyId].currentPlayerIndex === -1) {
revealNextStreet(pokerGames[lobbyId]);
} else {
startTurnTimer(pokerGames[lobbyId]);
}
return pokerGames[lobbyId]; return pokerGames[lobbyId];
} }
async function createPokerIntermission(lobbyId) { function createPokerIntermission(lobbyId) {
const players = playerMap[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]; const existingGame = pokerGames[lobbyId];
if (existingGame?.nextRoundTimer) { if (existingGame?.nextRoundTimer) {
clearTimeout(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 previousStacks = existingGame?.stacks || {};
const stacks = {}; const stacks = Object.create(null);
const folded = {}; const folded = Object.create(null);
const acted = {}; const acted = Object.create(null);
const contributions = {}; const contributions = Object.create(null);
const playerBets = {}; const playerBets = Object.create(null);
for (const player of players) { for (const player of players) {
stacks[player] = await getInitialPokerStack(player, previousStacks); stacks[player] = await getInitialPokerStack(player, previousStacks);
@@ -631,7 +639,7 @@ async function createPokerIntermission(lobbyId) {
bet: 0, bet: 0,
dealerIndex: existingGame?.dealerIndex ?? 0, dealerIndex: existingGame?.dealerIndex ?? 0,
communityCards: [], communityCards: [],
hands: {}, hands: Object.create(null),
folded, folded,
acted, acted,
contributions, contributions,
@@ -654,9 +662,13 @@ async function createPokerIntermission(lobbyId) {
pokerGames[lobbyId] = intermission; pokerGames[lobbyId] = intermission;
intermission.nextRoundTimer = setTimeout(async () => { intermission.nextRoundTimer = setTimeout(async () => {
if (!pokerGames[lobbyId] || !playerMap[lobbyId]?.length) return; if (!pokerGames[lobbyId] || !playerMap[lobbyId]?.length) return;
await createPokerGame(lobbyId); try {
broadcastLobby(lobbyId); await createPokerGame(lobbyId);
broadcastPokerState(lobbyId); broadcastLobby(lobbyId);
broadcastPokerState(lobbyId);
} catch (error) {
console.error("Failed to start next round:", error);
}
}, ROUND_INTERMISSION_MS); }, ROUND_INTERMISSION_MS);
return intermission; return intermission;
@@ -779,7 +791,7 @@ async function completeRoundWithWinner(game, winner) {
async function scheduleNextRound(game) { async function scheduleNextRound(game) {
clearTurnTimer(game); clearTurnTimer(game);
if (game.nextRoundTimer || !pokerGames[game.lobbyId]) return; if (game.nextRoundTimer || pokerGames[game.lobbyId] !== game) return;
await createPokerIntermission(game.lobbyId); await createPokerIntermission(game.lobbyId);
broadcastLobby(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) { async function startShowdown(game) {
const activePlayers = getActivePlayers(game); const activePlayers = getActivePlayers(game);
const evaluations = activePlayers.map(player => ({ const evaluations = activePlayers.map(player => ({
@@ -1000,17 +906,20 @@ async function startShowdown(game) {
game.showdown = revealOrder; game.showdown = revealOrder;
game.winner = winners.length ? winners.map(entry => entry.player).join(", ") : null; game.winner = winners.length ? winners.map(entry => entry.player).join(", ") : null;
if (winners.length && !game.potAwarded) { if (winners.length && !game.potAwarded) {
const baseShare = Math.floor(game.pot / winners.length); const seatOrder = game.players.slice(game.dealerIndex + 1).concat(game.players.slice(0, game.dealerIndex + 1));
let remainder = game.pot % winners.length; const payouts = calculatePayouts(game.contributions, evaluations, seatOrder);
for (const entry of winners) { for (const [player, amount] of Object.entries(payouts)) {
const extraChip = remainder > 0 ? 1 : 0; if (game.players.includes(player)) {
game.stacks[entry.player] += baseShare + extraChip; game.stacks[player] = (game.stacks[player] || 0) + amount;
remainder -= extraChip; } 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.potAwarded = true;
} }
game.lastAction = game.winner 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"; : "Showdown ended";
await persistPokerStacks(game); await persistPokerStacks(game);
await scheduleNextRound(game); await scheduleNextRound(game);
@@ -1074,6 +983,7 @@ async function removePlayerFromPokerGame(lobbyId, username) {
await setUserMoney(username, remainingStack); await setUserMoney(username, remainingStack);
} }
const currentPlayer = getCurrentPlayer(game);
const index = game.players.indexOf(username); const index = game.players.indexOf(username);
if (index > -1) { if (index > -1) {
game.players.splice(index, 1); game.players.splice(index, 1);
@@ -1081,7 +991,7 @@ async function removePlayerFromPokerGame(lobbyId, username) {
delete game.hands[username]; delete game.hands[username];
delete game.folded[username]; delete game.folded[username];
delete game.acted[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.playerBets[username];
delete game.stacks[username]; delete game.stacks[username];
@@ -1094,20 +1004,18 @@ async function removePlayerFromPokerGame(lobbyId, username) {
return; return;
} }
if (game.currentPlayerIndex >= game.players.length) { game.currentPlayerIndex = currentPlayer === username
game.currentPlayerIndex = 0; ? (index - 1 + game.players.length) % game.players.length
} : game.players.indexOf(currentPlayer);
game.dealerIndex = Math.min(game.dealerIndex, game.players.length - 1); game.dealerIndex = Math.min(game.dealerIndex, game.players.length - 1);
if (game.turnTimerPlayer === username) { if (game.phase !== "betting") return;
clearTurnTimer(game);
if (game.phase === "betting" && !haveAllActivePlayersMatched(game)) {
startTurnTimer(game);
}
}
if (finishIfOnlyOneActive(game)) return; if (finishIfOnlyOneActive(game)) return;
if (game.phase === "betting" && haveAllActivePlayersMatched(game)) { if (currentPlayer === username) {
advanceTurn(game);
} else if (haveAllActivePlayersMatched(game)) {
revealNextStreet(game); revealNextStreet(game);
} }
} }
async function ensurePlayerInPokerGame(lobbyId, username) { async function ensurePlayerInPokerGame(lobbyId, username) {
@@ -1153,17 +1061,20 @@ function applyPokerAction(game, username, action, payload = {}, automatic = fals
} }
if (action === "bet") { 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]) { if (amount > game.stacks[username]) {
return {error: "You do not have enough chips"}; 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}`}; return {error: `Minimum stake is $${MINIMUM_STAKE}`};
} }
if (amount <= 0 && game.playerBets[username] < game.currentBet) { if (amount <= 0 && game.playerBets[username] < game.currentBet) {
return {error: `You need to match the current bet of $${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`}; return {error: `Minimum bet is $${game.currentBet - game.playerBets[username]} to call`};
} }
clearTurnTimer(game); clearTurnTimer(game);
@@ -1238,6 +1149,12 @@ async function leavePokerLobby(lobbyId, username) {
if (!lobbyId || !username || !playerMap[lobbyId]?.includes(username)) return false; if (!lobbyId || !username || !playerMap[lobbyId]?.includes(username)) return false;
clearDisconnectTimer(lobbyId, username); 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 leaveLobby(lobbyId, username);
await removePlayerFromPokerGame(lobbyId, username); await removePlayerFromPokerGame(lobbyId, username);
await deleteLobby(lobbyId); 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; let lobbyId;
do { do {
lobbyId = crypto.randomBytes(4).toString("hex"); lobbyId = crypto.randomBytes(4).toString("hex");
@@ -1321,7 +1252,7 @@ async function createLobby() {
} }
app.post("/leave-lobby", authenticateToken, async (req, res) => { app.post("/leave-lobby", authenticateToken, async (req, res) => {
const {lobbyId} = req.body; const {lobbyId} = req.body || {};
const username = req.user.username; const username = req.user.username;
if (!lobbyId || !username) { if (!lobbyId || !username) {
return res.status(400).json({error: "Lobby ID und Username sind erforderlich!"}); 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"}); 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) { function removeEmptyLobbies(playerMap) {
for (const lobby in playerMap) { for (const lobby in playerMap) {
@@ -1378,6 +1310,7 @@ async function deleteLobby(id) {
async function joinLobby(lobbyId, playerName) { async function joinLobby(lobbyId, playerName) {
if (!lobbyId || !playerName || !playerMap[lobbyId]) return false; if (!lobbyId || !playerName || !playerMap[lobbyId]) return false;
assertLobbyAvailable(playerName, lobbyId);
if (!playerMap[lobbyId].includes(playerName)) { if (!playerMap[lobbyId].includes(playerName)) {
playerMap[lobbyId].push(playerName); playerMap[lobbyId].push(playerName);
} }
@@ -1389,7 +1322,8 @@ app.get("/lobby/create", authenticateToken, async (req, res) => {
if (!username) { if (!username) {
return res.status(400).send("Username is required"); return res.status(400).send("Username is required");
} }
let lobbyId = await createLobby(); assertLobbyAvailable(username);
let lobbyId = createLobby();
if (!playerMap[lobbyId]) { if (!playerMap[lobbyId]) {
playerMap[lobbyId] = []; playerMap[lobbyId] = [];
} }
@@ -1449,7 +1383,15 @@ app.get("/lobby/public", authenticateToken, async (req, res) => {
res.json({lobbies: createPublicLobbyList()}); 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 cookies = parseCookieHeader(request.headers.cookie);
const token = request.headers.authorization?.split(" ")[1] || cookies.authorization; const token = request.headers.authorization?.split(" ")[1] || cookies.authorization;
const user = await verifyAuthToken(token); const user = await verifyAuthToken(token);
@@ -1461,13 +1403,8 @@ wss.on("connection", async (socket, request) => {
} }
socket.user = user; socket.user = user;
sendSocketMessage(socket, {type: "auth:success", username: user.username});
const storedUser = await findUserByUsername(user.username); const storedUser = await findUserByUsername(user.username);
if (storedUser?.lobbyId && playerMap[storedUser.lobbyId]?.includes(user.username)) { if (socket.readyState !== WebSocket.OPEN) return;
subscribeToLobby(socket, storedUser.lobbyId);
broadcastLobby(storedUser.lobbyId);
}
socket.on("message", async (rawMessage) => { socket.on("message", async (rawMessage) => {
let message; let message;
try { try {
@@ -1478,8 +1415,17 @@ wss.on("connection", async (socket, request) => {
} }
try { 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") { if (message.type === "lobby:create") {
const lobbyId = await createLobby(); assertLobbyAvailable(user.username);
const lobbyId = createLobby();
playerMap[lobbyId] = playerMap[lobbyId] || []; playerMap[lobbyId] = playerMap[lobbyId] || [];
if (!playerMap[lobbyId].includes(user.username)) { if (!playerMap[lobbyId].includes(user.username)) {
playerMap[lobbyId].push(user.username); playerMap[lobbyId].push(user.username);
@@ -1544,6 +1490,10 @@ wss.on("connection", async (socket, request) => {
return; return;
} }
if (pokerGames[lobbyId] || startingLobbies.has(lobbyId)) {
sendSocketMessage(socket, {type: "poker:error", message: "Game already started"});
return;
}
await createPokerIntermission(lobbyId); await createPokerIntermission(lobbyId);
broadcastLobby(lobbyId); broadcastLobby(lobbyId);
broadcastPokerState(lobbyId); broadcastPokerState(lobbyId);
@@ -1564,7 +1514,8 @@ wss.on("connection", async (socket, request) => {
subscribeToLobby(socket, lobbyId); subscribeToLobby(socket, lobbyId);
if (!pokerGames[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 ensurePlayerInPokerGame(lobbyId, user.username);
await maybeStartWaitingGame(lobbyId); await maybeStartWaitingGame(lobbyId);
@@ -1596,10 +1547,11 @@ wss.on("connection", async (socket, request) => {
sendSocketMessage(socket, {type: "error", message: "Unknown message type"}); sendSocketMessage(socket, {type: "error", message: "Unknown message type"});
} catch (error) { } catch (error) {
console.error("WebSocket message error:", 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 () => { socket.on("close", async () => {
if (!socket.lobbyId) return; if (!socket.lobbyId) return;
const lobbyId = socket.lobbyId; const lobbyId = socket.lobbyId;
@@ -1608,15 +1560,46 @@ wss.on("connection", async (socket, request) => {
} }
scheduleDisconnectLeave(lobbyId, socket.user.username); 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() if (require.main === module) {
.then(() => { if (!process.env.TOKEN_SECRET) throw new Error("TOKEN_SECRET must be set in the environment or .env");
server.listen(port, () => { initializeDatabase()
console.log("Server is running on port " + port); .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); async function close() {
process.exit(1); 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", "name": "gambling_api",
"version": "1.0.0", "version": "1.0.0",
"description": "", "description": "",
"main": "swagger.js", "main": "gambling.js",
"scripts": { "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": { "repository": {
"type": "git", "type": "git",
+4 -3
View File
@@ -138,7 +138,7 @@ document.addEventListener("DOMContentLoaded", async () => {
}; };
const connectSocket = () => { const connectSocket = () => {
if (socket && socket.readyState === WebSocket.OPEN) { if (socket && socket.readyState === WebSocket.OPEN && socket.authenticated) {
return Promise.resolve(socket); return Promise.resolve(socket);
} }
@@ -158,6 +158,7 @@ document.addEventListener("DOMContentLoaded", async () => {
const message = JSON.parse(event.data); const message = JSON.parse(event.data);
if (message.type === "auth:success") { if (message.type === "auth:success") {
currentUsername = message.username; currentUsername = message.username;
socket.authenticated = true;
resolve(socket); resolve(socket);
} }
if (message.type === "auth:error") { if (message.type === "auth:error") {
@@ -258,7 +259,7 @@ document.addEventListener("DOMContentLoaded", async () => {
logoutButton.addEventListener("click", async (e) => { logoutButton.addEventListener("click", async (e) => {
e.preventDefault(); e.preventDefault();
try { try {
if (socket && socket.readyState === WebSocket.OPEN) { if (socket && socket.readyState === WebSocket.OPEN && socket.authenticated) {
socket.close(); socket.close();
} }
const logoutRes = await fetch("/logout", { const logoutRes = await fetch("/logout", {
@@ -266,7 +267,7 @@ document.addEventListener("DOMContentLoaded", async () => {
credentials: "same-origin" credentials: "same-origin"
}); });
if (logoutRes.ok) { if (logoutRes.ok) {
document.cookie = "authorization=; Max-Age=0; path=/;"; localStorage.removeItem("activeLobbyId");
window.location.href = "/login.html"; window.location.href = "/login.html";
} }
} catch (err) { } catch (err) {
+18 -6
View File
@@ -29,9 +29,8 @@ document.addEventListener("DOMContentLoaded", () => {
let socket = null; let socket = null;
let socketReady = null; let socketReady = null;
let isYourTurn = false; let isYourTurn = false;
let canLeaveTable = true;
let isSpectator = false; let isSpectator = false;
let currentStackValue = 400; let currentStackValue = 0;
let minimumStake = 10; let minimumStake = 10;
let callAmount = 0; let callAmount = 0;
let countdownTimer = null; let countdownTimer = null;
@@ -129,6 +128,7 @@ document.addEventListener("DOMContentLoaded", () => {
actionButtons.forEach((button) => { actionButtons.forEach((button) => {
button.disabled = !isYourTurn || isSpectator; button.disabled = !isYourTurn || isSpectator;
if (button.dataset.action === "hit") { if (button.dataset.action === "hit") {
button.textContent = callAmount > 0 ? "Call" : "Check";
button.title = callAmount > 0 button.title = callAmount > 0
? `Call with $${Math.min(callAmount, currentStackValue)}.` ? `Call with $${Math.min(callAmount, currentStackValue)}.`
: actionTooltips.hit; : actionTooltips.hit;
@@ -178,7 +178,7 @@ document.addEventListener("DOMContentLoaded", () => {
}; };
const connectSocket = () => { const connectSocket = () => {
if (socket && socket.readyState === WebSocket.OPEN) { if (socket && socket.readyState === WebSocket.OPEN && socket.authenticated) {
return Promise.resolve(socket); return Promise.resolve(socket);
} }
@@ -190,7 +190,16 @@ document.addEventListener("DOMContentLoaded", () => {
socket = new WebSocket(`${protocol}//${window.location.host}`); socket = new WebSocket(`${protocol}//${window.location.host}`);
socketReady = new Promise((resolve, reject) => { 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}); socket.addEventListener("error", () => reject(new Error("WebSocket connection failed")), {once: true});
}); });
@@ -200,7 +209,6 @@ document.addEventListener("DOMContentLoaded", () => {
if (message.type === "poker:state") { if (message.type === "poker:state") {
hideError(); hideError();
isYourTurn = Boolean(message.isYourTurn); isYourTurn = Boolean(message.isYourTurn);
canLeaveTable = Boolean(message.canLeaveTable);
isSpectator = Boolean(message.isSpectator); isSpectator = Boolean(message.isSpectator);
currentStackValue = Number(message.yourStack ?? 0); currentStackValue = Number(message.yourStack ?? 0);
minimumStake = Number(message.minimumStake || 10); minimumStake = Number(message.minimumStake || 10);
@@ -252,6 +260,10 @@ document.addEventListener("DOMContentLoaded", () => {
}); });
socket.addEventListener("close", () => { socket.addEventListener("close", () => {
isYourTurn = false;
stopCountdown();
updateActionButtons();
showError("Connection lost. Reload to reconnect to the table.");
socket = null; socket = null;
socketReady = null; socketReady = null;
}); });
@@ -271,7 +283,7 @@ document.addEventListener("DOMContentLoaded", () => {
const minBet = maxBet > 0 ? Math.min(requiredBet, maxBet) : 0; const minBet = maxBet > 0 ? Math.min(requiredBet, maxBet) : 0;
betRange.min = String(minBet); betRange.min = String(minBet);
betRange.max = String(maxBet); betRange.max = String(maxBet);
betRange.step = String(minimumStake); betRange.step = "1";
setBetValue(Math.max(minBet, Number(betRange.value) || minBet)); setBetValue(Math.max(minBet, Number(betRange.value) || minBet));
}; };
+1
View File
@@ -2,6 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Home</title> <title>Home</title>
<link rel="stylesheet" href="styles_home.css"> <link rel="stylesheet" href="styles_home.css">
<link rel="icon" href="gambling.png" type="image/x-icon"> <link rel="icon" href="gambling.png" type="image/x-icon">
+1
View File
@@ -2,6 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login</title> <title>Login</title>
<link rel="stylesheet" href="styles_login.css"> <link rel="stylesheet" href="styles_login.css">
<link rel="icon" href="gambling.png" type="image/x-icon"> <link rel="icon" href="gambling.png" type="image/x-icon">
+3 -3
View File
@@ -36,7 +36,7 @@
</section> </section>
<section class="actions" aria-label="Poker actions"> <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="fold">Fold</button>
<button data-action="bet">Bet</button> <button data-action="bet">Bet</button>
</section> </section>
@@ -49,9 +49,9 @@
<div id="spectator-note" class="spectator-note hidden">Spectating</div> <div id="spectator-note" class="spectator-note hidden">Spectating</div>
<div class="player-status"> <div class="player-status">
<span id="player-name">Player</span> <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 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> <span id="last-action">Game starting</span>
</div> </div>
</section> </section>
+1
View File
@@ -2,6 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Access Denied</title> <title>Access Denied</title>
<style> <style>
body { body {
+1 -1
View File
@@ -5,7 +5,7 @@ const doc = {
title: 'GAMBLE!', title: 'GAMBLE!',
description: 'Gambling API' description: 'Gambling API'
}, },
host: 'localhost:25566', servers: [{url: '/'}],
components: { components: {
securitySchemes:{ securitySchemes:{
bearerAuth: { bearerAuth: {
+2 -2
View File
@@ -7,7 +7,7 @@
}, },
"servers": [ "servers": [
{ {
"url": "http://localhost:25565/" "url": "/"
} }
], ],
"paths": { "paths": {
@@ -440,4 +440,4 @@
} }
} }
} }
} }
+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);
});