Merge pull request #2 from Fretuks/main

Refactor and improve code structure for better readability
This commit is contained in:
IM23a-spirgif
2026-09-09 10:51:17 +02:00
committed by GitHub
21 changed files with 3531 additions and 2539 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
+6
View File
@@ -3,3 +3,9 @@ users.sqlite
users.sqlite-shm
users.sqlite-wal
.env
.npm-cache/
.playwright-browsers/
playwright-report/
test-results/
*.backup
+43 -2
View File
@@ -1,2 +1,43 @@
# Gambling_API
Funny ah gambling heheha
# Gambling API
A small multiplayer poker prototype using Express, WebSockets, SQLite, and a vanilla JavaScript frontend. Accounts start with 200 virtual chips.
## Run locally
Use Node.js 24 (the version used for validation) and npm.
1. Run `npm install`.
2. Copy `.env.example` to `.env` and replace `TOKEN_SECRET` with a generated secret. The generation command is in the example file.
3. Run `npm start` and open <http://localhost:42069/login>.
4. Register two accounts in separate browser profiles, create a lobby, join with the second account, and start the game as host.
`PORT` overrides the listening port. `DATABASE_PATH` overrides the default `users.sqlite` path. `AUTH_DEBUG=true` enables verbose authentication diagnostics. SQLite files and `.env` are ignored by Git.
## Commands
- `npm start`: start the application.
- `npm run dev`: restart on source changes.
- `npm test`: syntax checks and Node regression tests.
- `npm run check`: syntax checks only.
- `npm run docs`: regenerate the HTTP OpenAPI document; browse `/swagger-ui` while the server is running.
If the environment blocks test child processes, run `npm run check` followed by `node --test --test-isolation=none`. Tests use an in-memory database, a temporary listening port, and skip legacy data migration.
## Current behavior
Sessions last 30 minutes. Logging in replaces the previous session. HTTP clients can use the authorization cookie or `Authorization: Bearer <token>`; browser WebSockets use the cookie. Clients must wait for `auth:success` before sending messages.
Lobbies hold at most 10 players, and each account may join one lobby at a time. Private lobbies are hidden from the public list but remain joinable by code. The host starts play. A round begins after a 30-second intermission. Each participating player pays a 10-chip ante; players below that amount spectate. There are four betting streets, best-five-of-seven hand evaluation, and contribution-based side pots. `hit` is the legacy protocol name for check/call. Turns time out after 30 seconds and automatically check/call. Disconnects have a 10-second grace period before leaving.
This is a simplified poker variant: it does not yet enforce standard blinds or minimum-raise/reopening rules. Leaving folds the hand. Unclaimable contribution layers are returned to their contributors. See the audit for settlement edge cases still needing work.
Account records persist in SQLite. Lobby and hand state live in memory and are lost on restart. Startup clears stale lobby references. Legacy `data.json` accounts are imported if present; the source is emptied after import, so back it up before migration.
## Project layout
- `gambling.js`: HTTP routes, database access, authentication, lobby lifecycle, and poker state transitions.
- `lib/poker.js`: pure hand evaluation, deck generation, and side-pot calculations.
- `public/`: HTML, CSS, and browser scripts.
- `test/`: poker and HTTP/WebSocket regression tests.
- `swagger.js` / `swagger.json`: HTTP documentation generator and output.
- [AUDIT.md](AUDIT.md): findings, completed fixes, and prioritized follow-up work.
+38 -1619
View File
File diff suppressed because it is too large Load Diff
+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};
+13 -3
View File
@@ -2,9 +2,16 @@
"name": "gambling_api",
"version": "1.0.0",
"description": "",
"main": "swagger.js",
"main": "gambling.js",
"scripts": {
"test": "node --check gambling.js && node --check public/frontend_home.js && node --check public/frontend_login.js && node --check public/frontend_poker.js && node --check swagger.js"
"start": "node gambling.js",
"dev": "node --watch gambling.js",
"docs": "node swagger.js",
"check": "node scripts/check.js && node swagger.js --check",
"test": "npm run check && node --test --test-isolation=none test/*.test.js",
"test:e2e": "playwright test",
"migrate:legacy": "node scripts/migrate-legacy.js",
"audit:deps": "npm audit --audit-level=high"
},
"repository": {
"type": "git",
@@ -23,6 +30,9 @@
"ws": "^8.20.0"
},
"devDependencies": {
"swagger-autogen": "^2.23.7"
"@playwright/test": "^1.63.0"
},
"engines": {
"node": ">=24"
}
}
+59 -372
View File
@@ -1,381 +1,68 @@
document.addEventListener("DOMContentLoaded", async () => {
const logoutButton = document.getElementById("logout");
const balance = document.getElementById("balance");
const hostButton = document.getElementById("host");
const joinButton = document.getElementById("join");
const hostForm = document.getElementById("host-form");
const createLobbyButton = document.getElementById("create-lobby");
const lobbyVisibilityInputs = document.querySelectorAll("input[name='lobby-visibility']");
const joinForm = document.getElementById("join-form");
const joinCodeInput = document.getElementById("join-code");
const joinSubmit = document.getElementById("submit-join");
const refreshLobbies = document.getElementById("refresh-lobbies");
const publicLobbies = document.getElementById("public-lobbies");
const leaveButton = document.getElementById("leave-button");
const joinError = document.getElementById("join-error");
const lobbyRoom = document.getElementById("lobby-room");
const lobbyIdDisplay = document.getElementById("lobby-id");
const playerList = document.getElementById("player-list");
const startGameButton = document.getElementById("start-game");
let socket = null;
let socketReady = null;
let currentUsername = null;
let currentLobbyId = null;
const showJoinError = (message) => {
joinError.textContent = message;
joinError.classList.remove("hidden");
};
const clearJoinError = () => {
joinError.textContent = "";
joinError.classList.add("hidden");
};
const renderPlayers = (players) => {
playerList.innerHTML = "";
if (!players.length) {
playerList.innerHTML = "<p>No players yet</p>";
return;
}
for (const player of players) {
const entry = document.createElement("p");
entry.textContent = player;
playerList.appendChild(entry);
}
};
const updateStartButton = (host, canStart) => {
const isHost = currentUsername && host === currentUsername;
startGameButton.classList.toggle("hidden", !isHost);
startGameButton.disabled = !isHost || !canStart;
startGameButton.textContent = canStart ? "Start Game" : "Waiting for 2 players";
};
const showLobby = (lobbyId, players = [], host = null, canStart = false) => {
currentLobbyId = lobbyId;
lobbyIdDisplay.textContent = lobbyId;
lobbyRoom.classList.remove("hidden");
joinForm.classList.add("hidden");
hostForm.classList.add("hidden");
renderPlayers(players);
updateStartButton(host, canStart);
};
const joinLobby = (lobbyId) => {
if (!currentUsername) {
showJoinError("User not authenticated.");
return Promise.reject(new Error("User not authenticated"));
}
return sendSocketMessage({type: "lobby:join", lobbyId});
};
const renderPublicLobbies = (lobbies = []) => {
publicLobbies.innerHTML = "";
if (!lobbies.length) {
publicLobbies.innerHTML = "<p>No public lobbies found</p>";
return;
}
lobbies.forEach((lobby) => {
const row = document.createElement("div");
row.className = "public-lobby";
const info = document.createElement("div");
info.className = "public-lobby-info";
const title = document.createElement("strong");
title.textContent = lobby.host ? `${lobby.host}'s lobby` : "Public lobby";
info.appendChild(title);
const meta = document.createElement("span");
meta.textContent = `${lobby.players} player${lobby.players === 1 ? "" : "s"} - ${lobby.gameStarted ? "Game running" : "Waiting"}`;
info.appendChild(meta);
const join = document.createElement("button");
join.type = "button";
join.textContent = "Join";
join.addEventListener("click", () => {
clearJoinError();
joinLobby(lobby.lobbyId).catch(() => showJoinError("Could not join lobby."));
});
row.appendChild(info);
row.appendChild(join);
publicLobbies.appendChild(row);
});
};
const loadPublicLobbies = async () => {
try {
publicLobbies.innerHTML = "<p>Loading lobbies...</p>";
const res = await fetch("/lobby/public", {credentials: "same-origin"});
if (!res.ok) {
throw new Error("Failed to load lobbies");
}
const data = await res.json();
renderPublicLobbies(data.lobbies || []);
} catch (error) {
publicLobbies.innerHTML = "<p>Could not load lobbies</p>";
}
};
document.addEventListener('DOMContentLoaded', async () => {
const byId = id => document.getElementById(id);
const client = new RealtimeClient();
let username; let lobbyId;
const error = message => {byId('join-error').textContent = message; byId('join-error').classList.toggle('hidden', !message);};
const send = async message => {try {error(''); await client.send(message);} catch (problem) {error(problem.message);}};
const hideLobby = () => {
currentLobbyId = null;
lobbyRoom.classList.add("hidden");
lobbyIdDisplay.textContent = "N/A";
playerList.innerHTML = "<p>Waiting for players...</p>";
startGameButton.classList.add("hidden");
startGameButton.disabled = true;
lobbyId = null; localStorage.removeItem('activeLobbyId'); byId('lobby-room').classList.add('hidden');
byId('host').disabled = false; byId('join').disabled = false;
};
const sendSocketMessage = async (message) => {
const ws = await connectSocket();
ws.send(JSON.stringify(message));
};
const connectSocket = () => {
if (socket && socket.readyState === WebSocket.OPEN) {
return Promise.resolve(socket);
}
if (socketReady) {
return socketReady;
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
socket = new WebSocket(`${protocol}//${window.location.host}`);
socketReady = new Promise((resolve, reject) => {
socket.addEventListener("error", () => reject(new Error("WebSocket connection failed")), {once: true});
socket.addEventListener("close", (event) => {
reject(new Error(`WebSocket closed before authentication: ${event.reason || event.code}`));
}, {once: true});
socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (message.type === "auth:success") {
currentUsername = message.username;
resolve(socket);
}
if (message.type === "auth:error") {
reject(new Error(message.message || "WebSocket authentication failed"));
}
}, {once: true});
});
socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (message.type === "auth:error") {
showJoinError(message.message);
return;
}
if (message.type === "auth:success") {
currentUsername = message.username;
return;
}
if (message.type === "lobby:state") {
clearJoinError();
if (message.gameStarted) {
localStorage.setItem("activeLobbyId", message.lobbyId);
window.location.href = message.gameUrl || `/poker.html?lobbyId=${encodeURIComponent(message.lobbyId)}`;
return;
}
showLobby(message.lobbyId, message.players || [], message.host, Boolean(message.canStart));
return;
}
if (message.type === "poker:started") {
localStorage.setItem("activeLobbyId", message.lobbyId);
window.location.href = message.url || `/poker.html?lobbyId=${encodeURIComponent(message.lobbyId)}`;
return;
}
if (message.type === "lobby:left") {
hideLobby();
return;
}
if (message.type === "lobby:error" || message.type === "error" || message.type === "poker:error") {
showJoinError(message.message || "WebSocket request failed");
}
});
socket.addEventListener("close", () => {
socket = null;
socketReady = null;
});
return socketReady;
};
const getUsernameFromToken = async () => {
async function refreshBalance() {
try {
const res = await fetch("/verify", {
method: "POST",
credentials: "same-origin"
});
if (!res.ok) {
console.error("Token verification failed", res.status, await res.text());
return null;
}
const data = await res.json();
return data.user?.username;
} catch (err) {
console.error("Failed to get username from token", err);
return null;
}
};
currentUsername = await getUsernameFromToken();
if (!currentUsername) {
showJoinError("User not authenticated.");
const [balance, ranks] = await Promise.all([apiRequest('/balance'), apiRequest('/leaderboard')]);
byId('balance').textContent = balance.balance;
byId('leaderboard-list').replaceChildren(...ranks.leaderboard.map((player, index) => {
const item = document.createElement('li'); item.textContent = `${index + 1}. ${player.username}: ${player.money}`;
item.classList.toggle('you', player.username === username); return item;
}));
byId('your-rank').textContent = `Your rank: #${ranks.self.position}`;
} catch (problem) {error(problem.message);}
}
connectSocket().catch((error) => {
console.error("Home WebSocket connection failed:", error);
showJoinError("Real-time poker connection failed.");
client.addEventListener('status', event => {error(event.detail); byId('start-game').disabled = !client.ready || byId('start-game').dataset.allowed !== 'true';});
client.addEventListener('message', event => {
const message = event.detail;
if (message.type === 'auth:success') username = message.username;
if (message.type === 'lobby:left') {hideLobby(); refreshBalance();}
if (message.type !== 'lobby:state') return;
lobbyId = message.lobbyId;
if (message.gameStarted) {
localStorage.setItem('activeLobbyId', lobbyId);
location.assign(`/poker.html?lobbyId=${encodeURIComponent(lobbyId)}`); return;
}
byId('lobby-id').textContent = lobbyId; byId('lobby-room').classList.remove('hidden');
byId('host-form').classList.add('hidden'); byId('join-form').classList.add('hidden');
byId('host').disabled = true; byId('join').disabled = true;
byId('player-list').replaceChildren(...message.players.map(player => {const item = document.createElement('p'); item.textContent = player; return item;}));
const allowed = message.host === username && message.canStart;
byId('start-game').dataset.allowed = String(allowed);
byId('start-game').disabled = !allowed; byId('start-game').classList.toggle('hidden', message.host !== username);
});
try {
const balanceRes = await fetch("/balance", {
method: "GET",
credentials: "same-origin"
});
if (balanceRes.ok) {
const balanceData = await balanceRes.json();
balance.textContent = balanceData.balance;
}
} catch (error) {
console.log("Balance error:", error);
}
logoutButton.addEventListener("click", async (e) => {
e.preventDefault();
byId('host').addEventListener('click', () => {byId('host-form').classList.toggle('hidden'); byId('join-form').classList.add('hidden');});
byId('create-lobby').addEventListener('click', () => send({type: 'lobby:create', private: document.querySelector('[name="lobby-visibility"]:checked').value === 'private'}));
async function loadLobbies() {
try {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.close();
const {lobbies} = await apiRequest('/lobby/public');
byId('public-lobbies').replaceChildren();
if (!lobbies.length) {byId('public-lobbies').textContent = 'No public lobbies found'; return;}
for (const lobby of lobbies) {
const row = document.createElement('div'); row.className = 'public-lobby';
const info = document.createElement('span'); info.textContent = `${lobby.host}: ${lobby.players}/10 players`;
const button = document.createElement('button'); button.textContent = 'Join'; button.type = 'button';
button.addEventListener('click', () => send({type: 'lobby:join', lobbyId: lobby.lobbyId}));
row.append(info, button); byId('public-lobbies').append(row);
}
const logoutRes = await fetch("/logout", {
method: "DELETE",
credentials: "same-origin"
});
if (logoutRes.ok) {
document.cookie = "authorization=; Max-Age=0; path=/;";
window.location.href = "/login.html";
}
} catch (err) {
console.log("Logout error:", err);
}
});
hostButton.addEventListener("click", async (e) => {
e.preventDefault();
clearJoinError();
hostForm.classList.toggle("hidden");
joinForm.classList.add("hidden");
});
createLobbyButton.addEventListener("click", async (e) => {
e.preventDefault();
clearJoinError();
if (!currentUsername) {
showJoinError("User not authenticated.");
return;
}
const selectedVisibility = [...lobbyVisibilityInputs].find(input => input.checked)?.value || "public";
sendSocketMessage({type: "lobby:create", private: selectedVisibility === "private"}).catch(() => {
showJoinError("Could not create lobby.");
});
});
joinButton.addEventListener("click", (e) => {
e.preventDefault();
joinForm.classList.toggle("hidden");
hostForm.classList.add("hidden");
if (!joinForm.classList.contains("hidden")) {
joinCodeInput.focus();
loadPublicLobbies();
}
});
refreshLobbies.addEventListener("click", (e) => {
e.preventDefault();
loadPublicLobbies();
});
joinSubmit.addEventListener("click", async (e) => {
e.preventDefault();
clearJoinError();
const lobbyId = joinCodeInput.value.trim();
if (!lobbyId) {
showJoinError("Please enter a lobby ID.");
return;
}
if (!currentUsername) {
showJoinError("User not authenticated.");
return;
}
joinLobby(lobbyId).then(() => {
joinForm.classList.add("hidden");
joinCodeInput.value = "";
}).catch(() => {
showJoinError("Could not join lobby. Please try again.");
});
});
leaveButton.addEventListener("click", (e) => {
e.preventDefault();
const lobbyId = lobbyIdDisplay.textContent;
if (!lobbyId || lobbyId === "N/A") return;
sendSocketMessage({type: "lobby:leave", lobbyId}).catch(() => {
showJoinError("Could not leave lobby.");
});
});
startGameButton.addEventListener("click", (e) => {
e.preventDefault();
if (!currentLobbyId || startGameButton.disabled) return;
sendSocketMessage({type: "poker:start", lobbyId: currentLobbyId}).catch(() => {
showJoinError("Could not start game.");
});
});
async function loadLeaderboard() {
try {
const res = await fetch("/leaderboard", {credentials: "same-origin"});
if (res.ok) {
const data = await res.json();
const list = document.getElementById("leaderboard-list");
const yourRank = document.getElementById("your-rank");
list.innerHTML = "";
data.leaderboard.forEach((player, i) => {
const item = document.createElement("li");
item.textContent = `${i + 1}. ${player.username} - $${player.money}`;
if (player.username === data.self.username) {
item.classList.add("you");
item.textContent += " (you)";
}
list.appendChild(item);
});
yourRank.textContent = `You are ranked #${data.self.position} with $${data.self.money}`;
} else {
console.error("Failed to load leaderboard.");
}
} catch (err) {
console.error("Leaderboard error:", err);
}
} catch (problem) {error(problem.message);}
}
await loadLeaderboard();
byId('join').addEventListener('click', () => {byId('join-form').classList.toggle('hidden'); byId('host-form').classList.add('hidden'); byId('join-code').focus(); loadLobbies();});
byId('refresh-lobbies').addEventListener('click', loadLobbies);
byId('submit-join').addEventListener('click', () => send({type: 'lobby:join', lobbyId: byId('join-code').value.trim().toLowerCase()}));
byId('leave-button').addEventListener('click', () => send({type: 'lobby:leave', lobbyId}));
byId('start-game').addEventListener('click', () => send({type: 'poker:start', lobbyId}));
byId('logout').addEventListener('click', async () => {
try {await apiRequest('/logout', {method: 'DELETE'}); client.close(); localStorage.removeItem('activeLobbyId'); location.assign('/login');}
catch (problem) {error(`Logout failed: ${problem.message}`);}
});
try {username = (await apiRequest('/verify', {method: 'POST'})).user.username; await client.connect(); await refreshBalance();}
catch (problem) {error(problem.message);}
});
+17 -106
View File
@@ -1,109 +1,20 @@
document.addEventListener("DOMContentLoaded", () => {
const form = document.querySelector("form");
const registerButton = document.getElementById("register-button");
const loginButton = document.getElementById("login-button");
const authError = document.getElementById("auth-error");
form.addEventListener("submit", async (event) => {
event.preventDefault();
await authenticate("/login", "Login successful. Loading Lobby...");
});
registerButton.addEventListener("click", async () => {
await authenticate("/register", "Register successful. Loading Lobby...");
});
async function authenticate(endpoint, successMessage) {
clearAuthError();
setButtonsDisabled(true);
const credentials = {
username: form.username.value,
password: form.password.value
};
document.addEventListener('DOMContentLoaded', () => {
const form = document.querySelector('form');
const error = document.getElementById('auth-error');
let pending = false;
async function authenticate(endpoint) {
if (pending || !form.reportValidity()) return;
pending = true; error.textContent = ''; error.classList.add('hidden');
for (const button of form.querySelectorAll('button')) button.disabled = true;
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {"Content-Type": "application/json"},
credentials: "same-origin",
body: JSON.stringify(credentials)
});
const data = await readJsonResponse(response);
if (!response.ok) {
showAuthError(data.error || "Authentication failed.");
return;
}
const verified = await verifySession();
if (!verified) {
showAuthError("Login succeeded, but the browser did not send the session cookie back to the server.");
return;
}
showLoadingScreen(successMessage);
} catch (error) {
console.error("Authentication request failed:", error);
showAuthError("Server unreachable.");
} finally {
setButtonsDisabled(false);
}
}
async function verifySession() {
const response = await fetch("/verify", {
method: "POST",
credentials: "same-origin"
});
if (!response.ok) return false;
const data = await readJsonResponse(response);
return Boolean(data.valid && data.user?.username);
}
async function readJsonResponse(response) {
try {
return await response.json();
} catch {
return {};
}
}
function showAuthError(message) {
authError.textContent = message;
authError.classList.remove("hidden");
}
function clearAuthError() {
authError.textContent = "";
authError.classList.add("hidden");
}
function setButtonsDisabled(disabled) {
loginButton.disabled = disabled;
registerButton.disabled = disabled;
}
function showLoadingScreen(message) {
const loadingScreen = document.getElementById("loading-screen");
const loadingText = document.getElementById("loading-text");
const progress = document.getElementById("progress");
loadingText.textContent = message;
loadingScreen.classList.remove("hidden");
let percent = 0;
const interval = setInterval(() => {
percent += Math.floor(Math.random() * 10) + 5;
if (percent >= 100) {
percent = 100;
progress.style.width = percent + "%";
clearInterval(interval);
setTimeout(() => {
window.location.href = "/";
}, 500);
} else {
progress.style.width = percent + "%";
}
}, 200);
const response = await fetch(endpoint, {method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({username: form.username.value, password: form.password.value})});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Authentication failed');
location.assign('/');
} catch (problem) {error.textContent = problem.message; error.classList.remove('hidden');}
finally {pending = false; for (const button of form.querySelectorAll('button')) button.disabled = false;}
}
form.addEventListener('submit', event => {event.preventDefault(); authenticate('/login');});
document.getElementById('register-button').addEventListener('click', () => authenticate('/register'));
});
+53 -48
View File
@@ -26,12 +26,14 @@ document.addEventListener("DOMContentLoaded", () => {
const actionButtons = document.querySelectorAll(".actions button");
const params = new URLSearchParams(window.location.search);
const lobbyId = params.get("lobbyId") || localStorage.getItem("activeLobbyId");
let socket = null;
let socketReady = null;
const client = new RealtimeClient();
let minimumRaise = 10;
let raisesAllowed = true;
let actionPending = false;
let previousFocus = null;
let isYourTurn = false;
let canLeaveTable = true;
let isSpectator = false;
let currentStackValue = 400;
let currentStackValue = 0;
let minimumStake = 10;
let callAmount = 0;
let countdownTimer = null;
@@ -121,22 +123,30 @@ document.addEventListener("DOMContentLoaded", () => {
};
const sendSocketMessage = async (message) => {
const ws = await connectSocket();
ws.send(JSON.stringify(message));
message = {...message, lobbyId};
if (message.payload?.amount !== undefined) message.payload = {amount: message.payload.amount};
else delete message.payload;
actionPending = true; updateActionButtons();
try { await client.send(message); }
finally { actionPending = false; updateActionButtons(); }
};
const closeModal = () => {
betModal.classList.add("hidden");
previousFocus?.focus();
};
const updateActionButtons = () => {
actionButtons.forEach((button) => {
button.disabled = !isYourTurn || isSpectator;
button.disabled = !client.ready || actionPending || !isYourTurn || isSpectator;
if (button.dataset.action === "hit") {
button.textContent = callAmount > 0 ? "Call" : "Check";
button.title = callAmount > 0
? `Call with $${Math.min(callAmount, currentStackValue)}.`
: actionTooltips.hit;
}
});
confirmBet.disabled = !isYourTurn || isSpectator;
leaveTable.disabled = false;
leaveTable.title = "Leave table. Chips already in the pot are lost.";
confirmBet.disabled = !client.ready || actionPending || !isYourTurn || isSpectator;
leaveTable.disabled = !client.ready || actionPending;
leaveTable.title = "Leave and fold. Your committed chips stay in this hand until settlement.";
};
const stopCountdown = () => {
@@ -177,34 +187,24 @@ document.addEventListener("DOMContentLoaded", () => {
countdownTimer = setInterval(updateCountdown, 1000);
};
const connectSocket = () => {
if (socket && socket.readyState === WebSocket.OPEN) {
return Promise.resolve(socket);
}
if (socketReady) {
return socketReady;
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
socket = new WebSocket(`${protocol}//${window.location.host}`);
socketReady = new Promise((resolve, reject) => {
socket.addEventListener("open", () => resolve(socket), {once: true});
socket.addEventListener("error", () => reject(new Error("WebSocket connection failed")), {once: true});
});
socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
client.addEventListener('status', event => {
if (event.detail) showError(event.detail);
if (!client.ready) {isYourTurn = false; stopCountdown(); closeModal();}
updateActionButtons();
});
client.addEventListener('message', event => {
const message = event.detail;
if (message.type === "poker:state") {
hideError();
isYourTurn = Boolean(message.isYourTurn);
canLeaveTable = Boolean(message.canLeaveTable);
isSpectator = Boolean(message.isSpectator);
currentStackValue = Number(message.yourStack ?? 0);
minimumStake = Number(message.minimumStake || 10);
callAmount = Number(message.callAmount || 0);
minimumRaise = message.minimumRaise || minimumStake;
raisesAllowed = message.canRaise !== false;
if (!isYourTurn || isSpectator) closeModal();
else if (!betModal.classList.contains("hidden")) updateBetRange();
roundDisplay.textContent = message.round;
potDisplay.textContent = message.pot;
currentBet.textContent = message.currentBet ?? message.bet;
@@ -249,15 +249,7 @@ document.addEventListener("DOMContentLoaded", () => {
if (message.type === "auth:error" || message.type === "poker:error" || message.type === "error") {
showError(message.message || "Poker connection failed");
}
});
socket.addEventListener("close", () => {
socket = null;
socketReady = null;
});
return socketReady;
};
});
const setBetValue = (value) => {
const nextValue = Math.max(Number(betRange.min), Math.min(Number(betRange.max), Number(value)));
@@ -266,12 +258,14 @@ document.addEventListener("DOMContentLoaded", () => {
};
const updateBetRange = () => {
const maxBet = Math.max(0, currentStackValue);
const requiredBet = Math.max(minimumStake, callAmount);
const maxBet = Math.max(0, raisesAllowed ? currentStackValue : Math.min(currentStackValue, callAmount));
const requiredBet = raisesAllowed ? callAmount + minimumRaise : callAmount;
const minBet = maxBet > 0 ? Math.min(requiredBet, maxBet) : 0;
betRange.min = String(minBet);
betRange.max = String(maxBet);
betRange.step = String(minimumStake);
betRange.step = "1";
document.getElementById("range-min").textContent = `$${minBet}`;
document.getElementById("range-max").textContent = `$${maxBet}`;
setBetValue(Math.max(minBet, Number(betRange.value) || minBet));
};
@@ -284,8 +278,8 @@ document.addEventListener("DOMContentLoaded", () => {
updateActionButtons();
connectSocket()
.then(() => sendSocketMessage({type: "poker:join", lobbyId}))
client.connect()
.then(() => client.send({type: "poker:join", lobbyId}))
.catch(() => showError("Could not connect to poker table."));
actionButtons.forEach((button) => {
@@ -301,7 +295,9 @@ document.addEventListener("DOMContentLoaded", () => {
}
if (action === "bet") {
updateBetRange();
previousFocus = document.activeElement;
betModal.classList.remove("hidden");
betRange.focus();
return;
}
@@ -317,7 +313,16 @@ document.addEventListener("DOMContentLoaded", () => {
});
});
closeBet.addEventListener("click", () => betModal.classList.add("hidden"));
closeBet.addEventListener("click", closeModal);
betModal.addEventListener("keydown", event => {
if (event.key === "Escape") {event.preventDefault(); closeModal();}
if (event.key === "Tab") {
const items = [...betModal.querySelectorAll("button:not(:disabled), input:not(:disabled)")];
const first = items[0], last = items[items.length - 1];
if (event.shiftKey && document.activeElement === first) {event.preventDefault(); last.focus();}
else if (!event.shiftKey && document.activeElement === last) {event.preventDefault(); first.focus();}
}
});
betRange.addEventListener("input", () => setBetValue(betRange.value));
decreaseBet.addEventListener("click", () => setBetValue(Number(betRange.value) - 10));
increaseBet.addEventListener("click", () => setBetValue(Number(betRange.value) + 10));
@@ -337,6 +342,6 @@ document.addEventListener("DOMContentLoaded", () => {
action: "bet",
payload: {amount: Number(betRange.value), lobbyId}
}).catch(() => showError("Could not place bet."));
betModal.classList.add("hidden");
closeModal();
});
});
+6 -1
View File
@@ -2,7 +2,9 @@
<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="shared.css">
<link rel="stylesheet" href="styles_home.css">
<link rel="icon" href="gambling.png" type="image/x-icon">
</head>
@@ -40,6 +42,7 @@
</div>
<div id="join-form" class="join-browser hidden">
<div class="join-code-row">
<label class="sr-only" for="join-code">Lobby code</label>
<input type="text" id="join-code" placeholder="Enter Lobby ID"/>
<button id="submit-join">Join</button>
</div>
@@ -51,7 +54,7 @@
<p>No public lobbies found</p>
</div>
</div>
<div id="join-error" class="error-message hidden"></div>
<div id="join-error" role="alert" aria-live="polite" class="error-message hidden"></div>
<div id="lobby-room" class="lobby-room hidden">
<div class="lobby-header">
<button id="leave-button" class="leave-button">Leave Lobby</button>
@@ -63,6 +66,8 @@
<button id="start-game" class="start-game hidden" disabled>Start Game</button>
</div>
</body>
<script src="protocol.js"></script>
<script src="client.js"></script>
<script src="frontend_home.js" defer></script>
<script src="matrix.js"></script>
</html>
+8 -9
View File
@@ -2,27 +2,26 @@
<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="shared.css">
<link rel="stylesheet" href="styles_login.css">
<link rel="icon" href="gambling.png" type="image/x-icon">
</head>
<body>
<h1>Login / Register</h1>
<form method="post" action="/login">
<input type="text" name="username" placeholder="Username">
<input type="password" name="password" placeholder="Password">
<label for="username">Username</label>
<input id="username" type="text" name="username" placeholder="Username" autocomplete="username" maxlength="40" required>
<label for="password">Password</label>
<input id="password" type="password" name="password" placeholder="Password" autocomplete="current-password" required aria-describedby="password-hint">
<small id="password-hint">Passwords support up to 72 UTF-8 bytes.</small>
<div class="auth-actions">
<button type="submit" id="login-button">Login</button>
<button type="button" id="register-button">Register</button>
</div>
<p id="auth-error" class="auth-error hidden"></p>
<p id="auth-error" role="alert" aria-live="polite" class="auth-error hidden"></p>
</form>
<div id="loading-screen" class="hidden">
<div class="loading-text" id="loading-text">Loading...</div>
<div class="progress-bar">
<div class="progress" id="progress"></div>
</div>
</div>
</body>
<script src="frontend_login.js"></script>
</html>
+12 -2
View File
@@ -5,6 +5,8 @@ const fontSize = 16;
const frameInterval = 65;
let drops = [];
let lastFrameTime = 0;
let animationFrame = null;
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
function resizeCanvas() {
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
@@ -41,9 +43,17 @@ function animate(timestamp) {
drawMatrix();
lastFrameTime = timestamp;
}
requestAnimationFrame(animate);
animationFrame = requestAnimationFrame(animate);
}
function updateAnimation() {
cancelAnimationFrame(animationFrame);
animationFrame = null;
if (!document.hidden && !reducedMotion.matches) animationFrame = requestAnimationFrame(animate);
else ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
}
resizeCanvas();
requestAnimationFrame(animate);
updateAnimation();
document.addEventListener("visibilitychange", updateAnimation);
reducedMotion.addEventListener("change", updateAnimation);
window.addEventListener("resize", resizeCanvas);
+10 -6
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Poker Table</title>
<link rel="stylesheet" href="shared.css">
<link rel="stylesheet" href="styles_poker.css">
<link rel="icon" href="gambling.png" type="image/x-icon">
</head>
@@ -36,7 +37,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,25 +50,26 @@
<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>
<section id="showdown-panel" class="showdown-panel hidden" aria-label="Showdown"></section>
<div id="error-message" class="error-message hidden"></div>
<div id="error-message" role="alert" aria-live="polite" class="error-message hidden"></div>
</main>
<div id="bet-modal" class="bet-modal hidden" role="dialog" aria-modal="true" aria-labelledby="bet-title">
<div class="bet-panel">
<button id="close-bet" class="close-bet" aria-label="Close bet dialog">x</button>
<h2 id="bet-title">Choose amount to bet</h2>
<label class="sr-only" for="bet-range">Chips to add to the pot</label>
<input id="bet-range" type="range" min="0" max="400" step="10" value="50">
<div class="range-labels">
<span>$0</span>
<span>$400</span>
<span id="range-min">$0</span>
<span id="range-max">$0</span>
</div>
<div class="bet-amount">
<button id="decrease-bet">-</button>
@@ -78,6 +80,8 @@
</div>
</div>
<script src="protocol.js"></script>
<script src="client.js"></script>
<script src="frontend_poker.js" defer></script>
<script src="matrix.js"></script>
</body>
+13 -20
View File
@@ -3,7 +3,7 @@
color: #0f0;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
height: 100vh;
min-height: 100vh;
position: relative;
display: flex;
flex-direction: column;
@@ -89,17 +89,6 @@ button.logout {
vertical-align: middle;
}
.lobby-room {
background: rgba(0, 0, 0, 0.7);
border: 2px solid #0f0;
border-radius: 12px;
padding: 30px;
margin-top: 40px;
width: 400px;
text-align: center;
box-shadow: 0 0 10px #0f0;
}
.lobby-room h2 {
color: #0f0;
font-size: 20px;
@@ -111,10 +100,6 @@ button.logout {
color: #fff;
}
.hidden {
display: none;
}
.join-browser {
margin-top: 20px;
width: min(520px, calc(100vw - 32px));
@@ -231,10 +216,6 @@ button.logout {
text-shadow: 0 0 3px red;
}
.hidden {
display: none;
}
#matrix-canvas {
position: fixed;
top: 0;
@@ -341,3 +322,15 @@ button.logout {
box-shadow: 0 0 5px #0f0 inset;
transform: none;
}
.lobby-room { width: min(460px, calc(100vw - 32px)); overflow-wrap: anywhere; }
body { padding: 72px 16px 24px; gap: 8px; }
@media (max-width: 1000px) {
body { justify-content: flex-start; }
.leaderboard { position: static; width: min(460px, 100%); order: 10; margin-top: 24px; }
body::before { text-align: center; font-size: 20px; margin-bottom: 12px; }
.join-code-row { flex-wrap: wrap; }
#join-form input { width: 100%; min-width: 0; }
.lobby-visibility { flex-wrap: wrap; justify-content: center; }
.lobby-header { position: static; text-align: right; }
}
+3 -40
View File
@@ -6,7 +6,7 @@
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
min-height: 100vh;
margin: 0;
}
@@ -82,43 +82,6 @@ button:disabled {
text-align: center;
}
.hidden {
display: none !important;
}
#loading-screen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: #000;
color: #0f0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 9999;
}
.loading-text {
font-size: 24px;
margin-bottom: 20px;
text-shadow: 0 0 10px #0f0;
}
.progress-bar {
width: 80%;
height: 20px;
border: 2px solid #0f0;
border-radius: 10px;
background: #111;
}
.progress {
height: 100%;
width: 0;
background-color: #0f0;
border-radius: 10px;
transition: width 0.3s ease;
}
form { width: min(364px, calc(100vw - 32px)); }
body { padding: 24px 0; }
+16 -5
View File
@@ -4,7 +4,7 @@ body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
min-height: 100vh;
overflow: hidden;
overflow-x: clip;
}
button {
@@ -348,10 +348,6 @@ button:disabled:hover {
padding: 8px 22px;
}
.hidden {
display: none;
}
@media (max-width: 700px) {
.poker-table {
padding: 14px;
@@ -386,3 +382,18 @@ button:disabled:hover {
justify-content: center;
}
}
.poker-table { gap: 24px; }
.turn-panel { min-width: 0; width: min(460px, 100%); }
.community-area, .player-area { max-width: 100%; min-width: 0; }
.card-row { flex-wrap: wrap; justify-content: center; }
.bet-modal { z-index: 10; padding: 24px; }
.bet-panel { max-width: 100%; }
.player-status, .round-panel { overflow-wrap: anywhere; }
@media (max-width: 700px) {
.card-row { gap: 6px; }
.card-row .card { width: 48px; height: 74px; }
.showdown-panel { position: static; width: 100%; max-height: none; }
.round-panel { padding: 12px 8px; font-size: 14px; }
.divider { margin: 0 4px; }
}
+1
View File
@@ -2,6 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Access Denied</title>
<style>
body {
+56 -24
View File
@@ -1,25 +1,57 @@
const swaggerAutogen = require('swagger-autogen')({openapi: '3.0.0'});
const doc = {
info: {
title: 'GAMBLE!',
description: 'Gambling API'
},
host: 'localhost:25566',
components: {
securitySchemes:{
bearerAuth: {
type: 'http',
scheme: 'bearer'
}
}
}
const fs = require('node:fs');
const {schemas} = require('./public/protocol');
const object = (properties, required = Object.keys(properties)) => ({type: 'object', properties, required, additionalProperties: false});
const string = {type: 'string'};
const integer = {type: 'integer', minimum: 0, maximum: Number.MAX_SAFE_INTEGER};
const nullableString = {type: ['string', 'null']};
const numberMap = {type: 'object', additionalProperties: integer};
const array = items => ({type: 'array', items});
const user = object({username: string, money: integer});
const error = object({code: string, error: string});
const security = [{bearerAuth: []}, {cookieAuth: []}];
const response = schema => ({description: 'Success', content: {'application/json': {schema}}});
const responses = schema => ({200: response(schema), ...Object.fromEntries([400,401,403,404,409,429,500].map(status => [status, {description: `Error ${status}`, content: {'application/json': {schema: error}}}]))});
const paths = {};
function route(path, method, summary, output, body, auth = true) {
const entry = {summary, responses: responses(output), ...(auth ? {security} : {}), ...(body ? {requestBody: {required: true, content: {'application/json': {schema: body}}}} : {})};
paths[path] ||= {}; paths[path][method] = entry; return entry;
}
const session = object({username: string, token: string});
route('/register','post','Create a 200-chip account and session',session,schemas.credentials,false);
route('/login','post','Replace the current session',session,schemas.credentials,false);
route('/verify','post','Verify a cookie or bearer session',object({valid:{const:true},user:object({username:string,exp:integer})}));
route('/logout','delete','Fold/leave the lobby, revoke the session, and clear the cookie',object({message:string}));
route('/balance','get','Current spendable chips (excludes escrow)',object({balance:integer}));
route('/leaderboard','get','Rank spendable balances; username breaks ties',object({leaderboard:array(user),self:object({username:string,money:integer,position:integer})}));
const ack = object({lobbyId:string,username:string,type:string,requestId:string});
for (const [path,type] of [['/lobby/create','lobby:create'],['/lobby/join','lobby:join'],['/lobby/leave','lobby:leave'],['/poker/start','poker:start'],['/poker/action','poker:action']]) {
const body = structuredClone(schemas[type]); delete body.properties.type; body.required = body.required.filter(key => key !== 'type');
route(path,'post',`${type}; retry the identical body/requestId after a lost response`,ack,body);
paths[path].get = {summary:'Deprecated mutation method',responses:{405:{description:'Use POST',content:{'application/json':{schema:error}}}}};
}
route('/lobby/public','get','List public lobbies',object({lobbies:array(object({lobbyId:string,host:nullableString,players:integer,gameStarted:{type:'boolean'}}))}));
route('/lobby/players','get','List members of your lobby',object({players:array(string)})).parameters=[{in:'query',name:'lobbyId',required:true,schema:schemas['lobby:join'].properties.lobbyId}];
const doc = {openapi:'3.1.0',info:{title:'Gambling API',version:'2.0.0',description:'Virtual chips. Account balances exclude active-hand escrow. See docs/PROTOCOL.md and docs/RULES.md.'},servers:[{url:'/'}],paths,
components:{securitySchemes:{bearerAuth:{type:'http',scheme:'bearer',bearerFormat:'JWT'},cookieAuth:{type:'apiKey',in:'cookie',name:'authorization'}}}};
const card = object({rank:{enum:['A','K','Q','J','10','9','8','7','6','5','4','3','2']},suit:{enum:['S','H','D','C']}});
const result = object({rank:integer,name:string,score:array(integer),comboCards:array(card)});
const outputSchemas = {
'auth:success':object({type:{const:'auth:success'},username:string}),
'command:ack':object({type:{const:'command:ack'},commandType:string,lobbyId:string,username:string,requestId:string}),
error:object({type:{const:'error'},code:string,message:string,requestId:string},['type','code','message']),
'lobby:left':object({type:{const:'lobby:left'},lobbyId:string}),
'lobby:state':object({type:{const:'lobby:state'},lobbyId:string,host:nullableString,players:array(string),isPublic:{type:'boolean'},canStart:{type:'boolean'},gameStarted:{type:'boolean'},gameUrl:string}),
'poker:state':object({type:{const:'poker:state'},lobbyId:string,username:string,players:array(string),round:integer,pot:integer,currentBet:integer,bet:integer,
communityCards:array(card),hand:array(card),currentPlayer:nullableString,isYourTurn:{type:'boolean'},queue:array(string),folded:{type:'object',additionalProperties:{type:'boolean'}},
street:{enum:['intermission','preflop','flop','turn','river']},phase:{enum:['intermission','waiting','betting']},lastAction:string,yourContribution:integer,yourStack:integer,
isSpectator:{type:'boolean'},callAmount:integer,minimumStake:integer,minimumRaise:integer,canRaise:{type:'boolean'},
nextRoundStartsAt:{type:['integer','null']},turnEndsAt:{type:['integer','null']},winner:nullableString,
showdown:{anyOf:[{type:'null'},array(object({player:string,hand:array(card),result}))]},payouts:numberMap})
};
const outputFile = './swagger.json';
const routes = ['./gambling.js'];
/* NOTE: If you are using the express Router, you must pass in the 'routes' only the
root file where the route starts, such as index.js, app.js, routes.js, etc ... */
swaggerAutogen(outputFile, routes, doc);
const websocket = {$schema:'https://json-schema.org/draft/2020-12/schema',title:'WebSocket protocol',oneOf:Object.entries(schemas).filter(([key])=>key!=='credentials').map(([,value])=>value),$defs:outputSchemas};
const artifacts = [['swagger.json',doc],['docs/websocket.schema.json',websocket]];
for (const [filename, data] of artifacts) {
const text = JSON.stringify(data,null,2)+'\n';
if (process.argv.includes('--check')) {if (fs.readFileSync(filename,'utf8') !== text) throw new Error(`${filename} is stale; run npm run docs`);}
else fs.writeFileSync(filename,text);
}
+2900 -282
View File
File diff suppressed because it is too large Load Diff
+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});
});
+89
View File
@@ -0,0 +1,89 @@
const {test} = require('node:test');
const assert = require('node:assert/strict');
const {once} = require('node:events');
const crypto = require('node:crypto');
const {WebSocket} = require('ws');
const {createApplication} = require('../gambling');
const {parseCookies} = require('../lib/transport');
const secret = 'test-secret-with-at-least-thirty-two-characters';
function nextMessage(socket, type) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {socket.off('message', listener); reject(new Error(`Timeout waiting for ${type}`));}, 3000);
const listener = raw => {const message = JSON.parse(raw); if (message.type !== type) return; clearTimeout(timeout); socket.off('message', listener); resolve(message);};
socket.on('message', listener);
});
}
async function fixture(t, options = {}) {
const api = await createApplication({secret, databasePath: ':memory:', bcryptRounds: 4, intermissionMs: 60000, ...options});
await api.listen(0); t.after(() => api.close());
const base = `http://127.0.0.1:${api.server.address().port}`;
const request = async (route, {token, method = 'GET', body, headers = {}} = {}) => fetch(base + route, {method, redirect: 'manual',
headers: {...(token ? {Authorization: `Bearer ${token}`} : {}), ...(body !== undefined ? {'Content-Type': 'application/json'} : {}), ...headers},
body: body === undefined ? undefined : JSON.stringify(body)});
return {api, base, request};
}
test('HTTP contract, sessions, origin checks, and socket lifecycle', async t => {
const {api, base, request} = await fixture(t);
assert.equal((await request('/balance')).status, 401);
assert.equal((await request('/poker.html')).status, 302);
assert.equal((await request('/register', {method: 'POST'})).status, 400);
assert.equal((await request('/lobby/create')).status, 405);
const registered = await request('/register', {method: 'POST', body: {username: '__proto__', password: 'password'}});
const {token} = await registered.json();
assert.equal(registered.status, 200);
assert.match(registered.headers.get('set-cookie'), /HttpOnly/);
assert.notEqual((await api.repository.user('__proto__')).sessionHash, token);
assert.equal((await request('/lobby/create', {method: 'POST', token, headers: {Origin: 'https://evil.invalid'}, body: {requestId: crypto.randomUUID()}})).status, 403);
const bob = await api.sessions.register({username: 'bob', password: 'password'});
const socket = new WebSocket(base.replace('http', 'ws'), {headers: {Authorization: `Bearer ${token}`, Cookie: 'bad=%ZZ'}});
await nextMessage(socket, 'auth:success');
let pending = nextMessage(socket, 'command:ack');
socket.send(JSON.stringify({type: 'lobby:create', requestId: crypto.randomUUID()}));
const created = await pending;
assert.equal(created.commandType, 'lobby:create');
assert.equal((await request('/lobby/join', {method: 'POST', token: bob.token, body: {lobbyId: created.lobbyId, requestId: crypto.randomUUID()}})).status, 200);
const start = {type: 'poker:start', lobbyId: created.lobbyId, requestId: crypto.randomUUID()};
pending = nextMessage(socket, 'command:ack'); socket.send(JSON.stringify(start)); await pending;
pending = nextMessage(socket, 'error'); socket.send(JSON.stringify({...start, requestId: crypto.randomUUID()}));
assert.equal((await pending).code, 'GAME_STARTED');
pending = nextMessage(socket, 'error'); socket.send('null'); assert.equal((await pending).code, 'INVALID_MESSAGE');
const closed = once(socket, 'close');
await request('/logout', {method: 'DELETE', token});
assert.equal((await closed)[0], 1008);
assert.equal((await request('/balance', {token})).status, 401);
});
test('login rotation immediately closes old sockets; proxy headers are untrusted by default', async t => {
const {api, base, request} = await fixture(t);
const user = await api.sessions.register({username: 'alice', password: 'password'});
const socket = new WebSocket(base.replace('http', 'ws'), {headers: {Authorization: `Bearer ${user.token}`}});
await nextMessage(socket, 'auth:success');
const closed = once(socket, 'close');
const response = await request('/login', {method: 'POST', headers: {'X-Forwarded-Proto': 'https'}, body: {username: 'ALICE', password: 'password'}});
assert.equal(response.status, 200); assert.doesNotMatch(response.headers.get('set-cookie'), /; Secure/);
assert.equal((await closed)[0], 1008);
assert.equal(await api.sessions.verify(user.token), null);
});
test('authentication is rate limited and cookies tolerate invalid escapes', async t => {
const {request} = await fixture(t, {authLimit: 2});
for (let index = 0; index < 2; index++) assert.equal((await request('/login', {method: 'POST', body: {username: 'missing', password: 'password'}})).status, 401);
const limited = await request('/login', {method: 'POST', body: {username: 'missing', password: 'password'}});
assert.equal(limited.status, 429); assert.ok(limited.headers.has('retry-after'));
assert.equal(parseCookies('bad=%ZZ; authorization=ok').authorization, 'ok');
});
test('socket expires without waiting for a client message', async t => {
const {api, base} = await fixture(t, {sessionSeconds: 1});
const {token} = await api.sessions.register({username: 'alice', password: 'password'});
const socket = new WebSocket(base.replace('http', 'ws'), {headers: {Authorization: `Bearer ${token}`}});
const closed = once(socket, 'close');
await nextMessage(socket, 'auth:success');
assert.equal((await closed)[0], 1008);
});
test('WebSocket rejects cross-origin upgrades', async t => {
const {api, base} = await fixture(t);
const {token} = await api.sessions.register({username: 'alice', password: 'password'});
const socket = new WebSocket(base.replace('http', 'ws'), {headers: {Authorization: `Bearer ${token}`, Origin: 'https://evil.invalid'}});
socket.on('error', () => {});
const [, response] = await once(socket, 'unexpected-response');
assert.equal(response.statusCode, 403); response.resume(); socket.terminate();
});
module.exports = {fixture};