mirror of
https://github.com/cassoule/flopobot_v2.git
synced 2026-01-18 16:37:40 +01:00
2
index.js
2
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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
316
src/game/blackjack.js
Normal file
316
src/game/blackjack.js
Normal file
@@ -0,0 +1,316 @@
|
||||
// /game/blackjack.js
|
||||
// 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"];
|
||||
|
||||
// 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;
|
||||
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: 10000,
|
||||
},
|
||||
animation = {
|
||||
dealerDrawMs: 500,
|
||||
}
|
||||
} = {}) {
|
||||
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, animation },
|
||||
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";
|
||||
const allRes = {}
|
||||
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,
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
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));
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -21,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();
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
265
src/server/routes/blackjack.js
Normal file
265
src/server/routes/blackjack.js
Normal file
@@ -0,0 +1,265 @@
|
||||
// /routes/blackjack.js
|
||||
import express from "express";
|
||||
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";
|
||||
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: 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,
|
||||
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(async () => {
|
||||
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));
|
||||
|
||||
room.phase_ends_at = Date.now() + room.settings.phaseDurations.playMsPerPlayer;
|
||||
emitUpdate("playing-start", snapshot(room));
|
||||
return;
|
||||
}
|
||||
|
||||
if (room.status === "playing") {
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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));
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
export const emitUpdate = (type, room) => io.emit("blackjack:update", { type, room });
|
||||
export const emitToast = (payload) => io.emit("blackjack:toast", payload);
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user