Merge pull request #2 from Fretuks/main
Refactor and improve code structure for better readability
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
|
||||
@@ -3,3 +3,9 @@ users.sqlite
|
||||
users.sqlite-shm
|
||||
users.sqlite-wal
|
||||
.env
|
||||
|
||||
.npm-cache/
|
||||
.playwright-browsers/
|
||||
playwright-report/
|
||||
test-results/
|
||||
*.backup
|
||||
|
||||
@@ -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.
|
||||
|
||||
+37
-1618
File diff suppressed because it is too large
Load Diff
+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};
|
||||
+13
-3
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+57
-370
@@ -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);
|
||||
async function refreshBalance() {
|
||||
try {
|
||||
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);}
|
||||
}
|
||||
|
||||
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();
|
||||
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", 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");
|
||||
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);
|
||||
});
|
||||
|
||||
socket.addEventListener("close", () => {
|
||||
socket = null;
|
||||
socketReady = null;
|
||||
});
|
||||
|
||||
return socketReady;
|
||||
};
|
||||
|
||||
const getUsernameFromToken = async () => {
|
||||
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 {
|
||||
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 {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 data = await res.json();
|
||||
return data.user?.username;
|
||||
} catch (err) {
|
||||
console.error("Failed to get username from token", err);
|
||||
return null;
|
||||
} catch (problem) {error(problem.message);}
|
||||
}
|
||||
};
|
||||
|
||||
currentUsername = await getUsernameFromToken();
|
||||
if (!currentUsername) {
|
||||
showJoinError("User not authenticated.");
|
||||
}
|
||||
|
||||
connectSocket().catch((error) => {
|
||||
console.error("Home WebSocket connection failed:", error);
|
||||
showJoinError("Real-time poker connection failed.");
|
||||
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 {
|
||||
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();
|
||||
try {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.close();
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
await loadLeaderboard();
|
||||
try {username = (await apiRequest('/verify', {method: 'POST'})).user.username; await client.connect(); await refreshBalance();}
|
||||
catch (problem) {error(problem.message);}
|
||||
});
|
||||
|
||||
+18
-107
@@ -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'));
|
||||
});
|
||||
|
||||
+51
-46
@@ -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});
|
||||
client.addEventListener('status', event => {
|
||||
if (event.detail) showError(event.detail);
|
||||
if (!client.ready) {isYourTurn = false; stopCountdown(); closeModal();}
|
||||
updateActionButtons();
|
||||
});
|
||||
|
||||
socket.addEventListener("message", (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
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;
|
||||
@@ -251,14 +251,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
});
|
||||
|
||||
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)));
|
||||
betRange.value = nextValue;
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+54
-22
@@ -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);
|
||||
}
|
||||
|
||||
+2868
-250
File diff suppressed because it is too large
Load Diff
@@ -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,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};
|
||||
Reference in New Issue
Block a user