diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..7e5b7d7 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/data.json b/data.json index 3eee4b7..fe51488 100644 --- a/data.json +++ b/data.json @@ -1,19 +1 @@ -[ - { - "username": "jerome", - "password": "Sigma", - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJsb3dlcl91c2VybmFtZSI6Implcm9tZSIsImlhdCI6MTc0NDg4OTY3MSwiZXhwIjoxNzQ0ODkxNDcxfQ.zYg3uxh_iNZ2xdEma8p3p-URT3Xa7wnYiQyOx0-QTac", - "money": 400 - }, - { - "username": "Gianimon", - "password": "gianimon", - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IkdpYW5pbW9uIiwiaWF0IjoxNzQ0ODc0NTczLCJleHAiOjE3NDQ4NzYzNzN9.dGp5WTKIDVhomKZ8J7nevz8O7PqwRhR9wEgeyDtL_XE", - "money": 300 - }, - { - "username": "any", - "password": "any", - "money": 300 - } -] \ No newline at end of file +[] diff --git a/gambling.js b/gambling.js index a4f25d0..e4eb994 100644 --- a/gambling.js +++ b/gambling.js @@ -1,14 +1,22 @@ const express = require("express"); const app = express(); +const http = require("http"); +const {WebSocket, WebSocketServer} = require("ws"); const swaggerUi = require("swagger-ui-express"); const swaggerDocument = require("./swagger.json"); const path = require("path"); app.use(express.static(path.join(__dirname, "public"))); -const fs = require("fs"); +const fs = require("fs/promises"); const dotenv = require("dotenv").config(); const jwt = require("jsonwebtoken"); const cookieParser = require("cookie-parser"); -const port = 25565; +const bcrypt = require("bcrypt"); +const sqlite3 = require("sqlite3"); +const {open} = require("sqlite"); +const port = process.env.PORT || 25566; +const server = http.createServer(app); +const wss = new WebSocketServer({server}); +let db; app.use(express.json()); app.use("/swagger-ui", swaggerUi.serve, swaggerUi.setup(swaggerDocument)); @@ -18,69 +26,160 @@ app.use(cookieParser()); Here are Helper function ----------------------------------------------------------------------------------------------- */ -function get_data() { +async function initializeDatabase() { + db = await open({ + filename: path.join(__dirname, "users.sqlite"), + driver: sqlite3.Database + }); + + await db.exec(` + CREATE TABLE IF NOT EXISTS users ( + username TEXT PRIMARY KEY COLLATE NOCASE, + password_hash TEXT NOT NULL, + token TEXT, + money INTEGER NOT NULL DEFAULT 200, + lobby_id TEXT + ) + `); + + await migrateJsonUsers(); +} + +async function migrateJsonUsers() { const dataPath = path.join(__dirname, "data.json"); - if (!fs.existsSync(dataPath)) { - fs.writeFileSync(dataPath, JSON.stringify([])); + try { + const rawUsers = JSON.parse(await fs.readFile(dataPath, "utf8")); + if (!Array.isArray(rawUsers)) return; + + for (const user of rawUsers) { + if (!user.username || !user.password) continue; + const username = user.username.toLowerCase(); + const existingUser = await findUserByUsername(username); + if (existingUser) continue; + + const passwordHash = user.password.startsWith("$2") + ? user.password + : await bcrypt.hash(user.password, 12); + + await db.run( + "INSERT INTO users (username, password_hash, token, money, lobby_id) VALUES (?, ?, ?, ?, ?)", + username, + passwordHash, + user.token || null, + Number.isFinite(user.money) ? user.money : 200, + user.lobbyId || null + ); + } + + if (rawUsers.length > 0) { + await fs.writeFile(dataPath, "[]\n"); + } + } catch (error) { + if (error.code !== "ENOENT") { + console.error("Failed to migrate data.json users:", error); + } } - const data = fs.readFileSync(dataPath); - return JSON.parse(data); } -function save_data(data) { - fs.writeFileSync(path.join(__dirname, "data.json"), JSON.stringify(data, null, 2)); +async function getUsers() { + return db.all("SELECT username, token, money, lobby_id AS lobbyId FROM users"); } -function saveUser(user) { - const users = get_data(); - users.push(user); - save_data(users); +async function findUserByUsername(username) { + return db.get( + "SELECT username, password_hash AS passwordHash, token, money, lobby_id AS lobbyId FROM users WHERE username = ?", + username + ); +} + +async function saveUser({username, password, token, money = 200}) { + const passwordHash = await bcrypt.hash(password, 12); + await db.run( + "INSERT INTO users (username, password_hash, token, money) VALUES (?, ?, ?, ?)", + username, + passwordHash, + token, + money + ); +} + +async function setUserToken(username, token) { + await db.run("UPDATE users SET token = ? WHERE username = ?", token || null, username); +} + +async function setUserLobby(username, lobbyId) { + await db.run("UPDATE users SET lobby_id = ? WHERE username = ?", lobbyId || null, username); +} + +async function setUserMoney(username, money) { + await db.run("UPDATE users SET money = ? WHERE username = ?", Math.max(0, Math.floor(money)), username); } function generateAccessToken(user) { return jwt.sign(user, process.env.TOKEN_SECRET, {expiresIn: "1800s"}); } -function authenticateToken(request, response, next) { +function parseCookieHeader(cookieHeader = "") { + return cookieHeader.split(";").reduce((cookies, cookie) => { + const separatorIndex = cookie.indexOf("="); + if (separatorIndex === -1) return cookies; + const key = cookie.slice(0, separatorIndex).trim(); + const value = decodeURIComponent(cookie.slice(separatorIndex + 1).trim()); + cookies[key] = value; + return cookies; + }, {}); +} + +function getAuthTokenFromRequest(request) { + return request.headers.authorization?.split(" ")[1] || request.cookies.authorization; +} + +async function verifyAuthToken(token) { + if (!token) return null; + try { + const decoded = jwt.verify(token, process.env.TOKEN_SECRET); + const foundUser = await findUserByUsername(decoded.username); + if (!foundUser || foundUser.token !== token) return null; + return decoded; + } catch { + return null; + } +} + +async function authenticateToken(request, response, next) { /* #swagger.security = [{ "bearerAuth": [] -}] */ - const token = - request.headers.authorization?.split(" ")[1] || - request.cookies.authorization; + }] */ + const token = getAuthTokenFromRequest(request); if (token == null) return response.sendStatus(401); - jwt.verify(token, process.env.TOKEN_SECRET, (err, user) => { - if (err) return response.sendStatus(403); - const users = get_data(); - const foundUser = users.find(u => u.username === user.username); - if (!foundUser || foundUser.token !== token) { - return response.status(403).json({error: "Token has been revoked or is invalid"}); - } - request.user = user; - next(); - }); + const user = await verifyAuthToken(token); + if (!user) { + return response.status(403).json({error: "Token has been revoked or is invalid"}); + } + request.user = user; + next(); } /* Here is the the Login logic ----------------------------------------------------------------------------------------------- */ -app.post("/register", (request, response) => { +app.post("/register", async (request, response) => { let {username, password} = request.body; - let lower_username = username.toLowerCase(); if (!username || !password) return response.status(400).json({error: "Username and password are required"}); - const users = get_data(); - if (users.some(u => u.username === username)) { + username = username.toLowerCase(); + const existingUser = await findUserByUsername(username); + if (existingUser) { return response.status(400).json({error: "Username already exists"}); } const token = generateAccessToken({username}); - saveUser({username, password, token, "money": 200}); - response.json({lower_username, token}); + await saveUser({username, password, token, money: 200}); + response.json({username, token}); }); -app.get("/leaderboard", authenticateToken, (req, res) => { - const users = get_data(); +app.get("/leaderboard", authenticateToken, async (req, res) => { + const users = await getUsers(); const sorted = users.sort((a, b) => b.money - a.money); const top10 = sorted.slice(0, 10).map(u => ({ username: u.username, @@ -103,51 +202,43 @@ app.get("/login", (request, response) => { response.sendFile(path.join(__dirname, "./public/login.html")); }) -app.post("/login", (request, response) => { +app.post("/login", async (request, response) => { let {username, password} = request.body; - username = username.toLowerCase(); if (!username || !password) return response.status(400).json({error: "Username and password are required"}); - const users = get_data(); - const user = users.find(u => u.username === username); + username = username.toLowerCase(); + const user = await findUserByUsername(username); if (!user) return response.status(401).json({error: "Invalid credentials"}); - if (user.password !== password) { + const passwordMatches = await bcrypt.compare(password, user.passwordHash); + if (!passwordMatches) { return response.status(401).json({error: "Invalid password"}); } const token = generateAccessToken({username}); - user.token = token; - save_data(users); + await setUserToken(username, token); response.json({username, token}); }); -app.get("/", (req, res) => { +app.get("/", async (req, res) => { const token = req.headers.authorization?.split(" ")[1] || req.cookies.authorization; if (!token) { return res.sendFile(path.join(__dirname, "./public/unauthorized.html")); } - jwt.verify(token, process.env.TOKEN_SECRET, (err, user) => { - if (err) { - return res.sendFile(path.join(__dirname, "./public/unauthorized.html")); - } - const users = get_data(); - const foundUser = users.find(u => u.username === user.username); - if (!foundUser || foundUser.token !== token) { - return res.sendFile(path.join(__dirname, "./public/unauthorized.html")); - } - req.user = user; - res.sendFile(path.join(__dirname, "./public/home.html")); - }); + const user = await verifyAuthToken(token); + if (!user) { + return res.sendFile(path.join(__dirname, "./public/unauthorized.html")); + } + req.user = user; + res.sendFile(path.join(__dirname, "./public/home.html")); }); -app.get("/balance", authenticateToken, (request, response) => { +app.get("/balance", authenticateToken, async (request, response) => { /* #swagger.security = [{ "bearerAuth": [] }] */ const username = request.user.username; - const allUsers = get_data(); - const me = allUsers.find(u => u.username === username); + const me = await findUserByUsername(username); if (!me) { return response.status(404).json({error: "User not found"}); } @@ -160,14 +251,11 @@ app.post("/verify", authenticateToken, (request, response) => { app.delete("/logout", authenticateToken, async (request, response) => { const username = request.user.username; - const users = get_data(); - const user = users.find(u => u.username === username); + const user = await findUserByUsername(username); if (!user) return response.status(404).json({error: "User not found"}); const lobbyId = user.lobbyId; - await leaveLobby(lobbyId, username); - await deleteLobby(lobbyId); - delete user.token; - save_data(users); + await leavePokerLobby(lobbyId, username); + await setUserToken(username, null); response.clearCookie("authorization", { httpOnly: true, secure: process.env.NODE_ENV === "production", @@ -177,6 +265,842 @@ app.delete("/logout", authenticateToken, async (request, response) => { }); const playerMap = {}; +const lobbySockets = {}; +const lobbyHosts = {}; +const lobbyPrivacy = {}; +const pokerGames = {}; +const disconnectTimers = {}; +const ROUND_INTERMISSION_MS = 30000; +const TURN_TIMEOUT_MS = 30000; +const DISCONNECT_GRACE_MS = 10000; +const MINIMUM_STAKE = 10; +const STARTING_STACK = 200; + +const cardSuits = ["S", "H", "D", "C"]; +const cardRanks = ["A", "K", "Q", "J", "10", "9", "8", "7", "6", "5", "4", "3", "2"]; + +function createPlaceholderDeck() { + const deck = []; + for (const suit of cardSuits) { + for (const rank of cardRanks) { + deck.push({rank, suit}); + } + } + return deck.sort(() => Math.random() - 0.5); +} + +async function getInitialPokerStack(player, previousStacks = {}) { + if (Number.isFinite(previousStacks[player])) { + return previousStacks[player]; + } + const user = await findUserByUsername(player); + return Number.isFinite(user?.money) ? user.money : STARTING_STACK; +} + +async function persistPokerStacks(game) { + await Promise.all(game.players.map(player => setUserMoney(player, game.stacks[player] || 0))); +} + +async function createPokerGame(lobbyId) { + const players = playerMap[lobbyId] || []; + const existingGame = pokerGames[lobbyId]; + if (existingGame?.nextRoundTimer) { + clearTimeout(existingGame.nextRoundTimer); + } + const roundNumber = existingGame ? existingGame.round + 1 : 1; + const previousStacks = existingGame?.stacks || {}; + const dealerIndex = existingGame + ? (existingGame.dealerIndex + 1) % Math.max(players.length, 1) + : 0; + const stacks = {}; + const playersWithChips = []; + + for (const player of players) { + stacks[player] = await getInitialPokerStack(player, previousStacks); + if (stacks[player] >= MINIMUM_STAKE) { + playersWithChips.push(player); + } + } + const activePlayers = playersWithChips.length >= 2 ? playersWithChips : []; + + const deck = createPlaceholderDeck(); + const hands = {}; + const folded = {}; + const acted = {}; + const contributions = {}; + const playerBets = {}; + let pot = 0; + + for (const player of activePlayers) { + hands[player] = [deck.pop(), deck.pop()]; + folded[player] = false; + acted[player] = false; + contributions[player] = MINIMUM_STAKE; + playerBets[player] = 0; + stacks[player] -= MINIMUM_STAKE; + pot += MINIMUM_STAKE; + } + + for (const player of players) { + if (!activePlayers.includes(player)) { + folded[player] = true; + acted[player] = true; + contributions[player] = 0; + playerBets[player] = 0; + } + } + + pokerGames[lobbyId] = { + lobbyId, + players, + activePlayers, + deck, + round: roundNumber, + street: "preflop", + phase: activePlayers.length >= 2 ? "betting" : "waiting", + pot, + currentBet: 0, + bet: 0, + dealerIndex, + communityCards: [], + hands, + folded, + acted, + contributions, + playerBets, + stacks, + currentPlayerIndex: activePlayers.length >= 2 + ? getFirstActiveLeftOfDealer({players, folded, dealerIndex}) + : -1, + lastAction: activePlayers.length >= 2 + ? `Round ${roundNumber} started. $${MINIMUM_STAKE} minimum stake paid by each player.` + : `Waiting for at least two players with $${MINIMUM_STAKE}.`, + lastBettor: null, + winner: null, + showdown: null, + potAwarded: false, + nextRoundTimer: null, + nextRoundStartsAt: null, + turnTimer: null, + turnTimerPlayer: null, + turnEndsAt: null, + minimumStake: MINIMUM_STAKE + }; + + startTurnTimer(pokerGames[lobbyId]); + return pokerGames[lobbyId]; +} + +async function createPokerIntermission(lobbyId) { + const players = playerMap[lobbyId] || []; + const existingGame = pokerGames[lobbyId]; + if (existingGame?.nextRoundTimer) { + clearTimeout(existingGame.nextRoundTimer); + } + + const roundNumber = existingGame ? existingGame.round + 1 : 1; + const previousStacks = existingGame?.stacks || {}; + const stacks = {}; + const folded = {}; + const acted = {}; + const contributions = {}; + const playerBets = {}; + + for (const player of players) { + stacks[player] = await getInitialPokerStack(player, previousStacks); + folded[player] = true; + acted[player] = true; + contributions[player] = 0; + playerBets[player] = 0; + } + + const previousResult = existingGame?.winner + ? `${existingGame.lastAction} Next round starts in ${ROUND_INTERMISSION_MS / 1000} seconds.` + : `Next round starts in ${ROUND_INTERMISSION_MS / 1000} seconds. Minimum stake: $${MINIMUM_STAKE}.`; + + const intermission = { + lobbyId, + players, + activePlayers: [], + deck: [], + round: roundNumber, + street: "intermission", + phase: "intermission", + pot: 0, + currentBet: 0, + bet: 0, + dealerIndex: existingGame?.dealerIndex ?? 0, + communityCards: [], + hands: {}, + folded, + acted, + contributions, + playerBets, + stacks, + currentPlayerIndex: -1, + lastAction: previousResult, + lastBettor: null, + winner: existingGame?.winner || null, + showdown: existingGame?.showdown || null, + potAwarded: false, + nextRoundTimer: null, + nextRoundStartsAt: Date.now() + ROUND_INTERMISSION_MS, + turnTimer: null, + turnTimerPlayer: null, + turnEndsAt: null, + minimumStake: MINIMUM_STAKE + }; + + pokerGames[lobbyId] = intermission; + intermission.nextRoundTimer = setTimeout(async () => { + if (!pokerGames[lobbyId] || !playerMap[lobbyId]?.length) return; + await createPokerGame(lobbyId); + broadcastLobby(lobbyId); + broadcastPokerState(lobbyId); + }, ROUND_INTERMISSION_MS); + + return intermission; +} + +function getCurrentPlayer(game) { + if (game.phase !== "betting") return null; + return game.players[game.currentPlayerIndex] || null; +} + +function getActivePlayers(game) { + return game.players.filter(player => !game.folded[player] && game.hands[player]); +} + +function canActInBetting(game, player) { + return !game.folded[player] && game.hands[player] && (game.stacks[player] || 0) > 0; +} + +function getPlayablePlayers(game) { + return game.players.filter(player => (game.stacks[player] || 0) >= MINIMUM_STAKE); +} + +function getCallAmount(game, username) { + return Math.max(0, (game.currentBet || 0) - (game.playerBets[username] || 0)); +} + +function isSpectator(game, username) { + return game.phase !== "intermission" && (!game.hands[username] || (game.stacks[username] || 0) < 0); +} + +function getFirstActiveLeftOfDealer(game) { + for (let offset = 1; offset <= game.players.length; offset++) { + const index = (game.dealerIndex + offset) % game.players.length; + const player = game.players[index]; + if (game.hands ? canActInBetting(game, player) : !game.folded[player]) return index; + } + return -1; +} + +function getQueue(game) { + if (game.phase !== "betting" || game.currentPlayerIndex < 0) { + return getActivePlayers(game); + } + + const queue = []; + for (let offset = 0; offset < game.players.length; offset++) { + const index = (game.currentPlayerIndex + offset) % game.players.length; + const player = game.players[index]; + if (canActInBetting(game, player)) { + queue.push(player); + } + } + return queue; +} + +function clearTurnTimer(game) { + if (game?.turnTimer) { + clearTimeout(game.turnTimer); + } + if (game) { + game.turnTimer = null; + game.turnTimerPlayer = null; + game.turnEndsAt = null; + } +} + +function startTurnTimer(game) { + clearTurnTimer(game); + const currentPlayer = getCurrentPlayer(game); + if (!game || game.phase !== "betting" || !currentPlayer) return; + + game.turnTimerPlayer = currentPlayer; + game.turnEndsAt = Date.now() + TURN_TIMEOUT_MS; + game.turnTimer = setTimeout(() => { + const activeGame = pokerGames[game.lobbyId]; + if (!activeGame || activeGame !== game || getCurrentPlayer(activeGame) !== currentPlayer) return; + + const result = applyPokerAction(activeGame, currentPlayer, "hit", {}, true); + if (result.error && getCurrentPlayer(activeGame) === currentPlayer) { + applyPokerAction(activeGame, currentPlayer, "fold", {}, true); + } + broadcastPokerState(activeGame.lobbyId); + }, TURN_TIMEOUT_MS); +} + +function resetBettingRound(game, street) { + clearTurnTimer(game); + game.street = street; + game.phase = "betting"; + game.currentBet = 0; + game.bet = 0; + game.lastBettor = null; + + for (const player of game.players) { + game.acted[player] = game.folded[player] || (game.hands[player] && (game.stacks[player] || 0) <= 0); + game.playerBets[player] = 0; + } + + game.currentPlayerIndex = getFirstActiveLeftOfDealer(game); + if (game.currentPlayerIndex === -1 || haveAllActivePlayersMatched(game)) { + revealNextStreet(game); + return; + } + startTurnTimer(game); +} + +async function completeRoundWithWinner(game, winner) { + clearTurnTimer(game); + game.phase = "complete"; + game.currentPlayerIndex = -1; + game.winner = winner; + if (!game.potAwarded) { + game.stacks[winner] += game.pot; + game.potAwarded = true; + } + game.lastAction = `${winner} wins $${game.pot}`; + await persistPokerStacks(game); + await scheduleNextRound(game); +} + +async function scheduleNextRound(game) { + clearTurnTimer(game); + if (game.nextRoundTimer || !pokerGames[game.lobbyId]) return; + + await createPokerIntermission(game.lobbyId); + broadcastLobby(game.lobbyId); + broadcastPokerState(game.lobbyId); +} + +async function maybeStartWaitingGame(lobbyId) { + const game = pokerGames[lobbyId]; + if (!game || game.phase !== "waiting") return; + const playersWithChips = game.players.filter(player => (game.stacks[player] || 0) >= MINIMUM_STAKE); + if (playersWithChips.length >= 2) { + await createPokerIntermission(lobbyId); + broadcastPokerState(lobbyId); + } +} + +function finishIfOnlyOneActive(game) { + const activePlayers = getActivePlayers(game); + if (activePlayers.length <= 1) { + clearTurnTimer(game); + if (activePlayers.length === 1) { + completeRoundWithWinner(game, activePlayers[0]).catch(error => { + console.error("Failed to complete poker round:", error); + }); + } else { + game.phase = "complete"; + game.currentPlayerIndex = -1; + game.lastAction = "Round ended"; + } + return true; + } + return false; +} + +function haveAllActivePlayersMatched(game) { + return getActivePlayers(game).every(player => ( + game.acted[player] && (game.playerBets[player] === game.currentBet || (game.stacks[player] || 0) <= 0) + )); +} + +function revealNextStreet(game) { + clearTurnTimer(game); + if (game.street === "preflop") { + game.communityCards.push(game.deck.pop(), game.deck.pop(), game.deck.pop()); + resetBettingRound(game, "flop"); + game.lastAction = "Flop revealed. Second betting round."; + return; + } + + if (game.street === "flop") { + game.communityCards.push(game.deck.pop()); + resetBettingRound(game, "turn"); + game.lastAction = "Turn revealed. Third betting round."; + return; + } + + if (game.street === "turn") { + game.communityCards.push(game.deck.pop()); + resetBettingRound(game, "river"); + game.lastAction = "River revealed. Final betting round."; + return; + } + + startShowdown(game).catch(error => { + console.error("Failed to start poker showdown:", error); + }); +} + +function advanceTurn(game) { + clearTurnTimer(game); + if (finishIfOnlyOneActive(game)) return; + + if (haveAllActivePlayersMatched(game)) { + revealNextStreet(game); + return; + } + + for (let offset = 1; offset <= game.players.length; offset++) { + const nextIndex = (game.currentPlayerIndex + offset) % game.players.length; + const nextPlayer = game.players[nextIndex]; + if (canActInBetting(game, nextPlayer)) { + game.currentPlayerIndex = nextIndex; + startTurnTimer(game); + return; + } + } +} + +function getRankValue(rank) { + return {A: 14, K: 13, Q: 12, J: 11, "10": 10, "9": 9, "8": 8, "7": 7, "6": 6, "5": 5, "4": 4, "3": 3, "2": 2}[rank] || 0; +} + +function getCombinations(cards, size, start = 0, current = [], result = []) { + if (current.length === size) { + result.push([...current]); + return result; + } + + for (let index = start; index < cards.length; index++) { + current.push(cards[index]); + getCombinations(cards, size, index + 1, current, result); + current.pop(); + } + return result; +} + +function compareScores(a, b) { + for (let index = 0; index < Math.max(a.length, b.length); index++) { + const diff = (a[index] || 0) - (b[index] || 0); + if (diff !== 0) return diff; + } + return 0; +} + +function evaluateFiveCardHand(cards) { + const values = cards.map(card => getRankValue(card.rank)).sort((a, b) => b - a); + const suits = cards.map(card => card.suit); + const isFlush = suits.every(suit => suit === suits[0]); + const uniqueValues = [...new Set(values)].sort((a, b) => b - a); + const straightValues = uniqueValues.includes(14) + ? [...uniqueValues, 1] + : uniqueValues; + let straightHigh = 0; + + for (let index = 0; index <= straightValues.length - 5; index++) { + const run = straightValues.slice(index, index + 5); + if (run[0] - run[4] === 4) { + straightHigh = run[0]; + break; + } + } + + const counts = values.reduce((acc, value) => { + acc[value] = (acc[value] || 0) + 1; + return acc; + }, {}); + const groups = Object.entries(counts) + .map(([value, count]) => ({value: Number(value), count})) + .sort((a, b) => b.count - a.count || b.value - a.value); + + const sortedCards = [...cards].sort((a, b) => getRankValue(b.rank) - getRankValue(a.rank)); + + if (isFlush && straightHigh === 14) return {rank: 9, name: "Royal Flush", score: [9, 14], comboCards: sortedCards}; + if (isFlush && straightHigh) return {rank: 8, name: "Straight Flush", score: [8, straightHigh], comboCards: sortedCards}; + if (groups[0].count === 4) { + const comboCards = [ + ...sortedCards.filter(card => getRankValue(card.rank) === groups[0].value), + ...sortedCards.filter(card => getRankValue(card.rank) !== groups[0].value).slice(0, 1) + ]; + return {rank: 7, name: "Four of a Kind", score: [7, groups[0].value, groups[1].value], comboCards}; + } + if (groups[0].count === 3 && groups[1].count === 2) { + const comboCards = [ + ...sortedCards.filter(card => getRankValue(card.rank) === groups[0].value), + ...sortedCards.filter(card => getRankValue(card.rank) === groups[1].value) + ]; + return {rank: 6, name: "Full House", score: [6, groups[0].value, groups[1].value], comboCards}; + } + if (isFlush) return {rank: 5, name: "Flush", score: [5, ...values], comboCards: sortedCards}; + if (straightHigh) return {rank: 4, name: "Straight", score: [4, straightHigh], comboCards: sortedCards}; + if (groups[0].count === 3) { + const kickers = groups.filter(group => group.count === 1).map(group => group.value); + const comboCards = [ + ...sortedCards.filter(card => getRankValue(card.rank) === groups[0].value), + ...sortedCards.filter(card => getRankValue(card.rank) !== groups[0].value) + ]; + return {rank: 3, name: "Three of a Kind", score: [3, groups[0].value, ...kickers], comboCards}; + } + if (groups[0].count === 2 && groups[1].count === 2) { + const pairValues = groups.filter(group => group.count === 2).map(group => group.value); + const kicker = groups.find(group => group.count === 1).value; + const comboCards = [ + ...sortedCards.filter(card => pairValues.includes(getRankValue(card.rank))), + ...sortedCards.filter(card => !pairValues.includes(getRankValue(card.rank))) + ]; + return {rank: 2, name: "Two Pair", score: [2, ...pairValues, kicker], comboCards}; + } + if (groups[0].count === 2) { + const kickers = groups.filter(group => group.count === 1).map(group => group.value); + const comboCards = [ + ...sortedCards.filter(card => getRankValue(card.rank) === groups[0].value), + ...sortedCards.filter(card => getRankValue(card.rank) !== groups[0].value) + ]; + return {rank: 1, name: "Pair", score: [1, groups[0].value, ...kickers], comboCards}; + } + return {rank: 0, name: "High Card", score: [0, ...values], comboCards: sortedCards}; +} + +function evaluateBestHand(cards) { + return getCombinations(cards, 5) + .map(evaluateFiveCardHand) + .sort((a, b) => compareScores(b.score, a.score))[0]; +} + +async function startShowdown(game) { + const activePlayers = getActivePlayers(game); + const evaluations = activePlayers.map(player => ({ + player, + hand: game.hands[player], + result: evaluateBestHand([...game.hands[player], ...game.communityCards]) + })); + const rankedResults = [...evaluations].sort((a, b) => compareScores(b.result.score, a.result.score)); + const bettorIndex = game.lastBettor ? game.players.indexOf(game.lastBettor) : 0; + const startIndex = bettorIndex >= 0 ? bettorIndex : 0; + const revealOrder = [...evaluations].sort((a, b) => { + const aIndex = game.players.indexOf(a.player); + const bIndex = game.players.indexOf(b.player); + return ((aIndex - startIndex + game.players.length) % game.players.length) + - ((bIndex - startIndex + game.players.length) % game.players.length); + }); + + game.phase = "showdown"; + game.currentPlayerIndex = -1; + game.showdown = revealOrder; + game.winner = rankedResults[0]?.player || null; + if (game.winner && !game.potAwarded) { + game.stacks[game.winner] += game.pot; + game.potAwarded = true; + } + game.lastAction = game.winner + ? `Showdown: ${game.winner} wins $${game.pot} with ${rankedResults[0].result.name}` + : "Showdown ended"; + await persistPokerStacks(game); + await scheduleNextRound(game); +} + +function createPokerState(game, username) { + return { + type: "poker:state", + lobbyId: game.lobbyId, + username, + players: game.players, + round: game.round, + pot: game.pot, + bet: game.bet, + currentBet: game.currentBet, + communityCards: game.communityCards, + hand: game.hands[username] || [], + currentPlayer: getCurrentPlayer(game), + isYourTurn: getCurrentPlayer(game) === username, + folded: game.folded, + queue: getQueue(game), + street: game.street, + phase: game.phase, + lastAction: game.lastAction, + contributions: game.contributions, + yourContribution: game.contributions[username] || 0, + stacks: game.stacks, + yourStack: game.stacks[username] || 0, + isSpectator: isSpectator(game, username), + playablePlayers: getPlayablePlayers(game), + canLeaveTable: true, + callAmount: getCallAmount(game, username), + minimumStake: game.minimumStake || MINIMUM_STAKE, + nextRoundStartsAt: game.nextRoundStartsAt, + turnEndsAt: game.turnEndsAt, + turnTimeoutMs: TURN_TIMEOUT_MS, + winner: game.winner, + showdown: game.phase === "showdown" || game.phase === "intermission" ? game.showdown : null + }; +} + +function broadcastPokerState(lobbyId) { + const game = pokerGames[lobbyId]; + if (!game || !lobbySockets[lobbyId]) return; + + for (const socket of lobbySockets[lobbyId]) { + sendSocketMessage(socket, createPokerState(game, socket.user.username)); + } +} + +function canLeavePokerTable(lobbyId, username) { + return Boolean(lobbyId && username); +} + +async function removePlayerFromPokerGame(lobbyId, username) { + const game = pokerGames[lobbyId]; + if (!game) return; + + const remainingStack = game.stacks[username]; + if (Number.isFinite(remainingStack)) { + await setUserMoney(username, remainingStack); + } + + const index = game.players.indexOf(username); + if (index > -1) { + game.players.splice(index, 1); + } + delete game.hands[username]; + delete game.folded[username]; + delete game.acted[username]; + delete game.contributions[username]; + delete game.playerBets[username]; + delete game.stacks[username]; + + if (game.players.length === 0) { + if (game.nextRoundTimer) { + clearTimeout(game.nextRoundTimer); + } + clearTurnTimer(game); + delete pokerGames[lobbyId]; + return; + } + + if (game.currentPlayerIndex >= game.players.length) { + game.currentPlayerIndex = 0; + } + game.dealerIndex = Math.min(game.dealerIndex, game.players.length - 1); + if (game.turnTimerPlayer === username) { + clearTurnTimer(game); + if (game.phase === "betting" && !haveAllActivePlayersMatched(game)) { + startTurnTimer(game); + } + } + if (finishIfOnlyOneActive(game)) return; + if (game.phase === "betting" && haveAllActivePlayersMatched(game)) { + revealNextStreet(game); + } +} + +async function ensurePlayerInPokerGame(lobbyId, username) { + const game = pokerGames[lobbyId]; + if (!game || !username) return; + + if (!game.players.includes(username)) { + game.players.push(username); + } + + if (!Number.isFinite(game.stacks[username])) { + game.stacks[username] = await getInitialPokerStack(username, {}); + } + if (!Object.prototype.hasOwnProperty.call(game.folded, username)) { + game.folded[username] = true; + } + if (!Object.prototype.hasOwnProperty.call(game.acted, username)) { + game.acted[username] = true; + } + if (!Object.prototype.hasOwnProperty.call(game.contributions, username)) { + game.contributions[username] = 0; + } + if (!Object.prototype.hasOwnProperty.call(game.playerBets, username)) { + game.playerBets[username] = 0; + } +} + +function applyPokerAction(game, username, action, payload = {}, automatic = false) { + if (game.phase !== "betting") { + return {error: "This round is already finished"}; + } + + if (isSpectator(game, username)) { + return {error: "You are out of chips and can only spectate"}; + } + + if (getCurrentPlayer(game) !== username) { + return {error: "It is not your turn"}; + } + + if (game.folded[username]) { + return {error: "You already folded"}; + } + + if (action === "bet") { + const amount = Math.max(0, Number(payload.amount) || 0); + if (amount > game.stacks[username]) { + return {error: "You do not have enough chips"}; + } + if (amount > 0 && amount < MINIMUM_STAKE) { + return {error: `Minimum stake is $${MINIMUM_STAKE}`}; + } + if (amount <= 0 && game.playerBets[username] < game.currentBet) { + return {error: `You need to match the current bet of $${game.currentBet}`}; + } + if (game.playerBets[username] + amount < game.currentBet) { + return {error: `Minimum bet is $${game.currentBet - game.playerBets[username]} to call`}; + } + clearTurnTimer(game); + game.acted[username] = true; + game.bet = amount; + game.currentBet = Math.max(game.currentBet, game.playerBets[username] + amount); + game.playerBets[username] += amount; + game.contributions[username] += amount; + game.stacks[username] -= amount; + game.pot += amount; + game.lastBettor = username; + game.lastAction = `${username} bet $${amount}`; + advanceTurn(game); + return {}; + } + + if (action === "fold") { + clearTurnTimer(game); + game.folded[username] = true; + game.acted[username] = true; + game.lastAction = `${username} folded`; + advanceTurn(game); + return {}; + } + + if (action === "hit") { + const callAmount = getCallAmount(game, username); + const paidAmount = Math.min(callAmount, game.stacks[username]); + clearTurnTimer(game); + if (paidAmount > 0) { + game.playerBets[username] += paidAmount; + game.contributions[username] += paidAmount; + game.stacks[username] -= paidAmount; + game.pot += paidAmount; + } + game.acted[username] = true; + game.lastAction = paidAmount > 0 + ? `${username} ${paidAmount < callAmount ? "went all-in with" : automatic ? "auto-called" : "called"} $${paidAmount}` + : `${username} ${automatic ? "auto-checked" : "checked"}`; + advanceTurn(game); + return {}; + } + + return {error: "Unknown poker action"}; +} + +function sendSocketMessage(socket, payload) { + if (socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify(payload)); + } +} + +function disconnectKey(lobbyId, username) { + return `${lobbyId}:${username}`; +} + +function clearDisconnectTimer(lobbyId, username) { + const key = disconnectKey(lobbyId, username); + if (disconnectTimers[key]) { + clearTimeout(disconnectTimers[key]); + delete disconnectTimers[key]; + } +} + +function hasActiveLobbySocket(lobbyId, username) { + return [...(lobbySockets[lobbyId] || [])].some(socket => ( + socket.user?.username === username && socket.readyState === WebSocket.OPEN + )); +} + +async function leavePokerLobby(lobbyId, username) { + if (!lobbyId || !username || !playerMap[lobbyId]?.includes(username)) return false; + + clearDisconnectTimer(lobbyId, username); + await leaveLobby(lobbyId, username); + await removePlayerFromPokerGame(lobbyId, username); + await deleteLobby(lobbyId); + await setUserLobby(username, null); + broadcastLobby(lobbyId); + broadcastPokerState(lobbyId); + return true; +} + +function scheduleDisconnectLeave(lobbyId, username) { + if (!lobbyId || !username || hasActiveLobbySocket(lobbyId, username)) return; + + clearDisconnectTimer(lobbyId, username); + disconnectTimers[disconnectKey(lobbyId, username)] = setTimeout(async () => { + delete disconnectTimers[disconnectKey(lobbyId, username)]; + if (hasActiveLobbySocket(lobbyId, username)) return; + + try { + await leavePokerLobby(lobbyId, username); + } catch (error) { + console.error("Failed to leave disconnected player:", error); + } + }, DISCONNECT_GRACE_MS); +} + +function subscribeToLobby(socket, lobbyId) { + if (socket.lobbyId && lobbySockets[socket.lobbyId]) { + lobbySockets[socket.lobbyId].delete(socket); + } + socket.lobbyId = lobbyId; + if (!lobbySockets[lobbyId]) { + lobbySockets[lobbyId] = new Set(); + } + lobbySockets[lobbyId].add(socket); + clearDisconnectTimer(lobbyId, socket.user.username); +} + +function broadcastLobby(lobbyId) { + if (!lobbyId || !lobbySockets[lobbyId]) return; + const players = playerMap[lobbyId] || []; + const gameStarted = Boolean(pokerGames[lobbyId]); + const message = { + type: "lobby:state", + lobbyId, + players, + host: lobbyHosts[lobbyId] || players[0] || null, + isPublic: lobbyPrivacy[lobbyId] !== "private", + canStart: players.length >= 2, + gameStarted, + gameUrl: gameStarted ? `/poker.html?lobbyId=${encodeURIComponent(lobbyId)}` : null + }; + for (const socket of lobbySockets[lobbyId]) { + sendSocketMessage(socket, message); + } +} + +function createPublicLobbyList() { + return Object.entries(playerMap) + .filter(([lobbyId]) => lobbyPrivacy[lobbyId] !== "private") + .map(([lobbyId, players]) => ({ + lobbyId, + host: lobbyHosts[lobbyId] || players[0] || null, + players: players.length, + gameStarted: Boolean(pokerGames[lobbyId]) + })); +} + +function broadcastToLobby(lobbyId, payload) { + if (!lobbyId || !lobbySockets[lobbyId]) return; + for (const socket of lobbySockets[lobbyId]) { + sendSocketMessage(socket, payload); + } +} async function createLobby() { let response = await fetch("https://www.deckofcardsapi.com/api/deck/new/shuffle/?deck_count=1") @@ -197,6 +1121,7 @@ app.post("/leave-lobby", (req, res) => { playerMap[lobbyId] = playerMap[lobbyId].filter(player => player !== username); if (playerMap[lobbyId].length === 0) { delete playerMap[lobbyId]; + delete lobbyPrivacy[lobbyId]; console.log(`Leere Lobby ${lobbyId} wurde gelöscht.`); } res.json({message: `Lobby ${lobbyId} aktualisiert.`}); @@ -207,6 +1132,7 @@ function removeEmptyLobbies(playerMap) { for (const lobby in playerMap) { if (playerMap[lobby].length === 0) { delete playerMap[lobby]; + delete lobbyPrivacy[lobby]; console.log(`Lobby ${lobby} wurde gelöscht.`); } } @@ -214,7 +1140,14 @@ function removeEmptyLobbies(playerMap) { async function leaveLobby(id, username) { if (id && playerMap[id] && username) { - playerMap[id].splice(playerMap[id].indexOf(username), 1); + const index = playerMap[id].indexOf(username); + if (index > -1) { + playerMap[id].splice(index, 1); + if (lobbyHosts[id] === username) { + lobbyHosts[id] = playerMap[id][0] || null; + } + broadcastLobby(id); + } } } @@ -222,6 +1155,14 @@ async function deleteLobby(id) { if (id && playerMap[id]) { if (playerMap[id].length === 0) { delete playerMap[id]; + delete lobbySockets[id]; + delete lobbyHosts[id]; + delete lobbyPrivacy[id]; + if (pokerGames[id]?.nextRoundTimer) { + clearTimeout(pokerGames[id].nextRoundTimer); + } + clearTurnTimer(pokerGames[id]); + delete pokerGames[id]; } } } @@ -231,7 +1172,7 @@ async function joinLobby(lobbyId, player_name) { } app.get("/lobby/create", authenticateToken, async (req, res) => { - const username = req.query.username; + const username = req.user.username; if (!username) { return res.status(400).send("Username is required"); } @@ -242,20 +1183,17 @@ app.get("/lobby/create", authenticateToken, async (req, res) => { if (!playerMap[lobbyId].includes(username)) { playerMap[lobbyId].push(username); } - const users = get_data(); - const user = users.find(u => u.username === username); - if (user) { - user.lobbyId = lobbyId; - save_data(users); - } + lobbyHosts[lobbyId] = username; + lobbyPrivacy[lobbyId] = req.query.private === "true" ? "private" : "public"; + broadcastLobby(lobbyId); + await setUserLobby(username, lobbyId); res.redirect(`/lobby/join?lobbyId=${lobbyId}&username=${username}`); }) app.get("/lobby/join", authenticateToken, async (req, res) => { let lobbyId = req.query.lobbyId; - let username = req.query.username; - const users = get_data(); - const user = users.find(u => u.username === username); + let username = req.user.username; + const user = await findUserByUsername(username); if (!user) { return res.status(401).json({error: "User doesn't exist"}); } @@ -264,8 +1202,11 @@ app.get("/lobby/join", authenticateToken, async (req, res) => { if (!playerMap[lobbyId].includes(username)) { playerMap[lobbyId].push(username); } - user.lobbyId = lobbyId; - save_data(users); + await ensurePlayerInPokerGame(lobbyId, username); + await maybeStartWaitingGame(lobbyId); + broadcastLobby(lobbyId); + broadcastPokerState(lobbyId); + await setUserLobby(username, lobbyId); res.json({lobbyId, username}); } else { console.log("Lobby with " + lobbyId + " not found."); @@ -275,18 +1216,15 @@ app.get("/lobby/join", authenticateToken, async (req, res) => { app.delete("/lobby/leave", authenticateToken, async (req, res) => { const lobbyId = req.query.lobbyId; - const username = req.query.username; + const username = req.user.username; if (!lobbyId || !username) { return res.status(400).json({error: "Missing lobbyId or username"}); } if (lobbyId in playerMap) { - const index = playerMap[lobbyId].indexOf(username); - if (index > -1) { - playerMap[lobbyId].splice(index, 1); + if (await leavePokerLobby(lobbyId, username)) { return res.json({message: "Successfully left lobby"}); - } else { - return res.status(404).json({error: "User not in lobby"}); } + return res.status(404).json({error: "User not in lobby"}); } res.status(404).json({error: "Lobby not found"}); }); @@ -297,6 +1235,181 @@ app.get("/lobby/players", authenticateToken, async (req, res) => { res.json({players}); }); -app.listen(port, () => { - console.log("Server is running on port " + port); -}); \ No newline at end of file +app.get("/lobby/public", authenticateToken, async (req, res) => { + res.json({lobbies: createPublicLobbyList()}); +}); + +wss.on("connection", async (socket, request) => { + const cookies = parseCookieHeader(request.headers.cookie); + const token = request.headers.authorization?.split(" ")[1] || cookies.authorization; + const user = await verifyAuthToken(token); + + if (!user) { + sendSocketMessage(socket, {type: "auth:error", message: "Authentication failed"}); + socket.close(1008, "Authentication failed"); + return; + } + + socket.user = user; + sendSocketMessage(socket, {type: "auth:success", username: user.username}); + const storedUser = await findUserByUsername(user.username); + if (storedUser?.lobbyId && playerMap[storedUser.lobbyId]?.includes(user.username)) { + subscribeToLobby(socket, storedUser.lobbyId); + broadcastLobby(storedUser.lobbyId); + } + + socket.on("message", async (rawMessage) => { + let message; + try { + message = JSON.parse(rawMessage.toString()); + } catch { + sendSocketMessage(socket, {type: "error", message: "Invalid JSON message"}); + return; + } + + try { + if (message.type === "lobby:create") { + const lobbyId = await createLobby(); + playerMap[lobbyId] = playerMap[lobbyId] || []; + if (!playerMap[lobbyId].includes(user.username)) { + playerMap[lobbyId].push(user.username); + } + lobbyHosts[lobbyId] = user.username; + lobbyPrivacy[lobbyId] = message.private ? "private" : "public"; + + await setUserLobby(user.username, lobbyId); + + subscribeToLobby(socket, lobbyId); + await ensurePlayerInPokerGame(lobbyId, user.username); + await maybeStartWaitingGame(lobbyId); + broadcastLobby(lobbyId); + broadcastPokerState(lobbyId); + return; + } + + if (message.type === "lobby:join") { + const lobbyId = message.lobbyId; + if (!lobbyId || !playerMap[lobbyId]) { + sendSocketMessage(socket, {type: "lobby:error", message: "Lobby not found"}); + return; + } + + await joinLobby(lobbyId, user.username); + if (!playerMap[lobbyId].includes(user.username)) { + playerMap[lobbyId].push(user.username); + } + await ensurePlayerInPokerGame(lobbyId, user.username); + await maybeStartWaitingGame(lobbyId); + + await setUserLobby(user.username, lobbyId); + + subscribeToLobby(socket, lobbyId); + broadcastLobby(lobbyId); + broadcastPokerState(lobbyId); + return; + } + + if (message.type === "lobby:leave") { + const lobbyId = message.lobbyId || socket.lobbyId; + await leavePokerLobby(lobbyId, user.username); + if (lobbySockets[lobbyId]) { + lobbySockets[lobbyId].delete(socket); + } + socket.lobbyId = null; + sendSocketMessage(socket, {type: "lobby:left", lobbyId}); + broadcastPokerState(lobbyId); + return; + } + + if (message.type === "poker:start") { + const lobbyId = message.lobbyId || socket.lobbyId; + const players = playerMap[lobbyId] || []; + if (!lobbyId || !playerMap[lobbyId]) { + sendSocketMessage(socket, {type: "poker:error", message: "Lobby not found"}); + return; + } + if (lobbyHosts[lobbyId] !== user.username) { + sendSocketMessage(socket, {type: "poker:error", message: "Only the lobby host can start the game"}); + return; + } + if (players.length < 2) { + sendSocketMessage(socket, {type: "poker:error", message: "At least 2 players are needed to start"}); + return; + } + + await createPokerIntermission(lobbyId); + broadcastLobby(lobbyId); + broadcastPokerState(lobbyId); + broadcastToLobby(lobbyId, { + type: "poker:started", + lobbyId, + url: `/poker.html?lobbyId=${encodeURIComponent(lobbyId)}` + }); + return; + } + + if (message.type === "poker:join") { + const lobbyId = message.lobbyId; + if (!lobbyId || !playerMap[lobbyId] || !playerMap[lobbyId].includes(user.username)) { + sendSocketMessage(socket, {type: "poker:error", message: "Poker table not found"}); + return; + } + + subscribeToLobby(socket, lobbyId); + if (!pokerGames[lobbyId]) { + await createPokerIntermission(lobbyId); + } + await ensurePlayerInPokerGame(lobbyId, user.username); + await maybeStartWaitingGame(lobbyId); + broadcastPokerState(lobbyId); + return; + } + + if (message.type === "poker:action") { + if (!socket.lobbyId) { + sendSocketMessage(socket, {type: "poker:error", message: "Join a lobby before sending poker actions"}); + return; + } + const game = pokerGames[socket.lobbyId]; + if (!game) { + sendSocketMessage(socket, {type: "poker:error", message: "Poker game not found"}); + return; + } + + const result = applyPokerAction(game, user.username, message.action, message.payload); + if (result.error) { + sendSocketMessage(socket, {type: "poker:error", message: result.error}); + return; + } + + broadcastPokerState(socket.lobbyId); + return; + } + + sendSocketMessage(socket, {type: "error", message: "Unknown message type"}); + } catch (error) { + console.error("WebSocket message error:", error); + sendSocketMessage(socket, {type: "error", message: "WebSocket request failed"}); + } + }); + + socket.on("close", async () => { + if (!socket.lobbyId) return; + const lobbyId = socket.lobbyId; + if (lobbySockets[lobbyId]) { + lobbySockets[lobbyId].delete(socket); + } + scheduleDisconnectLeave(lobbyId, socket.user.username); + }); +}); + +initializeDatabase() + .then(() => { + server.listen(port, () => { + console.log("Server is running on port " + port); + }); + }) + .catch((error) => { + console.error("Failed to initialize database:", error); + process.exit(1); + }); diff --git a/package.json b/package.json index 4d40208..9a066b6 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,15 @@ }, "private": true, "dependencies": { + "bcrypt": "^6.0.0", "cookie-parser": "^1.4.7", "dotenv": "^16.5.0", "express": "^5.1.0", "jsonwebtoken": "^9.0.2", - "swagger-ui-express": "^5.0.1" + "sqlite": "^5.1.1", + "sqlite3": "^6.0.1", + "swagger-ui-express": "^5.0.1", + "ws": "^8.20.0" }, "devDependencies": { "swagger-autogen": "^2.23.7" diff --git a/public/frontend_home.js b/public/frontend_home.js index b91cb35..81ddd74 100644 --- a/public/frontend_home.js +++ b/public/frontend_home.js @@ -3,50 +3,204 @@ document.addEventListener("DOMContentLoaded", async () => { 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"); - let playerUpdateInterval = null; + 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 startPlayerUpdates = (lobbyId) => { - if (playerUpdateInterval) { - clearInterval(playerUpdateInterval); - } - - playerUpdateInterval = setInterval(async () => { - const res = await fetch(`/lobby/players?lobbyId=${lobbyId}`); - const data = await res.json(); - const playerList = document.getElementById("player-list"); - playerList.innerHTML = data.players.map(p => `

${p}

`).join("") || "

No players yet

"; - }, 3000); + const showJoinError = (message) => { + joinError.textContent = message; + joinError.classList.remove("hidden"); }; - leaveButton.addEventListener("click", async () => { - const username = await getUsernameFromToken(); - const lobbyId = document.getElementById("lobby-id").textContent; - try { - const res = await fetch(`/lobby/leave?lobbyId=${lobbyId}&username=${username}`, { - method: "DELETE", - }); - if (res.ok) { - // Stoppe das Interval beim Verlassen - if (playerUpdateInterval) { - clearInterval(playerUpdateInterval); - playerUpdateInterval = null; - } - document.getElementById("lobby-room").classList.add("hidden"); - document.getElementById("player-list").innerHTML = "

Waiting for players...

"; - document.getElementById("lobby-id").textContent = "N/A"; - } else { - const err = await res.json(); - alert("Failed to leave lobby: " + (err.error || "Unknown error")); - } - } catch (err) { - console.error("Error leaving lobby:", err); - alert("Could not leave lobby."); + const clearJoinError = () => { + joinError.textContent = ""; + joinError.classList.add("hidden"); + }; + + const renderPlayers = (players) => { + playerList.innerHTML = ""; + + if (!players.length) { + playerList.innerHTML = "

No players yet

"; + 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 = "

No public lobbies found

"; + 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 = "

Loading lobbies...

"; + const res = await fetch("/lobby/public"); + if (!res.ok) { + throw new Error("Failed to load lobbies"); + } + const data = await res.json(); + renderPublicLobbies(data.lobbies || []); + } catch (error) { + publicLobbies.innerHTML = "

Could not load lobbies

"; + } + }; + + const hideLobby = () => { + currentLobbyId = null; + lobbyRoom.classList.add("hidden"); + lobbyIdDisplay.textContent = "N/A"; + playerList.innerHTML = "

Waiting for players...

"; + startGameButton.classList.add("hidden"); + startGameButton.disabled = true; + }; + + 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("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); + + 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 () => { try { @@ -54,24 +208,35 @@ document.addEventListener("DOMContentLoaded", async () => { const data = await res.json(); return data.user?.username; } catch (err) { - console.error("Failed to get username from token"); + console.error("Failed to get username from token", err); return null; } }; try { - const balanceRes = await fetch('/balance', {method: "GET"}); + await connectSocket(); + currentUsername = await getUsernameFromToken(); + } catch (error) { + showJoinError("Real-time poker connection failed."); + } + + try { + const balanceRes = await fetch("/balance", {method: "GET"}); if (balanceRes.ok) { const balanceData = await balanceRes.json(); - balance.innerHTML = balanceData.balance; + balance.textContent = balanceData.balance; } } catch (error) { console.log("Balance error:", error); } + logoutButton.addEventListener("click", async (e) => { e.preventDefault(); try { - const logoutRes = await fetch('/logout', {method: "DELETE"}); + if (socket && socket.readyState === WebSocket.OPEN) { + socket.close(); + } + const logoutRes = await fetch("/logout", {method: "DELETE"}); if (logoutRes.ok) { document.cookie = "authorization=; Max-Age=0; path=/;"; window.location.href = "/login.html"; @@ -80,74 +245,80 @@ document.addEventListener("DOMContentLoaded", async () => { console.log("Logout error:", err); } }); - // Im hostButton Event Listener + hostButton.addEventListener("click", async (e) => { e.preventDefault(); - const username = await getUsernameFromToken(); - if (!username) return alert("User not authenticated"); - try { - const res = await fetch(`/lobby/create?username=${username}`); - if (res.redirected) { - const joinURL = new URL(res.url); - const lobbyId = joinURL.searchParams.get("lobbyId"); - const joinRes = await fetch(joinURL.href); - const {lobbyId: finalLobbyId} = await joinRes.json(); - document.getElementById("lobby-id").textContent = finalLobbyId; - document.getElementById("lobby-room").classList.remove("hidden"); - // ... bisheriger Code bis zum Interval ... - startPlayerUpdates(finalLobbyId); - } - } catch (err) { - console.error("Lobby create/join error:", err); - } + 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(); - if (joinForm.classList.contains("hidden")) { - joinForm.classList.remove("hidden"); + joinForm.classList.toggle("hidden"); + hostForm.classList.add("hidden"); + if (!joinForm.classList.contains("hidden")) { joinCodeInput.focus(); - } else { - joinForm.classList.add("hidden"); + loadPublicLobbies(); } }); - // Im joinSubmit Event Listener + + refreshLobbies.addEventListener("click", (e) => { + e.preventDefault(); + loadPublicLobbies(); + }); + joinSubmit.addEventListener("click", async (e) => { e.preventDefault(); + clearJoinError(); + const lobbyId = joinCodeInput.value.trim(); - const joinError = document.getElementById("join-error"); - joinError.classList.add("hidden"); // hide any previous error - joinError.textContent = ""; if (!lobbyId) { - joinError.textContent = "Please enter a lobby ID."; - joinError.classList.remove("hidden"); + showJoinError("Please enter a lobby ID."); return; } - const username = await getUsernameFromToken(); - if (!username) { - joinError.textContent = "User not authenticated."; - joinError.classList.remove("hidden"); + + if (!currentUsername) { + showJoinError("User not authenticated."); return; } - try { - const joinRes = await fetch(`/lobby/join?lobbyId=${lobbyId}&username=${username}`); - if (joinRes.ok) { - const {lobbyId: finalLobbyId} = await joinRes.json(); - document.getElementById("lobby-id").textContent = finalLobbyId; - document.getElementById("lobby-room").classList.remove("hidden"); - joinForm.classList.add("hidden"); - joinCodeInput.value = ""; - // ... bisheriger Code bis zum Interval ... - startPlayerUpdates(finalLobbyId); - } else { - const error = await joinRes.json(); - joinError.textContent = "Join failed: " + (error.error || "Unknown error"); - joinError.classList.remove("hidden"); - } - } catch (err) { - console.error("Join lobby error:", err); - joinError.textContent = "Could not join lobby. Please try again."; - joinError.classList.remove("hidden"); - } + + 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() { @@ -164,7 +335,7 @@ document.addEventListener("DOMContentLoaded", async () => { item.textContent = `${i + 1}. ${player.username} - $${player.money}`; if (player.username === data.self.username) { item.classList.add("you"); - item.textContent += " 👑"; + item.textContent += " (you)"; } list.appendChild(item); }); @@ -179,4 +350,4 @@ document.addEventListener("DOMContentLoaded", async () => { } await loadLeaderboard(); -}); \ No newline at end of file +}); diff --git a/public/frontend_poker.js b/public/frontend_poker.js new file mode 100644 index 0000000..ae7f202 --- /dev/null +++ b/public/frontend_poker.js @@ -0,0 +1,342 @@ +document.addEventListener("DOMContentLoaded", () => { + const roundDisplay = document.getElementById("round"); + const streetDisplay = document.getElementById("street"); + const potDisplay = document.getElementById("pot"); + const communityCards = document.getElementById("community-cards"); + const playerCards = document.getElementById("player-cards"); + const spectatorNote = document.getElementById("spectator-note"); + const playerName = document.getElementById("player-name"); + const currentBet = document.getElementById("current-bet"); + const yourContribution = document.getElementById("your-contribution"); + const yourStack = document.getElementById("your-stack"); + const currentTurn = document.getElementById("current-turn"); + const turnState = document.getElementById("turn-state"); + const turnQueue = document.getElementById("turn-queue"); + const lastAction = document.getElementById("last-action"); + const showdownPanel = document.getElementById("showdown-panel"); + const errorMessage = document.getElementById("error-message"); + const leaveTable = document.getElementById("leave-table"); + const betModal = document.getElementById("bet-modal"); + const closeBet = document.getElementById("close-bet"); + const betRange = document.getElementById("bet-range"); + const betValue = document.getElementById("bet-value"); + const decreaseBet = document.getElementById("decrease-bet"); + const increaseBet = document.getElementById("increase-bet"); + const confirmBet = document.getElementById("confirm-bet"); + 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; + let isYourTurn = false; + let canLeaveTable = true; + let isSpectator = false; + let currentStackValue = 400; + let minimumStake = 10; + let callAmount = 0; + let countdownTimer = null; + const actionTooltips = { + hit: "Check or call if your bet already matches the table.", + fold: "Give up this hand and lose your current stake.", + bet: "Open the bet dialog to add chips to the pot." + }; + + actionButtons.forEach((button) => { + const tooltip = actionTooltips[button.dataset.action]; + if (!tooltip) return; + button.title = tooltip; + button.setAttribute("aria-label", `${button.textContent.trim()}: ${tooltip}`); + }); + closeBet.title = "Close the bet dialog."; + decreaseBet.title = "Decrease the bet amount."; + increaseBet.title = "Increase the bet amount."; + confirmBet.title = "Place the selected bet."; + + const showError = (message) => { + errorMessage.textContent = message; + errorMessage.classList.remove("hidden"); + }; + + const hideError = () => { + errorMessage.textContent = ""; + errorMessage.classList.add("hidden"); + }; + + const renderCard = (card, fallback) => { + const cardElement = document.createElement("div"); + cardElement.className = "card"; + if (!card) { + cardElement.classList.add("placeholder"); + cardElement.textContent = fallback; + return cardElement; + } + + cardElement.classList.toggle("red", card.suit === "H" || card.suit === "D"); + cardElement.innerHTML = `${card.rank}${card.suit}`; + return cardElement; + }; + + const renderCards = (container, cards, count, fallbackPrefix) => { + container.innerHTML = ""; + for (let index = 0; index < count; index++) { + container.appendChild(renderCard(cards[index], `${fallbackPrefix} ${index + 1}`)); + } + }; + + const renderQueue = (queue = [], currentPlayer = null) => { + turnQueue.innerHTML = ""; + queue.forEach((player, index) => { + const item = document.createElement("li"); + item.textContent = index === 0 ? `${player} (now)` : player; + item.classList.toggle("active", player === currentPlayer); + turnQueue.appendChild(item); + }); + }; + + const renderShowdown = (showdown = [], winner = null) => { + showdownPanel.innerHTML = ""; + showdownPanel.classList.toggle("hidden", showdown.length === 0 && !winner); + + if (winner) { + const heading = document.createElement("h2"); + heading.textContent = `Winner: ${winner}`; + showdownPanel.appendChild(heading); + } + + showdown.forEach((entry) => { + const row = document.createElement("div"); + row.className = "showdown-row"; + + const name = document.createElement("span"); + name.textContent = `${entry.player}: ${entry.result.name}`; + row.appendChild(name); + + const cards = document.createElement("div"); + cards.className = "mini-cards"; + (entry.result.comboCards || entry.hand).forEach((card) => cards.appendChild(renderCard(card, "?"))); + row.appendChild(cards); + + showdownPanel.appendChild(row); + }); + }; + + const sendSocketMessage = async (message) => { + const ws = await connectSocket(); + ws.send(JSON.stringify(message)); + }; + + const updateActionButtons = () => { + actionButtons.forEach((button) => { + button.disabled = !isYourTurn || isSpectator; + if (button.dataset.action === "hit") { + 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."; + }; + + const stopCountdown = () => { + if (countdownTimer) { + clearInterval(countdownTimer); + countdownTimer = null; + } + }; + + const renderIntermissionCountdown = (startsAt, baseMessage) => { + stopCountdown(); + const updateCountdown = () => { + const seconds = Math.max(0, Math.ceil((Number(startsAt) - Date.now()) / 1000)); + currentTurn.textContent = `${seconds}s`; + turnState.textContent = "Waiting for next round"; + lastAction.textContent = baseMessage || `Next round starts in ${seconds}s. Minimum stake: $${minimumStake}.`; + if (seconds <= 0) { + stopCountdown(); + } + }; + updateCountdown(); + countdownTimer = setInterval(updateCountdown, 1000); + }; + + const renderTurnCountdown = (startsAt, currentPlayer) => { + stopCountdown(); + const updateCountdown = () => { + const seconds = Math.max(0, Math.ceil((Number(startsAt) - Date.now()) / 1000)); + currentTurn.textContent = `${currentPlayer || "Player"} (${seconds}s)`; + turnState.textContent = isYourTurn + ? `Your decision (${seconds}s)` + : `Waiting for other player (${seconds}s)`; + if (seconds <= 0) { + stopCountdown(); + } + }; + updateCountdown(); + 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); + + 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); + roundDisplay.textContent = message.round; + potDisplay.textContent = message.pot; + currentBet.textContent = message.currentBet ?? message.bet; + yourContribution.textContent = message.yourContribution || 0; + yourStack.textContent = currentStackValue; + streetDisplay.textContent = message.street || "Preflop"; + playerName.textContent = message.username; + if (message.phase === "intermission" && message.nextRoundStartsAt) { + renderIntermissionCountdown(message.nextRoundStartsAt, message.lastAction); + } else if (message.phase === "betting" && message.turnEndsAt) { + renderTurnCountdown(message.turnEndsAt, message.currentPlayer); + lastAction.textContent = message.lastAction || ""; + } else { + stopCountdown(); + currentTurn.textContent = message.currentPlayer || "Round ended"; + turnState.textContent = isSpectator + ? "Spectating" + : isYourTurn + ? "Your decision" + : message.phase === "betting" ? "Waiting for other player" : message.phase; + lastAction.textContent = message.lastAction || ""; + } + renderCards(communityCards, message.communityCards || [], Math.max(3, message.communityCards?.length || 0), "Dealer Card"); + renderCards(playerCards, message.hand || [], isSpectator ? 0 : 2, "Card"); + spectatorNote.classList.toggle("hidden", !isSpectator); + renderQueue(message.queue || [], message.currentPlayer); + renderShowdown(message.showdown || [], message.winner); + updateActionButtons(); + return; + } + + if (message.type === "poker:action") { + return; + } + + if (message.type === "lobby:left") { + localStorage.removeItem("activeLobbyId"); + window.location.href = "/"; + return; + } + + 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))); + betRange.value = nextValue; + betValue.textContent = nextValue; + }; + + const updateBetRange = () => { + const maxBet = Math.max(0, currentStackValue); + const requiredBet = Math.max(minimumStake, callAmount); + const minBet = maxBet > 0 ? Math.min(requiredBet, maxBet) : 0; + betRange.min = String(minBet); + betRange.max = String(maxBet); + betRange.step = String(minimumStake); + setBetValue(Math.max(minBet, Number(betRange.value) || minBet)); + }; + + if (!lobbyId) { + showError("No active lobby found."); + isYourTurn = false; + updateActionButtons(); + return; + } + + updateActionButtons(); + + connectSocket() + .then(() => sendSocketMessage({type: "poker:join", lobbyId})) + .catch(() => showError("Could not connect to poker table.")); + + actionButtons.forEach((button) => { + button.addEventListener("click", () => { + const action = button.dataset.action; + if (isSpectator) { + showError("You are out of chips and can only spectate."); + return; + } + if (!isYourTurn) { + showError("Wait until it is your turn."); + return; + } + if (action === "bet") { + updateBetRange(); + betModal.classList.remove("hidden"); + return; + } + + sendSocketMessage({type: "poker:action", action, payload: {lobbyId}}).catch(() => { + showError("Could not send poker action."); + }); + }); + }); + + leaveTable.addEventListener("click", () => { + sendSocketMessage({type: "lobby:leave", lobbyId}).catch(() => { + showError("Could not leave table."); + }); + }); + + closeBet.addEventListener("click", () => betModal.classList.add("hidden")); + betRange.addEventListener("input", () => setBetValue(betRange.value)); + decreaseBet.addEventListener("click", () => setBetValue(Number(betRange.value) - 10)); + increaseBet.addEventListener("click", () => setBetValue(Number(betRange.value) + 10)); + confirmBet.addEventListener("click", () => { + if (isSpectator) { + showError("You are out of chips and can only spectate."); + betModal.classList.add("hidden"); + return; + } + if (!isYourTurn) { + showError("Wait until it is your turn."); + betModal.classList.add("hidden"); + return; + } + sendSocketMessage({ + type: "poker:action", + action: "bet", + payload: {amount: Number(betRange.value), lobbyId} + }).catch(() => showError("Could not place bet.")); + betModal.classList.add("hidden"); + }); +}); diff --git a/public/home.html b/public/home.html index cf9cbf9..82b723c 100644 --- a/public/home.html +++ b/public/home.html @@ -25,9 +25,31 @@ -