Implement backend for blackjack game with Discord authentication, database integration, and WebSocket support
This commit is contained in:
23
.env.example
Normal file
23
.env.example
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
PORT=4000
|
||||||
|
CORS_ORIGIN=*
|
||||||
|
JWT_SECRET=valtoztasd_meg
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=blackjack
|
||||||
|
DB_PASSWORD=blackjack
|
||||||
|
DB_NAME=blackjack
|
||||||
|
|
||||||
|
DISCORD_CLIENT_ID=ide
|
||||||
|
DISCORD_CLIENT_SECRET=ide
|
||||||
|
DISCORD_REDIRECT_URI=http://localhost:4000/auth/discord/callback
|
||||||
|
DEFAULT_APP_REDIRECT=blackjack://auth
|
||||||
|
|
||||||
|
STRIPE_SECRET_KEY=sk_test_...
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||||
|
|
||||||
|
MIN_BET=10
|
||||||
|
MAX_BET=100
|
||||||
|
ROUND_START_DELAY_MS=3000
|
||||||
|
ROUND_RESET_DELAY_MS=5000
|
||||||
25
package.json
Normal file
25
package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "blackjack-backend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nodemon src/index.js",
|
||||||
|
"start": "node src/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"mysql2": "^3.10.0",
|
||||||
|
"stripe": "^16.2.0",
|
||||||
|
"ws": "^8.17.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
22
schema.sql
Normal file
22
schema.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
discord_id VARCHAR(32) NOT NULL UNIQUE,
|
||||||
|
username VARCHAR(100) NOT NULL,
|
||||||
|
avatar VARCHAR(128) NOT NULL DEFAULT '',
|
||||||
|
balance INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS deposits (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
amount INT NOT NULL,
|
||||||
|
stripe_payment_intent_id VARCHAR(128) NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'created',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_deposits_user_id FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_deposits_stripe ON deposits (stripe_payment_intent_id);
|
||||||
33
src/auth.js
Normal file
33
src/auth.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'dev_secret_change_me';
|
||||||
|
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
|
||||||
|
|
||||||
|
export function signToken(user) {
|
||||||
|
return jwt.sign(
|
||||||
|
{ sub: user.id, username: user.username },
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: JWT_EXPIRES_IN }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyToken(token) {
|
||||||
|
return jwt.verify(token, JWT_SECRET);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authMiddleware(req, res, next) {
|
||||||
|
const header = req.headers.authorization || '';
|
||||||
|
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({ error: 'Nincs hitelesites.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = jwt.verify(token, JWT_SECRET);
|
||||||
|
req.userId = Number(payload.sub);
|
||||||
|
req.username = payload.username;
|
||||||
|
return next();
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(401).json({ error: 'Ervenytelen token.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/db.js
Normal file
19
src/db.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import mysql from 'mysql2/promise';
|
||||||
|
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
port: Number(process.env.DB_PORT || 3306),
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
queueLimit: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function query(sql, params = []) {
|
||||||
|
const [rows] = await pool.execute(sql, params);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default pool;
|
||||||
51
src/discord.js
Normal file
51
src/discord.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { URLSearchParams } from 'url';
|
||||||
|
|
||||||
|
const DISCORD_API = 'https://discord.com/api';
|
||||||
|
|
||||||
|
export function getDiscordAuthUrl(state) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: process.env.DISCORD_CLIENT_ID,
|
||||||
|
redirect_uri: process.env.DISCORD_REDIRECT_URI,
|
||||||
|
response_type: 'code',
|
||||||
|
scope: 'identify',
|
||||||
|
state
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${DISCORD_API}/oauth2/authorize?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeCodeForToken(code) {
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
client_id: process.env.DISCORD_CLIENT_ID,
|
||||||
|
client_secret: process.env.DISCORD_CLIENT_SECRET,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
redirect_uri: process.env.DISCORD_REDIRECT_URI
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${DISCORD_API}/oauth2/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`Discord token hiba: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDiscordUser(accessToken) {
|
||||||
|
const response = await fetch(`${DISCORD_API}/users/@me`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`Discord user hiba: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
54
src/game/blackjack.js
Normal file
54
src/game/blackjack.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
const SUITS = ['S', 'H', 'D', 'C'];
|
||||||
|
const RANKS = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'];
|
||||||
|
|
||||||
|
export function createDeck() {
|
||||||
|
const deck = [];
|
||||||
|
for (const suit of SUITS) {
|
||||||
|
for (const rank of RANKS) {
|
||||||
|
deck.push({ rank, suit });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deck;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shuffle(deck) {
|
||||||
|
for (let i = deck.length - 1; i > 0; i -= 1) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[deck[i], deck[j]] = [deck[j], deck[i]];
|
||||||
|
}
|
||||||
|
return deck;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function draw(deck) {
|
||||||
|
return deck.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handValue(hand) {
|
||||||
|
let total = 0;
|
||||||
|
let aces = 0;
|
||||||
|
|
||||||
|
for (const card of hand) {
|
||||||
|
if (card.rank === 'A') {
|
||||||
|
total += 11;
|
||||||
|
aces += 1;
|
||||||
|
} else if (['J', 'Q', 'K'].includes(card.rank)) {
|
||||||
|
total += 10;
|
||||||
|
} else {
|
||||||
|
total += Number(card.rank);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (total > 21 && aces > 0) {
|
||||||
|
total -= 10;
|
||||||
|
aces -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
soft: aces > 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBlackjack(hand) {
|
||||||
|
return hand.length === 2 && handValue(hand).total === 21;
|
||||||
|
}
|
||||||
446
src/game/tables.js
Normal file
446
src/game/tables.js
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
import { createDeck, shuffle, draw, handValue, isBlackjack } from './blackjack.js';
|
||||||
|
import { query } from '../db.js';
|
||||||
|
|
||||||
|
const MIN_BET = Number(process.env.MIN_BET || 10);
|
||||||
|
const MAX_BET = Number(process.env.MAX_BET || 100);
|
||||||
|
const ROUND_START_DELAY_MS = Number(process.env.ROUND_START_DELAY_MS || 3000);
|
||||||
|
const ROUND_RESET_DELAY_MS = Number(process.env.ROUND_RESET_DELAY_MS || 5000);
|
||||||
|
|
||||||
|
class Table {
|
||||||
|
constructor(id, seatCount) {
|
||||||
|
this.id = id;
|
||||||
|
this.seatCount = seatCount;
|
||||||
|
this.seats = Array.from({ length: seatCount }, () => ({
|
||||||
|
userId: null,
|
||||||
|
username: null,
|
||||||
|
bet: 0,
|
||||||
|
hand: [],
|
||||||
|
status: 'empty',
|
||||||
|
ready: false,
|
||||||
|
result: null
|
||||||
|
}));
|
||||||
|
this.phase = 'waiting';
|
||||||
|
this.dealerHand = [];
|
||||||
|
this.currentSeatIndex = null;
|
||||||
|
this.deck = [];
|
||||||
|
this.roundId = 0;
|
||||||
|
this.roundTimeout = null;
|
||||||
|
this.resetTimeout = null;
|
||||||
|
this.clients = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot() {
|
||||||
|
const occupied = this.seats.filter((seat) => seat.userId).length;
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
seatCount: this.seatCount,
|
||||||
|
occupied,
|
||||||
|
phase: this.phase,
|
||||||
|
minBet: MIN_BET,
|
||||||
|
maxBet: MAX_BET
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
stateFor(userId) {
|
||||||
|
const dealerHand = this.dealerHand.map((card, index) => {
|
||||||
|
if (this.phase === 'playing' && index === 1) {
|
||||||
|
return { rank: 'X', suit: 'X', hidden: true };
|
||||||
|
}
|
||||||
|
return { ...card, hidden: false };
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
phase: this.phase,
|
||||||
|
minBet: MIN_BET,
|
||||||
|
maxBet: MAX_BET,
|
||||||
|
roundId: this.roundId,
|
||||||
|
dealerHand,
|
||||||
|
currentSeatIndex: this.currentSeatIndex,
|
||||||
|
seats: this.seats.map((seat, index) => ({
|
||||||
|
index,
|
||||||
|
username: seat.username,
|
||||||
|
bet: seat.bet,
|
||||||
|
hand: seat.hand,
|
||||||
|
status: seat.status,
|
||||||
|
ready: seat.ready,
|
||||||
|
result: seat.result,
|
||||||
|
isYou: seat.userId === userId
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
addClient(ws) {
|
||||||
|
this.clients.add(ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeClient(ws) {
|
||||||
|
this.clients.delete(ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast(payload, forUserId = null) {
|
||||||
|
const message = JSON.stringify(payload);
|
||||||
|
for (const client of this.clients) {
|
||||||
|
if (client.readyState !== 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (forUserId && client.user && client.user.id !== forUserId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
client.send(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastState() {
|
||||||
|
for (const client of this.clients) {
|
||||||
|
if (client.readyState !== 1 || !client.user) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
client.send(JSON.stringify({
|
||||||
|
type: 'table_state',
|
||||||
|
table: this.stateFor(client.user.id)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findSeatIndexByUser(userId) {
|
||||||
|
return this.seats.findIndex((seat) => seat.userId === userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async join(user) {
|
||||||
|
const existing = this.findSeatIndexByUser(user.id);
|
||||||
|
if (existing !== -1) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = this.seats.findIndex((seat) => !seat.userId);
|
||||||
|
if (index === -1) {
|
||||||
|
throw new Error('Nincs szabad hely az asztalnal.');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.seats[index] = {
|
||||||
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
bet: 0,
|
||||||
|
hand: [],
|
||||||
|
status: 'waiting',
|
||||||
|
ready: false,
|
||||||
|
result: null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.phase === 'waiting') {
|
||||||
|
this.phase = 'betting';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.broadcastState();
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
async leave(userId, reason = 'left') {
|
||||||
|
const index = this.findSeatIndexByUser(userId);
|
||||||
|
if (index === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seat = this.seats[index];
|
||||||
|
if (seat.bet > 0 && this.phase === 'playing') {
|
||||||
|
seat.result = { outcome: reason, payout: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.seats[index] = {
|
||||||
|
userId: null,
|
||||||
|
username: null,
|
||||||
|
bet: 0,
|
||||||
|
hand: [],
|
||||||
|
status: 'empty',
|
||||||
|
ready: false,
|
||||||
|
result: null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.currentSeatIndex === index) {
|
||||||
|
this.advanceTurn();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.seats.every((seat) => !seat.userId)) {
|
||||||
|
this.phase = 'waiting';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.broadcastState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async placeBet(userId, amount) {
|
||||||
|
if (this.phase === 'playing') {
|
||||||
|
throw new Error('A kor mar fut, varj a kovetkezo korig.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(amount) || amount < MIN_BET || amount > MAX_BET) {
|
||||||
|
throw new Error(`A tet ${MIN_BET}-${MAX_BET} Ft kozott lehet.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = this.findSeatIndexByUser(userId);
|
||||||
|
if (index === -1) {
|
||||||
|
throw new Error('Nem ulsz az asztalnal.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const balanceRows = await query('SELECT balance FROM users WHERE id = ?', [userId]);
|
||||||
|
const balance = balanceRows[0]?.balance ?? 0;
|
||||||
|
if (balance < amount) {
|
||||||
|
throw new Error('Nincs eleg egyenleged.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query('UPDATE users SET balance = balance - ? WHERE id = ?', [amount, userId]);
|
||||||
|
const updatedBalanceRows = await query('SELECT balance FROM users WHERE id = ?', [userId]);
|
||||||
|
const updatedBalance = updatedBalanceRows[0]?.balance ?? 0;
|
||||||
|
|
||||||
|
const seat = this.seats[index];
|
||||||
|
seat.bet = amount;
|
||||||
|
seat.ready = false;
|
||||||
|
seat.status = 'bet';
|
||||||
|
seat.result = null;
|
||||||
|
|
||||||
|
if (this.phase === 'waiting') {
|
||||||
|
this.phase = 'betting';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.broadcastState();
|
||||||
|
this.broadcast({ type: 'balance', balance: updatedBalance }, userId);
|
||||||
|
return amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
async readyUp(userId) {
|
||||||
|
const index = this.findSeatIndexByUser(userId);
|
||||||
|
if (index === -1) {
|
||||||
|
throw new Error('Nem ulsz az asztalnal.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const seat = this.seats[index];
|
||||||
|
if (seat.bet <= 0) {
|
||||||
|
throw new Error('Eloszor tegyel tetet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
seat.ready = true;
|
||||||
|
this.broadcastState();
|
||||||
|
this.scheduleRound();
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleRound() {
|
||||||
|
if (this.roundTimeout || this.phase === 'playing') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.roundTimeout = setTimeout(() => {
|
||||||
|
this.roundTimeout = null;
|
||||||
|
const hasBets = this.seats.some((seat) => seat.bet > 0);
|
||||||
|
if (!hasBets) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void this.startRound();
|
||||||
|
}, ROUND_START_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async startRound() {
|
||||||
|
if (this.phase === 'playing') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.phase = 'playing';
|
||||||
|
this.roundId += 1;
|
||||||
|
this.deck = shuffle(createDeck());
|
||||||
|
this.dealerHand = [draw(this.deck), draw(this.deck)];
|
||||||
|
|
||||||
|
for (const seat of this.seats) {
|
||||||
|
if (seat.bet > 0) {
|
||||||
|
seat.hand = [draw(this.deck), draw(this.deck)];
|
||||||
|
seat.status = isBlackjack(seat.hand) ? 'blackjack' : 'playing';
|
||||||
|
seat.ready = false;
|
||||||
|
} else if (seat.userId) {
|
||||||
|
seat.status = 'waiting';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentSeatIndex = this.nextPlayableSeatIndex(-1);
|
||||||
|
if (this.currentSeatIndex === null) {
|
||||||
|
await this.dealerTurn();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.broadcastState();
|
||||||
|
}
|
||||||
|
|
||||||
|
nextPlayableSeatIndex(fromIndex) {
|
||||||
|
for (let offset = 1; offset <= this.seatCount; offset += 1) {
|
||||||
|
const index = (fromIndex + offset) % this.seatCount;
|
||||||
|
const seat = this.seats[index];
|
||||||
|
if (seat.bet > 0 && seat.status === 'playing') {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleAction(userId, action) {
|
||||||
|
if (this.phase !== 'playing') {
|
||||||
|
throw new Error('Most nincs jatekban kor.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = this.findSeatIndexByUser(userId);
|
||||||
|
if (index !== this.currentSeatIndex) {
|
||||||
|
throw new Error('Nem te jossz.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const seat = this.seats[index];
|
||||||
|
if (seat.status !== 'playing') {
|
||||||
|
throw new Error('A kezed mar lezart.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'hit') {
|
||||||
|
seat.hand.push(draw(this.deck));
|
||||||
|
const value = handValue(seat.hand).total;
|
||||||
|
if (value > 21) {
|
||||||
|
seat.status = 'bust';
|
||||||
|
this.advanceTurn();
|
||||||
|
} else if (value === 21) {
|
||||||
|
seat.status = 'stood';
|
||||||
|
this.advanceTurn();
|
||||||
|
}
|
||||||
|
} else if (action === 'stand') {
|
||||||
|
seat.status = 'stood';
|
||||||
|
this.advanceTurn();
|
||||||
|
} else if (action === 'double') {
|
||||||
|
if (seat.hand.length !== 2) {
|
||||||
|
throw new Error('Duplazni csak az elso ket lap utan lehet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const balanceRows = await query('SELECT balance FROM users WHERE id = ?', [userId]);
|
||||||
|
const balance = balanceRows[0]?.balance ?? 0;
|
||||||
|
if (balance < seat.bet) {
|
||||||
|
throw new Error('Nincs eleg egyenleged a duplazashoz.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query('UPDATE users SET balance = balance - ? WHERE id = ?', [seat.bet, userId]);
|
||||||
|
const updatedBalanceRows = await query('SELECT balance FROM users WHERE id = ?', [userId]);
|
||||||
|
const updatedBalance = updatedBalanceRows[0]?.balance ?? 0;
|
||||||
|
seat.bet += seat.bet;
|
||||||
|
seat.hand.push(draw(this.deck));
|
||||||
|
seat.status = 'stood';
|
||||||
|
this.advanceTurn();
|
||||||
|
this.broadcast({ type: 'balance', balance: updatedBalance }, userId);
|
||||||
|
} else {
|
||||||
|
throw new Error('Ismeretlen akcio.');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.broadcastState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async advanceTurn() {
|
||||||
|
const nextIndex = this.nextPlayableSeatIndex(this.currentSeatIndex ?? -1);
|
||||||
|
if (nextIndex === null) {
|
||||||
|
await this.dealerTurn();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentSeatIndex = nextIndex;
|
||||||
|
this.broadcastState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async dealerTurn() {
|
||||||
|
this.phase = 'dealer';
|
||||||
|
this.currentSeatIndex = null;
|
||||||
|
|
||||||
|
let dealerValue = handValue(this.dealerHand);
|
||||||
|
while (dealerValue.total < 17) {
|
||||||
|
this.dealerHand.push(draw(this.deck));
|
||||||
|
dealerValue = handValue(this.dealerHand);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.payout();
|
||||||
|
}
|
||||||
|
|
||||||
|
async payout() {
|
||||||
|
const dealerValue = handValue(this.dealerHand).total;
|
||||||
|
const dealerBlackjack = isBlackjack(this.dealerHand);
|
||||||
|
const dealerBust = dealerValue > 21;
|
||||||
|
|
||||||
|
for (const seat of this.seats) {
|
||||||
|
if (!seat.userId || seat.bet <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerValue = handValue(seat.hand).total;
|
||||||
|
const playerBlackjack = isBlackjack(seat.hand);
|
||||||
|
let payout = 0;
|
||||||
|
let outcome = 'lose';
|
||||||
|
|
||||||
|
if (seat.status === 'bust') {
|
||||||
|
outcome = 'bust';
|
||||||
|
} else if (dealerBlackjack && playerBlackjack) {
|
||||||
|
payout = seat.bet;
|
||||||
|
outcome = 'push';
|
||||||
|
} else if (dealerBlackjack && !playerBlackjack) {
|
||||||
|
payout = 0;
|
||||||
|
outcome = 'lose';
|
||||||
|
} else if (playerBlackjack && !dealerBlackjack) {
|
||||||
|
payout = Math.floor(seat.bet * 2.5);
|
||||||
|
outcome = 'blackjack';
|
||||||
|
} else if (dealerBust) {
|
||||||
|
payout = seat.bet * 2;
|
||||||
|
outcome = 'win';
|
||||||
|
} else if (playerValue > dealerValue) {
|
||||||
|
payout = seat.bet * 2;
|
||||||
|
outcome = 'win';
|
||||||
|
} else if (playerValue === dealerValue) {
|
||||||
|
payout = seat.bet;
|
||||||
|
outcome = 'push';
|
||||||
|
}
|
||||||
|
|
||||||
|
seat.result = { outcome, payout };
|
||||||
|
if (payout > 0) {
|
||||||
|
await query('UPDATE users SET balance = balance + ? WHERE id = ?', [payout, seat.userId]);
|
||||||
|
}
|
||||||
|
const updatedBalanceRows = await query('SELECT balance FROM users WHERE id = ?', [seat.userId]);
|
||||||
|
const updatedBalance = updatedBalanceRows[0]?.balance ?? 0;
|
||||||
|
this.broadcast({ type: 'balance', balance: updatedBalance }, seat.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.phase = 'payout';
|
||||||
|
this.broadcastState();
|
||||||
|
|
||||||
|
if (this.resetTimeout) {
|
||||||
|
clearTimeout(this.resetTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resetTimeout = setTimeout(() => {
|
||||||
|
this.resetRound();
|
||||||
|
}, ROUND_RESET_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetRound() {
|
||||||
|
for (const seat of this.seats) {
|
||||||
|
if (!seat.userId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seat.bet = 0;
|
||||||
|
seat.hand = [];
|
||||||
|
seat.status = 'waiting';
|
||||||
|
seat.ready = false;
|
||||||
|
seat.result = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dealerHand = [];
|
||||||
|
this.currentSeatIndex = null;
|
||||||
|
this.phase = this.seats.some((seat) => seat.userId) ? 'betting' : 'waiting';
|
||||||
|
this.broadcastState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tables = Array.from({ length: 3 }, (_, index) => new Table(index + 1, 7));
|
||||||
|
|
||||||
|
export function listTables() {
|
||||||
|
return tables.map((table) => table.snapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTableById(id) {
|
||||||
|
return tables.find((table) => table.id === Number(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default tables;
|
||||||
39
src/index.js
Normal file
39
src/index.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
import express from 'express';
|
||||||
|
import http from 'http';
|
||||||
|
import cors from 'cors';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import morgan from 'morgan';
|
||||||
|
import { WebSocketServer } from 'ws';
|
||||||
|
import authRoutes from './routes/auth.js';
|
||||||
|
import lobbyRoutes from './routes/lobby.js';
|
||||||
|
import walletRoutes from './routes/wallet.js';
|
||||||
|
import stripeRoutes from './routes/stripe.js';
|
||||||
|
import { setupWebSocket } from './ws.js';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(helmet());
|
||||||
|
app.use(cors({ origin: process.env.CORS_ORIGIN || '*', credentials: true }));
|
||||||
|
app.use(morgan('dev'));
|
||||||
|
|
||||||
|
app.use(stripeRoutes);
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
app.get('/health', (req, res) => res.json({ ok: true }));
|
||||||
|
|
||||||
|
app.use(authRoutes);
|
||||||
|
app.use(lobbyRoutes);
|
||||||
|
app.use(walletRoutes);
|
||||||
|
|
||||||
|
const server = http.createServer(app);
|
||||||
|
const wss = new WebSocketServer({ server });
|
||||||
|
setupWebSocket(wss);
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT || 4000);
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`Backend fut: http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
80
src/routes/auth.js
Normal file
80
src/routes/auth.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { getDiscordAuthUrl, exchangeCodeForToken, fetchDiscordUser } from '../discord.js';
|
||||||
|
import { query } from '../db.js';
|
||||||
|
import { signToken } from '../auth.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const stateStore = new Map();
|
||||||
|
const STATE_TTL_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
function storeState(state, redirect) {
|
||||||
|
stateStore.set(state, { redirect, createdAt: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumeState(state) {
|
||||||
|
const entry = stateStore.get(state);
|
||||||
|
stateStore.delete(state);
|
||||||
|
if (!entry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (Date.now() - entry.createdAt > STATE_TTL_MS) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/auth/discord/url', (req, res) => {
|
||||||
|
const state = crypto.randomUUID();
|
||||||
|
const redirect = req.query.redirect?.toString() || process.env.DEFAULT_APP_REDIRECT || '';
|
||||||
|
storeState(state, redirect);
|
||||||
|
const url = getDiscordAuthUrl(state);
|
||||||
|
res.json({ url });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/auth/discord/callback', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { code, state } = req.query;
|
||||||
|
if (!code || !state) {
|
||||||
|
return res.status(400).send('Hibas visszairanyitas.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = consumeState(state.toString());
|
||||||
|
if (!entry) {
|
||||||
|
return res.status(400).send('Ervenytelen state.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenResponse = await exchangeCodeForToken(code.toString());
|
||||||
|
const discordUser = await fetchDiscordUser(tokenResponse.access_token);
|
||||||
|
|
||||||
|
const existing = await query('SELECT id FROM users WHERE discord_id = ?', [discordUser.id]);
|
||||||
|
let userId;
|
||||||
|
if (existing.length > 0) {
|
||||||
|
userId = existing[0].id;
|
||||||
|
await query('UPDATE users SET username = ?, avatar = ? WHERE id = ?', [
|
||||||
|
discordUser.username,
|
||||||
|
discordUser.avatar || '',
|
||||||
|
userId
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
const result = await query(
|
||||||
|
'INSERT INTO users (discord_id, username, avatar, balance) VALUES (?, ?, ?, ?)',
|
||||||
|
[discordUser.id, discordUser.username, discordUser.avatar || '', 0]
|
||||||
|
);
|
||||||
|
userId = result.insertId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = signToken({ id: userId, username: discordUser.username });
|
||||||
|
|
||||||
|
if (entry.redirect) {
|
||||||
|
const separator = entry.redirect.includes('?') ? '&' : '?';
|
||||||
|
return res.redirect(`${entry.redirect}${separator}token=${encodeURIComponent(token)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ token });
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).send('Hiba a bejelentkezes kozben.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
20
src/routes/lobby.js
Normal file
20
src/routes/lobby.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { authMiddleware } from '../auth.js';
|
||||||
|
import { query } from '../db.js';
|
||||||
|
import { listTables } from '../game/tables.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/api/me', authMiddleware, async (req, res) => {
|
||||||
|
const rows = await query('SELECT id, username, avatar, balance FROM users WHERE id = ?', [req.userId]);
|
||||||
|
if (!rows[0]) {
|
||||||
|
return res.status(404).json({ error: 'Nincs felhasznalo.' });
|
||||||
|
}
|
||||||
|
return res.json(rows[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/api/tables', authMiddleware, (req, res) => {
|
||||||
|
return res.json({ tables: listTables() });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
49
src/routes/stripe.js
Normal file
49
src/routes/stripe.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import express, { Router } from 'express';
|
||||||
|
import Stripe from 'stripe';
|
||||||
|
import { query } from '../db.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || '', {
|
||||||
|
apiVersion: '2024-06-20'
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/api/stripe/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
|
||||||
|
const signature = req.headers['stripe-signature'];
|
||||||
|
let event;
|
||||||
|
|
||||||
|
try {
|
||||||
|
event = stripe.webhooks.constructEvent(
|
||||||
|
req.body,
|
||||||
|
signature,
|
||||||
|
process.env.STRIPE_WEBHOOK_SECRET || ''
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(400).send('Webhook alairas hiba.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'payment_intent.succeeded') {
|
||||||
|
const intent = event.data.object;
|
||||||
|
const amount = Number(intent.amount || 0);
|
||||||
|
const userId = Number(intent.metadata?.userId || 0);
|
||||||
|
|
||||||
|
if (userId && amount) {
|
||||||
|
const rows = await query(
|
||||||
|
'SELECT status FROM deposits WHERE stripe_payment_intent_id = ?',
|
||||||
|
[intent.id]
|
||||||
|
);
|
||||||
|
const status = rows[0]?.status;
|
||||||
|
|
||||||
|
if (status !== 'succeeded') {
|
||||||
|
await query(
|
||||||
|
'UPDATE deposits SET status = ? WHERE stripe_payment_intent_id = ?',
|
||||||
|
['succeeded', intent.id]
|
||||||
|
);
|
||||||
|
await query('UPDATE users SET balance = balance + ? WHERE id = ?', [amount, userId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ received: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
38
src/routes/wallet.js
Normal file
38
src/routes/wallet.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import Stripe from 'stripe';
|
||||||
|
import { authMiddleware } from '../auth.js';
|
||||||
|
import { query } from '../db.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || '', {
|
||||||
|
apiVersion: '2024-06-20'
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/api/wallet/deposit-intent', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const amount = Number(req.body.amount);
|
||||||
|
if (!Number.isFinite(amount) || amount < 50 || amount > 100) {
|
||||||
|
return res.status(400).json({ error: 'A feltoltes 50 es 100 Ft kozott lehet.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentIntent = await stripe.paymentIntents.create({
|
||||||
|
amount,
|
||||||
|
currency: 'huf',
|
||||||
|
automatic_payment_methods: { enabled: true },
|
||||||
|
metadata: {
|
||||||
|
userId: String(req.userId)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await query(
|
||||||
|
'INSERT INTO deposits (user_id, amount, stripe_payment_intent_id, status) VALUES (?, ?, ?, ?)',
|
||||||
|
[req.userId, amount, paymentIntent.id, 'created']
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({ clientSecret: paymentIntent.client_secret });
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({ error: 'Nem sikerult letrehozni a fizetest.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
117
src/ws.js
Normal file
117
src/ws.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { verifyToken } from './auth.js';
|
||||||
|
import { query } from './db.js';
|
||||||
|
import { getTableById } from './game/tables.js';
|
||||||
|
|
||||||
|
function safeSend(ws, payload) {
|
||||||
|
if (ws.readyState === 1) {
|
||||||
|
ws.send(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupWebSocket(wss) {
|
||||||
|
wss.on('connection', (ws) => {
|
||||||
|
ws.user = null;
|
||||||
|
ws.tableId = null;
|
||||||
|
|
||||||
|
ws.on('message', async (data) => {
|
||||||
|
let message;
|
||||||
|
try {
|
||||||
|
message = JSON.parse(data.toString());
|
||||||
|
} catch (err) {
|
||||||
|
safeSend(ws, { type: 'error', message: 'Hibas uzenet formatum.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (message.type === 'hello') {
|
||||||
|
const payload = verifyToken(message.token);
|
||||||
|
const rows = await query('SELECT id, username, balance FROM users WHERE id = ?', [payload.sub]);
|
||||||
|
const user = rows[0];
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('Ismeretlen felhasznalo.');
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.user = { id: user.id, username: user.username };
|
||||||
|
safeSend(ws, { type: 'welcome', user: ws.user, balance: user.balance });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ws.user) {
|
||||||
|
throw new Error('Nincs hitelesites.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'join') {
|
||||||
|
const table = getTableById(message.tableId);
|
||||||
|
if (!table) {
|
||||||
|
throw new Error('Ismeretlen asztal.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ws.tableId && ws.tableId !== table.id) {
|
||||||
|
const previous = getTableById(ws.tableId);
|
||||||
|
if (previous) {
|
||||||
|
await previous.leave(ws.user.id, 'moved');
|
||||||
|
previous.removeClient(ws);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.tableId = table.id;
|
||||||
|
table.addClient(ws);
|
||||||
|
await table.join(ws.user);
|
||||||
|
safeSend(ws, { type: 'joined', tableId: table.id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'leave') {
|
||||||
|
if (ws.tableId) {
|
||||||
|
const table = getTableById(ws.tableId);
|
||||||
|
if (table) {
|
||||||
|
await table.leave(ws.user.id, 'left');
|
||||||
|
table.removeClient(ws);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ws.tableId = null;
|
||||||
|
safeSend(ws, { type: 'left' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ws.tableId) {
|
||||||
|
throw new Error('Nem vagy asztalnal.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = getTableById(ws.tableId);
|
||||||
|
if (!table) {
|
||||||
|
throw new Error('Ismeretlen asztal.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'bet') {
|
||||||
|
await table.placeBet(ws.user.id, Number(message.amount));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'ready') {
|
||||||
|
await table.readyUp(ws.user.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'action') {
|
||||||
|
await table.handleAction(ws.user.id, message.action);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
safeSend(ws, { type: 'error', message: 'Ismeretlen uzenet.' });
|
||||||
|
} catch (err) {
|
||||||
|
safeSend(ws, { type: 'error', message: err.message || 'Hiba tortent.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', async () => {
|
||||||
|
if (ws.tableId && ws.user) {
|
||||||
|
const table = getTableById(ws.tableId);
|
||||||
|
if (table) {
|
||||||
|
await table.leave(ws.user.id, 'disconnect');
|
||||||
|
table.removeClient(ws);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user