try to fix this spaghetti of a code
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
|
||||
+185
-202
@@ -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,9 +1560,24 @@ wss.on("connection", async (socket, request) => {
|
||||
}
|
||||
scheduleDisconnectLeave(lobbyId, socket.user.username);
|
||||
});
|
||||
sendSocketMessage(socket, {type: "auth:success", username: user.username});
|
||||
if (storedUser?.lobbyId && playerMap[storedUser.lobbyId]?.includes(user.username)) {
|
||||
subscribeToLobby(socket, storedUser.lobbyId);
|
||||
broadcastLobby(storedUser.lobbyId);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
app.use((error, req, res, next) => {
|
||||
if (res.headersSent) return next(error);
|
||||
const status = error.status >= 400 && error.status < 500 ? error.status : 500;
|
||||
if (status === 500) console.error("Request failed:", error);
|
||||
res.status(status).json({error: status === 500 ? "Internal server error" : status === 409 ? error.message : "Invalid request body"});
|
||||
});
|
||||
|
||||
initializeDatabase()
|
||||
if (require.main === module) {
|
||||
if (!process.env.TOKEN_SECRET) throw new Error("TOKEN_SECRET must be set in the environment or .env");
|
||||
initializeDatabase()
|
||||
.then(() => {
|
||||
server.listen(port, () => {
|
||||
console.log("Server is running on port " + port);
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -5,7 +5,7 @@ const doc = {
|
||||
title: 'GAMBLE!',
|
||||
description: 'Gambling API'
|
||||
},
|
||||
host: 'localhost:25566',
|
||||
servers: [{url: '/'}],
|
||||
components: {
|
||||
securitySchemes:{
|
||||
bearerAuth: {
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "http://localhost:25565/"
|
||||
"url": "/"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
|
||||
@@ -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});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user