From 1233fd5b3995e72cf2defc2c62ce6994bcb0c391 Mon Sep 17 00:00:00 2001 From: milo Date: Thu, 11 Sep 2025 23:46:41 +0200 Subject: [PATCH 1/2] blackjack v0.1 --- index.js | 2 + src/game/blackjack.js | 284 +++++++++++++++++++++++++++++++++ src/game/state.js | 2 + src/server/app.js | 3 + src/server/routes/blackjack.js | 210 ++++++++++++++++++++++++ src/server/socket.js | 54 ++++++- src/utils/index.js | 2 +- 7 files changed, 550 insertions(+), 7 deletions(-) create mode 100644 src/game/blackjack.js create mode 100644 src/server/routes/blackjack.js diff --git a/index.js b/index.js index 7481168..70fc89b 100644 --- a/index.js +++ b/index.js @@ -19,6 +19,8 @@ export const io = new Server(server, { origin: FLAPI_URL, methods: ['GET', 'POST', 'PUT', 'OPTIONS'], }, + pingInterval: 5000, + pingTimeout: 5000, }); initializeSocket(io, client); diff --git a/src/game/blackjack.js b/src/game/blackjack.js new file mode 100644 index 0000000..7d339c3 --- /dev/null +++ b/src/game/blackjack.js @@ -0,0 +1,284 @@ +// /game/blackjack.js +// Core blackjack helpers for a single continuous room. +// Inspired by your poker helpers API style. + +export const RANKS = ["A","2","3","4","5","6","7","8","9","T","J","Q","K"]; +export const SUITS = ["d","s","c","h"]; + +// Build a single 52-card deck like "Ad","Ts", etc. +export const singleDeck = RANKS.flatMap(r => SUITS.map(s => `${r}${s}`)); + +export function buildShoe(decks = 6) { + const shoe = []; + for (let i = 0; i < decks; i++) shoe.push(...singleDeck); + return shuffle(shoe); +} + +export function shuffle(arr) { + // Fisher–Yates + const a = [...arr]; + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + +// Draw one card from the shoe; if empty, caller should reshuffle at end of round. +export function draw(shoe) { + return shoe.pop(); +} + +// Return an object describing the best value of a hand with flexible Aces. +export function handValue(cards) { + // Count with all aces as 11, then reduce as needed + let total = 0; + let aces = 0; + for (const c of cards) { + const r = c[0]; + if (r === "A") { total += 11; aces += 1; } + else if (r === "T" || r === "J" || r === "Q" || r === "K") total += 10; + else total += Number(r); + } + while (total > 21 && aces > 0) { + total -= 10; // convert an Ace from 11 to 1 + aces -= 1; + } + const soft = (aces > 0); // if any Ace still counted as 11, it's a soft hand + return { total, soft }; +} + +export function isBlackjack(cards) { + return cards.length === 2 && handValue(cards).total === 21; +} + +export function isBust(cards) { + return handValue(cards).total > 21; +} + +// Dealer draw rule. By default, dealer stands on soft 17 (S17). +export function dealerShouldHit(dealerCards, hitSoft17 = false) { + const v = handValue(dealerCards); + if (v.total < 17) return true; + if (v.total === 17 && v.soft && hitSoft17) return true; + return false; +} + +// Compare a player hand to dealer and return outcome. +export function compareHands(playerCards, dealerCards) { + const pv = handValue(playerCards).total; + const dv = handValue(dealerCards).total; + if (pv > 21) return "lose"; + if (dv > 21) return "win"; + if (pv > dv) return "win"; + if (pv < dv) return "lose"; + return "push"; +} + +// Compute payout for a single finished hand (no splits here). +// options: { blackjackPayout: 1.5, allowSurrender: false } +export function settleHand({ bet, playerCards, dealerCards, doubled = false, surrendered = false, blackjackPayout = 1.5 }) { + if (surrendered) return { delta: -bet / 2, result: "surrender" }; + + const pBJ = isBlackjack(playerCards); + const dBJ = isBlackjack(dealerCards); + + if (pBJ && !dBJ) return { delta: bet * blackjackPayout, result: "blackjack" }; + if (!pBJ && dBJ) return { delta: -bet, result: "lose" }; + if (pBJ && dBJ) return { delta: 0, result: "push" }; + + const outcome = compareHands(playerCards, dealerCards); + let unit = bet * (doubled ? 2 : 1); + if (outcome === "win") return { delta: unit, result: "win" }; + if (outcome === "lose") return { delta: -unit, result: "lose" }; + return { delta: 0, result: "push" }; +} + +// Helper to decide if doubling is still allowed (first decision, 2 cards, not hit yet). +export function canDouble(hand) { + return hand.cards.length === 2 && !hand.hasActed; +} + +// Very small utility to format a public-safe snapshot of room state +export function publicPlayerView(player) { + // Hide hole cards until dealer reveal is fine for dealer only; player cards are visible. + return { + id: player.id, + globalName: player.globalName, + avatar: player.avatar, + bank: player.bank, + currentBet: player.currentBet, + inRound: player.inRound, + hands: player.hands.map(h => ({ + cards: h.cards, + stood: h.stood, + busted: h.busted, + doubled: h.doubled, + surrendered: h.surrendered, + result: h.result ?? null, + total: handValue(h.cards).total, + soft: handValue(h.cards).soft, + })), + }; +} + +// Build initial room object +export function createBlackjackRoom({ + minBet = 10, + maxBet = 1000, + fakeMoney = false, + decks = 6, + hitSoft17 = false, + blackjackPayout = 1.5, + cutCardRatio = 0.25, // reshuffle when 25% of shoe remains + phaseDurations = { bettingMs: 15000, dealMs: 1000, playMsPerPlayer: 15000, revealMs: 1000, payoutMs: 2000 } +} = {}) { + return { + id: "blackjack-room", + name: "Blackjack", + created_at: Date.now(), + status: "betting", // betting | dealing | playing | dealer | payout | shuffle + phase_ends_at: Date.now() + phaseDurations.bettingMs, + minBet, maxBet, fakeMoney, + settings: { decks, hitSoft17, blackjackPayout, cutCardRatio, phaseDurations }, + shoe: buildShoe(decks), + discard: [], + dealer: { cards: [], holeHidden: true }, + players: {}, // userId -> { id, globalName, avatar, bank, currentBet, inRound, hands: [{cards, stood, busted, doubled, surrendered, hasActed}], activeHand: 0 } + leavingAfterRound: {}, + }; +} + +// Reshuffle at start of the next round if the shoe is low +export function needsReshuffle(room) { + return room.shoe.length < singleDeck.length * room.settings.decks * room.settings.cutCardRatio; +} + +// --- Round Lifecycle helpers --- + +export function resetForNewRound(room) { + room.status = "betting"; + room.dealer = { cards: [], holeHidden: true }; + room.leavingAfterRound = {}; + // Clear per-round attributes on players, but keep bank and presence + for (const p of Object.values(room.players)) { + p.inRound = false; + p.currentBet = 0; + p.hands = [ { cards: [], stood: false, busted: false, doubled: false, surrendered: false, hasActed: false } ]; + p.activeHand = 0; + } +} + +export function startBetting(room, now) { + resetForNewRound(room); + if (needsReshuffle(room)) { + room.status = "shuffle"; + // quick shuffle animation phase + room.shoe = buildShoe(room.settings.decks); + } + room.status = "betting"; + room.phase_ends_at = now + room.settings.phaseDurations.bettingMs; +} + +export function dealInitial(room) { + room.status = "dealing"; + // Deal one to each player who placed a bet, then again, then dealer up + hole + const actives = Object.values(room.players).filter(p => p.currentBet >= room.minBet); + for (const p of actives) { + p.inRound = true; + p.hands = [ { cards: [draw(room.shoe)], stood: false, busted: false, doubled: false, surrendered: false, hasActed: false } ]; + } + room.dealer.cards = [draw(room.shoe), draw(room.shoe)]; + room.dealer.holeHidden = true; + for (const p of actives) { + p.hands[0].cards.push(draw(room.shoe)); + } + room.status = "playing"; +} + +export function autoActions(room) { + // Auto-stand if player already blackjack + for (const p of Object.values(room.players)) { + if (!p.inRound) continue; + const h = p.hands[p.activeHand]; + if (isBlackjack(h.cards)) { + h.stood = true; + h.hasActed = true; + } + } +} + +export function everyoneDone(room) { + return Object.values(room.players).every(p => { + if (!p.inRound) return true; + const h = p.hands[p.activeHand]; + return h.stood || h.busted || isBlackjack(h.cards) || h.surrendered; + }); +} + +export function dealerPlay(room) { + room.status = "dealer"; + room.dealer.holeHidden = false; + while (dealerShouldHit(room.dealer.cards, room.settings.hitSoft17)) { + room.dealer.cards.push(draw(room.shoe)); + } +} + +export function settleAll(room) { + room.status = "payout"; + for (const p of Object.values(room.players)) { + if (!p.inRound) continue; + const hand = p.hands[p.activeHand]; + const res = settleHand({ + bet: p.currentBet, + playerCards: hand.cards, + dealerCards: room.dealer.cards, + doubled: hand.doubled, + surrendered: hand.surrendered, + blackjackPayout: room.settings.blackjackPayout, + }); + hand.result = res.result; + hand.delta = res.delta; + } +} + +// Apply a player decision; returns a string event or throws on invalid. +export function applyAction(room, playerId, action) { + const p = room.players[playerId]; + if (!p || !p.inRound || room.status !== "playing") throw new Error("Not allowed"); + const hand = p.hands[p.activeHand]; + + switch (action) { + case "hit": { + if (hand.stood || hand.busted) throw new Error("Already ended"); + hand.hasActed = true; + hand.cards.push(draw(room.shoe)); + if (isBust(hand.cards)) hand.busted = true; + return "hit"; + } + case "stand": { + hand.stood = true; + hand.hasActed = true; + return "stand"; + } + case "double": { + if (!canDouble(hand)) throw new Error("Cannot double now"); + hand.doubled = true; + hand.hasActed = true; + // The caller (routes) must also handle additional balance lock on the bet if using real coins + hand.cards.push(draw(room.shoe)); + if (isBust(hand.cards)) hand.busted = true; + else hand.stood = true; + return "double"; + } + case "surrender": { + if (hand.cards.length !== 2 || hand.hasActed) throw new Error("Cannot surrender now"); + hand.surrendered = true; + hand.stood = true; + hand.hasActed = true; + return "surrender"; + } + default: + throw new Error("Invalid action"); + } +} \ No newline at end of file diff --git a/src/game/state.js b/src/game/state.js index abec360..b71b53b 100644 --- a/src/game/state.js +++ b/src/game/state.js @@ -53,6 +53,8 @@ export let tictactoeQueue = []; // Stores user IDs waiting to play Connect 4. export let connect4Queue = []; +export let queueMessagesEndpoints = []; + // --- Rate Limiting and Caching --- diff --git a/src/server/app.js b/src/server/app.js index 00a9119..afceae5 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -10,6 +10,7 @@ import { pokerRoutes } from './routes/poker.js'; import { solitaireRoutes } from './routes/solitaire.js'; import {getSocketIo} from "./socket.js"; import {erinyesRoutes} from "./routes/erinyes.js"; +import {blackjackRoutes} from "./routes/blackjack.js"; // --- EXPRESS APP INITIALIZATION --- const app = express(); @@ -50,6 +51,8 @@ app.use('/api/poker', pokerRoutes(client, io)); // Solitaire-specific routes app.use('/api/solitaire', solitaireRoutes(client, io)); +app.use('/api/blackjack', blackjackRoutes(client, io)); + // erinyes-specific routes app.use('/api/erinyes', erinyesRoutes(client, io)); diff --git a/src/server/routes/blackjack.js b/src/server/routes/blackjack.js new file mode 100644 index 0000000..7f3d295 --- /dev/null +++ b/src/server/routes/blackjack.js @@ -0,0 +1,210 @@ +// /routes/blackjack.js +import express from "express"; +import { createBlackjackRoom, startBetting, dealInitial, autoActions, everyoneDone, dealerPlay, settleAll, applyAction, publicPlayerView, handValue } from "../../game/blackjack.js"; + +// Optional: hook into your DB & Discord systems if available +import { getUser, updateUserCoins, insertLog } from "../../database/index.js"; +import { client } from "../../bot/client.js"; +import {emitToast, emitUpdate} from "../socket.js"; + +export function blackjackRoutes(io) { + const router = express.Router(); + + // --- Singleton continuous room --- + const room = createBlackjackRoom({ + minBet: 10, + maxBet: 5000, + fakeMoney: false, + decks: 6, + hitSoft17: false, // S17 (dealer stands on soft 17) if false + blackjackPayout: 1.5, // 3:2 + cutCardRatio: 0.25, + phaseDurations: { bettingMs: 15000, dealMs: 1000, playMsPerPlayer: 15000, revealMs: 1000, payoutMs: 2000 }, + }); + + function snapshot(r) { + return { + id: r.id, + name: r.name, + status: r.status, + phase_ends_at: r.phase_ends_at, + minBet: r.minBet, + maxBet: r.maxBet, + settings: r.settings, + dealer: { cards: r.dealer.holeHidden ? [r.dealer.cards[0], "XX"] : r.dealer.cards, total: r.dealer.holeHidden ? null : handValue(r.dealer.cards).total }, + players: Object.values(r.players).map(publicPlayerView), + shoeCount: r.shoe.length, + }; + } + + // --- Public endpoints --- + router.get("/", (req, res) => res.status(200).json({ room: snapshot(room) })); + + router.post("/join", async (req, res) => { + const { userId } = req.body; + if (!userId) return res.status(400).json({ message: "userId required" }); + if (room.players[userId]) return res.status(200).json({ message: "Already here" }); + + const user = await client.users.fetch(userId); + const bank = getUser.get(userId)?.coins ?? 0; + + room.players[userId] = { + id: userId, + globalName: user.globalName || user.username, + avatar: user.displayAvatarURL({ dynamic: true, size: 256 }), + bank, + currentBet: 0, + inRound: false, + hands: [{ cards: [], stood: false, busted: false, doubled: false, surrendered: false, hasActed: false }], + activeHand: 0, + joined_at: Date.now(), + }; + + emitUpdate("player-joined", snapshot(room)); + return res.status(200).json({ message: "joined" }); + }); + + router.post("/leave", (req, res) => { + const { userId } = req.body; + if (!userId || !room.players[userId]) return res.status(404).json({ message: "not in room" }); + + const p = room.players[userId]; + if (p.inRound) { + // leave after round to avoid abandoning an active bet + room.leavingAfterRound[userId] = true; + return res.status(200).json({ message: "will-leave-after-round" }); + } else { + delete room.players[userId]; + emitUpdate("player-left", snapshot(room)); + return res.status(200).json({ message: "left" }); + } + }); + + router.post("/bet", (req, res) => { + const { userId, amount } = req.body; + const p = room.players[userId]; + if (!p) return res.status(404).json({ message: "not in room" }); + if (room.status !== "betting") return res.status(403).json({ message: "betting-closed" }); + + const bet = Math.floor(Number(amount) || 0); + if (bet < room.minBet || bet > room.maxBet) return res.status(400).json({ message: "invalid-bet" }); + + if (!room.settings.fakeMoney) { + const userDB = getUser.get(userId); + const coins = userDB?.coins ?? 0; + if (coins < bet) return res.status(403).json({ message: "insufficient-funds" }); + updateUserCoins.run({ id: userId, coins: coins - bet }); + insertLog.run({ + id: `${userId}-blackjack-${Date.now()}`, + user_id: userId, target_user_id: null, + action: 'BLACKJACK_BET', + coins_amount: -bet, user_new_amount: coins - bet, + }); + p.bank = coins - bet; + } + + p.currentBet = bet; + emitToast({ type: "player-bet", userId, amount: bet }); + emitUpdate("bet-placed", snapshot(room)); + return res.status(200).json({ message: "bet-accepted" }); + }); + + router.post("/action/:action", (req, res) => { + const { userId } = req.body; + const action = req.params.action; + const p = room.players[userId]; + if (!p) return res.status(404).json({ message: "not in room" }); + if (!p.inRound || room.status !== "playing") return res.status(403).json({ message: "not-your-turn" }); + + // Handle extra coin lock for double + if (action === "double" && !room.settings.fakeMoney) { + const userDB = getUser.get(userId); + const coins = userDB?.coins ?? 0; + if (coins < p.currentBet) return res.status(403).json({ message: "insufficient-funds-for-double" }); + updateUserCoins.run({ id: userId, coins: coins - p.currentBet }); + insertLog.run({ + id: `${userId}-blackjack-${Date.now()}`, + user_id: userId, target_user_id: null, + action: 'BLACKJACK_DOUBLE', + coins_amount: -p.currentBet, user_new_amount: coins - p.currentBet, + }); + p.bank = coins - p.currentBet; + // effective bet size is handled in settlement via hand.doubled flag + } + + try { + const evt = applyAction(room, userId, action); + emitToast({ type: `player-${evt}`, userId }); + emitUpdate("player-action", snapshot(room)); + return res.status(200).json({ message: "ok" }); + } catch (e) { + return res.status(400).json({ message: e.message }); + } + }); + + // --- Game loop --- + // Simple phase machine that runs regardless of player count. + setInterval(() => { + const now = Date.now(); + + if (room.status === "betting" && now >= room.phase_ends_at) { + const hasBets = Object.values(room.players).some(p => p.currentBet >= room.minBet); + if (!hasBets) { + // Extend betting window if no one bet + room.phase_ends_at = now + room.settings.phaseDurations.bettingMs; + emitUpdate("betting-extend", snapshot(room)); + return; + } + dealInitial(room); + autoActions(room); + emitUpdate("initial-deal", snapshot(room)); + } + + if (room.status === "playing") { + // When all active players are done, proceed to dealer play + if (everyoneDone(room)) { + dealerPlay(room); + emitUpdate("dealer-start", snapshot(room)); + } + } + + if (room.status === "dealer") { + settleAll(room); + + // Apply coin deltas + for (const p of Object.values(room.players)) { + if (!p.inRound) continue; + const h = p.hands[p.activeHand]; + if (room.settings.fakeMoney) continue; + if (typeof h.delta === "number" && h.delta !== 0) { + const userDB = getUser.get(p.id); + if (userDB) { + updateUserCoins.run({ id: p.id, coins: userDB.coins + h.delta }); + insertLog.run({ + id: `${p.id}-blackjack-${Date.now()}`, + user_id: p.id, target_user_id: null, + action: `BLACKJACK_${h.delta > 0 ? "WIN" : "LOSE"}`, + coins_amount: h.delta, user_new_amount: userDB.coins + h.delta, + }); + } + } + } + + room.phase_ends_at = now + room.settings.phaseDurations.payoutMs; + emitUpdate("payout", snapshot(room)); + room.status = "payout"; + } + + if (room.status === "payout" && now >= room.phase_ends_at) { + // Remove leavers + for (const userId of Object.keys(room.leavingAfterRound)) { + delete room.players[userId]; + } + // Prepare next round + startBetting(room, now); + emitUpdate("new-round", snapshot(room)); + } + }, 400); + + return router; +} \ No newline at end of file diff --git a/src/server/socket.js b/src/server/socket.js index 6e39341..c5fa0db 100644 --- a/src/server/socket.js +++ b/src/server/socket.js @@ -1,5 +1,11 @@ import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; -import { activeTicTacToeGames, tictactoeQueue, activeConnect4Games, connect4Queue } from '../game/state.js'; +import { + activeTicTacToeGames, + tictactoeQueue, + activeConnect4Games, + connect4Queue, + queueMessagesEndpoints, activePredis +} from '../game/state.js'; import { createConnect4Board, formatConnect4BoardForDiscord, checkConnect4Win, checkConnect4Draw, C4_ROWS } from '../game/various.js'; import { eloHandler } from '../game/elo.js'; import { getUser } from "../database/index.js"; @@ -21,6 +27,14 @@ export function initializeSocket(server, client) { registerTicTacToeEvents(socket, client); registerConnect4Events(socket, client); + socket.on('tictactoe:queue:leave', async ({ discordId }) => await refreshQueuesForUser(discordId, client)); + + // catch tab kills / network drops + socket.on('disconnecting', async () => { + const discordId = socket.handshake.auth?.discordId; // or your mapping + await refreshQueuesForUser(discordId, client); + }); + socket.on('disconnect', () => { // }); @@ -211,10 +225,34 @@ async function createGame(client, gameType) { async function refreshQueuesForUser(userId, client) { // FIX: Mutate the array instead of reassigning it. let index = tictactoeQueue.indexOf(userId); - if (index > -1) tictactoeQueue.splice(index, 1); + if (index > -1) { + tictactoeQueue.splice(index, 1); + try { + const generalChannel = await client.channels.fetch(process.env.GENERAL_CHANNEL_ID); + const user = await client.users.fetch(userId); + const queueMsg = await generalChannel.messages.fetch(queueMessagesEndpoints[userId]) + const updatedEmbed = new EmbedBuilder().setTitle('Tic Tac Toe').setDescription(`**${user.globalName || user.username}** a quitté la file d'attente.`).setColor(0xED4245).setTimestamp(new Date()); + await queueMsg.edit({ embeds: [updatedEmbed], components: [] }); + delete queueMessagesEndpoints[userId]; + } catch (e) { + console.error('Error updating queue message : ', e); + } + } index = connect4Queue.indexOf(userId); - if (index > -1) connect4Queue.splice(index, 1); + if (index > -1) { + connect4Queue.splice(index, 1); + try { + const generalChannel = await client.channels.fetch(process.env.GENERAL_CHANNEL_ID); + const user = await client.users.fetch(userId); + const queueMsg = await generalChannel.messages.fetch(queueMessagesEndpoints[userId]) + const updatedEmbed = new EmbedBuilder().setTitle('Puissance 4').setDescription(`**${user.globalName || user.username}** a quitté la file d'attente.`).setColor(0xED4245).setTimestamp(new Date()); + await queueMsg.edit({ embeds: [updatedEmbed], components: [] }); + delete queueMessagesEndpoints[userId]; + } catch (e) { + console.error('Error updating queue message : ', e); + } + } await emitQueueUpdate(client, 'tictactoe'); await emitQueueUpdate(client, 'connect4'); @@ -239,9 +277,10 @@ async function postQueueToDiscord(client, playerId, title, url) { try { const generalChannel = await client.channels.fetch(process.env.GENERAL_CHANNEL_ID); const user = await client.users.fetch(playerId); - const embed = new EmbedBuilder().setTitle(title).setDescription(`**${user.globalName || user.username}** est dans la file d'attente.`).setColor('#5865F2'); + const embed = new EmbedBuilder().setTitle(title).setDescription(`**${user.globalName || user.username}** est dans la file d'attente.`).setColor('#5865F2').setTimestamp(new Date()); const row = new ActionRowBuilder().addComponents(new ButtonBuilder().setLabel(`Jouer contre ${user.username}`).setURL(`${process.env.DEV_SITE === 'true' ? process.env.FLAPI_URL_DEV : process.env.FLAPI_URL}${url}`).setStyle(ButtonStyle.Link)); - await generalChannel.send({ embeds: [embed], components: [row] }); + const msg = await generalChannel.send({ embeds: [embed], components: [row] }); + queueMessagesEndpoints[playerId] = msg.id } catch (e) { console.error(`Failed to post queue message for ${title}:`, e); } } @@ -302,4 +341,7 @@ export async function emitPokerUpdate(data) { export async function emitPokerToast(data) { io.emit('poker-toast', data); -} \ No newline at end of file +} + +export const emitUpdate = (type, room) => io.emit("blackjack:update", { type, room }); +export const emitToast = (payload) => io.emit("blackjack:toast", payload); \ No newline at end of file diff --git a/src/utils/index.js b/src/utils/index.js index c7ccb05..37a2eb6 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -209,7 +209,7 @@ export async function getOnlineUsersWithRole(guild, roleId) { if (!guild || !roleId) return new Map(); try { const members = await guild.members.fetch(); - return members.filter(m => !m.user.bot && m.presence?.status !== 'offline' && m.roles.cache.has(roleId)); + return members.filter(m => !m.user.bot && m.presence?.status !== 'offline' && m.presence?.status !== undefined && m.roles.cache.has(roleId)); } catch (err) { console.error('Error fetching online members with role:', err); return new Map(); From 65073776ec9500a5c21cad840d9753f75b7ede4b Mon Sep 17 00:00:00 2001 From: milo Date: Fri, 12 Sep 2025 14:53:52 +0200 Subject: [PATCH 2/2] blackjack beta 1 --- src/bot/events.js | 2 +- src/game/blackjack.js | 38 +++++++++- src/server/app.js | 2 +- src/server/routes/blackjack.js | 123 ++++++++++++++++++++++++--------- 4 files changed, 126 insertions(+), 39 deletions(-) diff --git a/src/bot/events.js b/src/bot/events.js index 9c2dacb..45c4fe9 100644 --- a/src/bot/events.js +++ b/src/bot/events.js @@ -12,7 +12,7 @@ export function initializeEvents(client, io) { // --- on 'ready' --- // This event fires once the bot has successfully logged in and is ready to operate. // It's a good place for setup tasks that require the bot to be online. - client.once('ready', async () => { + client.once('clientReady', async () => { console.log(`Bot is ready and logged in as ${client.user.tag}!`); console.log('[Startup] Bot is ready, performing initial data sync...'); await getAkhys(client); diff --git a/src/game/blackjack.js b/src/game/blackjack.js index 7d339c3..a37adf0 100644 --- a/src/game/blackjack.js +++ b/src/game/blackjack.js @@ -2,6 +2,9 @@ // Core blackjack helpers for a single continuous room. // Inspired by your poker helpers API style. +import {emitToast} from "../server/socket.js"; +import {getUser, insertLog, updateUserCoins} from "../database/index.js"; + export const RANKS = ["A","2","3","4","5","6","7","8","9","T","J","Q","K"]; export const SUITS = ["d","s","c","h"]; @@ -88,7 +91,7 @@ export function settleHand({ bet, playerCards, dealerCards, doubled = false, sur if (pBJ && dBJ) return { delta: 0, result: "push" }; const outcome = compareHands(playerCards, dealerCards); - let unit = bet * (doubled ? 2 : 1); + let unit = bet; if (outcome === "win") return { delta: unit, result: "win" }; if (outcome === "lose") return { delta: -unit, result: "lose" }; return { delta: 0, result: "push" }; @@ -131,7 +134,16 @@ export function createBlackjackRoom({ hitSoft17 = false, blackjackPayout = 1.5, cutCardRatio = 0.25, // reshuffle when 25% of shoe remains - phaseDurations = { bettingMs: 15000, dealMs: 1000, playMsPerPlayer: 15000, revealMs: 1000, payoutMs: 2000 } + phaseDurations = { + bettingMs: 15000, + dealMs: 1000, + playMsPerPlayer: 15000, + revealMs: 1000, + payoutMs: 10000, + }, + animation = { + dealerDrawMs: 500, + } } = {}) { return { id: "blackjack-room", @@ -140,7 +152,7 @@ export function createBlackjackRoom({ status: "betting", // betting | dealing | playing | dealer | payout | shuffle phase_ends_at: Date.now() + phaseDurations.bettingMs, minBet, maxBet, fakeMoney, - settings: { decks, hitSoft17, blackjackPayout, cutCardRatio, phaseDurations }, + settings: { decks, hitSoft17, blackjackPayout, cutCardRatio, phaseDurations, animation }, shoe: buildShoe(decks), discard: [], dealer: { cards: [], holeHidden: true }, @@ -226,6 +238,7 @@ export function dealerPlay(room) { export function settleAll(room) { room.status = "payout"; + const allRes = {} for (const p of Object.values(room.players)) { if (!p.inRound) continue; const hand = p.hands[p.activeHand]; @@ -237,6 +250,24 @@ export function settleAll(room) { surrendered: hand.surrendered, blackjackPayout: room.settings.blackjackPayout, }); + allRes[p.id] = res; + if (res.result === 'win' || res.result === 'push') { + const userDB = getUser.get(p.id); + if (userDB) { + try { + updateUserCoins.run({ id: p.id, coins: userDB.coins + p.currentBet + res.delta }); + insertLog.run({ + id: `${p.id}-blackjack-${Date.now()}`, + user_id: p.id, target_user_id: null, + action: 'BLACKJACK_PAYOUT', + coins_amount: res.delta + p.currentBet, user_new_amount: userDB.coins + p.currentBet + res.delta, + }); + } catch (e) { + console.log(e) + } + } + } + emitToast({ type: `payout-res`, allRes }); hand.result = res.result; hand.delta = res.delta; } @@ -264,6 +295,7 @@ export function applyAction(room, playerId, action) { case "double": { if (!canDouble(hand)) throw new Error("Cannot double now"); hand.doubled = true; + p.currentBet*=2 hand.hasActed = true; // The caller (routes) must also handle additional balance lock on the bet if using real coins hand.cards.push(draw(room.shoe)); diff --git a/src/server/app.js b/src/server/app.js index afceae5..897b277 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -22,7 +22,7 @@ const FLAPI_URL = process.env.DEV_SITE === 'true' ? process.env.FLAPI_URL_DEV : // CORS Middleware app.use((req, res, next) => { res.header('Access-Control-Allow-Origin', FLAPI_URL); - res.header('Access-Control-Allow-Headers', 'Content-Type, X-API-Key, ngrok-skip-browser-warning'); + res.header('Access-Control-Allow-Headers', 'Content-Type, X-API-Key, ngrok-skip-browser-warning, Cache-Control, Pragma, Expires'); next(); }); diff --git a/src/server/routes/blackjack.js b/src/server/routes/blackjack.js index 7f3d295..6571950 100644 --- a/src/server/routes/blackjack.js +++ b/src/server/routes/blackjack.js @@ -1,6 +1,18 @@ // /routes/blackjack.js import express from "express"; -import { createBlackjackRoom, startBetting, dealInitial, autoActions, everyoneDone, dealerPlay, settleAll, applyAction, publicPlayerView, handValue } from "../../game/blackjack.js"; +import { + createBlackjackRoom, + startBetting, + dealInitial, + autoActions, + everyoneDone, + dealerPlay, + settleAll, + applyAction, + publicPlayerView, + handValue, + dealerShouldHit, draw +} from "../../game/blackjack.js"; // Optional: hook into your DB & Discord systems if available import { getUser, updateUserCoins, insertLog } from "../../database/index.js"; @@ -19,9 +31,65 @@ export function blackjackRoutes(io) { hitSoft17: false, // S17 (dealer stands on soft 17) if false blackjackPayout: 1.5, // 3:2 cutCardRatio: 0.25, - phaseDurations: { bettingMs: 15000, dealMs: 1000, playMsPerPlayer: 15000, revealMs: 1000, payoutMs: 2000 }, + phaseDurations: { bettingMs: 10000, dealMs: 2000, playMsPerPlayer: 15000, revealMs: 1000, payoutMs: 7000 }, + animation: { dealerDrawMs: 1000 } }); + const sleep = (ms) => new Promise(res => setTimeout(res, ms)); + let animatingDealer = false; + + async function runDealerAnimation() { + if (animatingDealer) return; + animatingDealer = true; + + room.status = "dealer"; + room.dealer.holeHidden = false; + await sleep(room.settings.phaseDurations.revealMs ?? 1000); + room.phase_ends_at = Date.now() + (room.settings.phaseDurations.revealMs ?? 1000); + emitUpdate("dealer-reveal", snapshot(room)); + await sleep(room.settings.phaseDurations.revealMs ?? 1000); + + while (dealerShouldHit(room.dealer.cards, room.settings.hitSoft17)) { + room.dealer.cards.push(draw(room.shoe)); + room.phase_ends_at = Date.now() + (room.settings.animation?.dealerDrawMs ?? 500); + emitUpdate("dealer-hit", snapshot(room)); + await sleep(room.settings.animation?.dealerDrawMs ?? 500); + } + + settleAll(room); + room.status = "payout"; + room.phase_ends_at = Date.now() + (room.settings.phaseDurations.payoutMs ?? 10000); + emitUpdate("payout", snapshot(room)) + + animatingDealer = false; + } + + function autoTimeoutAFK(now) { + if (room.status !== "playing") return false; + if (!room.phase_ends_at || now < room.phase_ends_at) return false; + + let changed = false; + for (const p of Object.values(room.players)) { + if (!p.inRound) continue; + const h = p.hands[p.activeHand]; + if (!h.hasActed && !h.busted && !h.stood && !h.surrendered) { + h.surrendered = true; + h.stood = true; + h.hasActed = true; + room.leavingAfterRound[p.id] = true; // kick at end of round + emitToast({ type: "player-timeout", userId: p.id }); + changed = true; + } else if (h.hasActed && !h.stood) { + h.stood = true; + room.leavingAfterRound[p.id] = true; // kick at end of round + emitToast({ type: "player-auto-stand", userId: p.id }); + changed = true; + } + } + if (changed) emitUpdate("auto-surrender", snapshot(room)); + return changed; + } + function snapshot(r) { return { id: r.id, @@ -144,7 +212,7 @@ export function blackjackRoutes(io) { // --- Game loop --- // Simple phase machine that runs regardless of player count. - setInterval(() => { + setInterval(async () => { const now = Date.now(); if (room.status === "betting" && now >= room.phase_ends_at) { @@ -158,41 +226,28 @@ export function blackjackRoutes(io) { dealInitial(room); autoActions(room); emitUpdate("initial-deal", snapshot(room)); + + room.phase_ends_at = Date.now() + room.settings.phaseDurations.playMsPerPlayer; + emitUpdate("playing-start", snapshot(room)); + return; } if (room.status === "playing") { - // When all active players are done, proceed to dealer play - if (everyoneDone(room)) { - dealerPlay(room); - emitUpdate("dealer-start", snapshot(room)); - } - } - - if (room.status === "dealer") { - settleAll(room); - - // Apply coin deltas - for (const p of Object.values(room.players)) { - if (!p.inRound) continue; - const h = p.hands[p.activeHand]; - if (room.settings.fakeMoney) continue; - if (typeof h.delta === "number" && h.delta !== 0) { - const userDB = getUser.get(p.id); - if (userDB) { - updateUserCoins.run({ id: p.id, coins: userDB.coins + h.delta }); - insertLog.run({ - id: `${p.id}-blackjack-${Date.now()}`, - user_id: p.id, target_user_id: null, - action: `BLACKJACK_${h.delta > 0 ? "WIN" : "LOSE"}`, - coins_amount: h.delta, user_new_amount: userDB.coins + h.delta, - }); - } - } + // If the per-round playing timer expired, auto-surrender AFKs (you already added this) + if (room.phase_ends_at && now >= room.phase_ends_at) { + autoTimeoutAFK(now); } - room.phase_ends_at = now + room.settings.phaseDurations.payoutMs; - emitUpdate("payout", snapshot(room)); - room.status = "payout"; + // Everyone acted before the timer? Cut short and go straight to dealer. + if (everyoneDone(room) && !animatingDealer) { + // Set a new server-driven deadline for the reveal pause, + // so the client's countdown immediately reflects the phase change. + room.phase_ends_at = Date.now(); + emitUpdate("playing-cut-short", snapshot(room)); + + // Now run the animated dealer with per-step updates + runDealerAnimation(); + } } if (room.status === "payout" && now >= room.phase_ends_at) { @@ -204,7 +259,7 @@ export function blackjackRoutes(io) { startBetting(room, now); emitUpdate("new-round", snapshot(room)); } - }, 400); + }, 100); return router; } \ No newline at end of file