'use strict'; const express = require('express'); const http = require('http'); const https = require('https'); const { Server } = require('socket.io'); const path = require('path'); const fs = require('fs'); const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const app = express(); const httpServer = http.createServer(app); let httpsServer = null; const SSL_KEY = path.join(__dirname, 'ssl', 'key.pem'); const SSL_CERT = path.join(__dirname, 'ssl', 'cert.pem'); if (fs.existsSync(SSL_KEY) && fs.existsSync(SSL_CERT)) { httpsServer = https.createServer( { key: fs.readFileSync(SSL_KEY), cert: fs.readFileSync(SSL_CERT) }, app ); } const io = new Server(httpServer); if (httpsServer) io.attach(httpsServer); const JWT_SECRET = process.env.JWT_SECRET || 'shelem-secret-change-me'; const ADMIN_USERNAME = process.env.ADMIN_USERNAME || ''; const SHARED_USERS_FILE = process.env.SHARED_USERS_FILE || ''; const HTTP_PORT = parseInt(process.env.PORT || '4000'); const HTTPS_PORT = parseInt(process.env.HTTPS_PORT || '4443'); // ─── JSON file database ──────────────────────────────────────── const DATA_DIR = path.join(__dirname, 'data'); if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); const USERS_FILE = path.join(DATA_DIR, 'users.json'); const STATS_FILE = path.join(DATA_DIR, 'stats.json'); const CONFIG_FILE = path.join(DATA_DIR, 'config.json'); function readJSON(file, def) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return def; } } function writeJSON(file, data) { const tmp = file + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(data, null, 2)); fs.renameSync(tmp, file); } let users = readJSON(USERS_FILE, []); let stats = readJSON(STATS_FILE, []); let config = readJSON(CONFIG_FILE, { signupsOpen: true }); function saveUsers() { writeJSON(USERS_FILE, users); } function saveStats() { writeJSON(STATS_FILE, stats); } function saveConfig() { writeJSON(CONFIG_FILE, config); } function isAdmin(user) { return ADMIN_USERNAME && user && user.username === ADMIN_USERNAME; } let _nextId = users.length > 0 ? Math.max(...users.map(u => u.id)) + 1 : 1; function nextId() { return _nextId++; } function findUser(username) { const local = users.find(u => u.username.toLowerCase() === username.toLowerCase()); if (local) return local; if (SHARED_USERS_FILE) { try { const shared = readJSON(SHARED_USERS_FILE, []); const s = shared.find(u => u.username.toLowerCase() === username.toLowerCase()); if (s) return { ...s, _fromShared: true }; } catch { /* ignore */ } } return null; } function getStats(userId) { let s = stats.find(s => s.userId === userId); if (!s) { s = { userId, games_played: 0, games_won: 0, shelemCount: 0, total_score: 0 }; stats.push(s); saveStats(); } return s; } function addStats(userId, delta) { const s = getStats(userId); s.games_played = (s.games_played || 0) + (delta.games_played || 0); s.games_won = (s.games_won || 0) + (delta.games_won || 0); s.shelemCount = (s.shelemCount || 0) + (delta.shelemCount || 0); s.total_score = (s.total_score || 0) + (delta.total_score || 0); saveStats(); } function requireAuth(req, res, next) { const h = req.headers.authorization; if (!h?.startsWith('Bearer ')) return res.status(401).json({ error: 'Not authenticated' }); try { req.user = jwt.verify(h.slice(7), JWT_SECRET); next(); } catch { res.status(401).json({ error: 'Invalid or expired token' }); } } // ─── Middleware ──────────────────────────────────────────────── app.use(express.json()); app.use(express.static(path.join(__dirname, 'public'), { setHeaders: (res, filePath) => { if (/\.svg$/.test(filePath)) { res.setHeader('Cache-Control', 'public, max-age=2592000, immutable'); } else if (/\.(js|css|html)$/.test(filePath)) { res.setHeader('Cache-Control', 'no-cache'); } } })); // ─── Auth API ───────────────────────────────────────────────── app.get('/api/config', (_req, res) => { res.json({ signupsOpen: config.signupsOpen }); }); app.post('/api/register', (req, res) => { if (!config.signupsOpen) return res.status(403).json({ error: 'New registrations are currently closed.' }); const { username, password } = req.body || {}; if (!username || !password) return res.status(400).json({ error: 'Username and password are required' }); if (username.trim().length < 2 || username.trim().length > 16) return res.status(400).json({ error: 'Username must be 2–16 characters' }); if (password.length < 4) return res.status(400).json({ error: 'Password must be at least 4 characters' }); if (findUser(username)) return res.status(409).json({ error: 'Username already taken' }); const id = nextId(); users.push({ id, username: username.trim(), password: bcrypt.hashSync(password, 10) }); saveUsers(); getStats(id); const token = jwt.sign({ id, username: username.trim() }, JWT_SECRET, { expiresIn: '30d' }); res.json({ token, username: username.trim() }); }); app.post('/api/login', (req, res) => { const { username, password } = req.body || {}; if (!username || !password) return res.status(400).json({ error: 'Username and password required' }); const user = findUser(username); if (!user || !bcrypt.compareSync(password, user.password)) return res.status(401).json({ error: 'Invalid username or password' }); const effectiveId = user._fromShared ? `hokm_${user.id}` : user.id; const effectiveUsername = user.username; const token = jwt.sign({ id: effectiveId, username: effectiveUsername }, JWT_SECRET, { expiresIn: '30d' }); res.json({ token, username: effectiveUsername }); }); app.post('/api/change-password', requireAuth, (req, res) => { const { currentPassword, newPassword } = req.body || {}; if (!currentPassword || !newPassword) return res.status(400).json({ error: 'Both passwords required' }); if (newPassword.length < 4) return res.status(400).json({ error: 'New password must be at least 4 characters' }); const user = users.find(u => u.id === req.user.id); if (!user) return res.status(404).json({ error: 'User not found (shared accounts cannot change password here)' }); if (!bcrypt.compareSync(currentPassword, user.password)) return res.status(401).json({ error: 'Current password is incorrect' }); user.password = bcrypt.hashSync(newPassword, 10); saveUsers(); res.json({ ok: true }); }); app.get('/api/profile/:username', (req, res) => { const user = findUser(req.params.username); if (!user) return res.status(404).json({ error: 'User not found' }); const effectiveId = user._fromShared ? `hokm_${user.id}` : user.id; const s = getStats(effectiveId); res.json({ username: user.username, games_played: s.games_played || 0, games_won: s.games_won || 0, shelemCount: s.shelemCount || 0, total_score: s.total_score || 0, isAdmin: isAdmin(user), signupsOpen: config.signupsOpen, }); }); app.get('/api/leaderboard', (_req, res) => { const allUsers = [...users]; if (SHARED_USERS_FILE) { try { const shared = readJSON(SHARED_USERS_FILE, []); for (const su of shared) { if (!allUsers.find(u => u.username.toLowerCase() === su.username.toLowerCase())) allUsers.push({ ...su, _fromShared: true }); } } catch { /* ignore */ } } const rows = allUsers.map(u => { const eid = u._fromShared ? `hokm_${u.id}` : u.id; const s = getStats(eid); const played = s.games_played || 0; return { username: u.username, games_played: played, games_won: s.games_won || 0, shelemCount: s.shelemCount || 0, total_score: s.total_score || 0, score_per_game: played > 0 ? +((s.total_score || 0) / played).toFixed(1) : null, }; }) .filter(r => r.games_played > 0) .sort((a, b) => { if (a.score_per_game === null) return 1; if (b.score_per_game === null) return -1; return b.score_per_game - a.score_per_game || b.games_played - a.games_played; }) .slice(0, 30); res.json(rows); }); app.get('/api/rooms', (_req, res) => { const list = []; for (const [, room] of rooms) { if (!room.isPublic || room.state !== 'WAITING') continue; const playerCount = room.seats.filter(Boolean).length + room.bots.filter(Boolean).length; list.push({ id: room.id, playerCount, jokerMode: room.jokerMode, winScore: room.winScore, hostName: room.names[0] || '?', }); } res.json(list); }); app.post('/api/admin/toggle-signups', requireAuth, (req, res) => { if (!isAdmin(req.user)) return res.status(403).json({ error: 'Forbidden' }); config.signupsOpen = !config.signupsOpen; saveConfig(); res.json({ signupsOpen: config.signupsOpen }); }); // ═══════════════════════════════════════════════════════════════ // SHELEM GAME ENGINE // ═══════════════════════════════════════════════════════════════ const SUITS = ['C', 'D', 'H', 'S']; const RANKS = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A']; // rank index for non-joker cards: 2→0, A→12 const RANK_IDX = Object.fromEntries(RANKS.map((r, i) => [r, i])); function makeDeck(jokerMode) { const d = []; for (const s of SUITS) for (const r of RANKS) d.push(`${s}-${r}`); if (jokerMode) { d.push('JOKER-COLOR'); d.push('JOKER-BLACK'); } return d; } function shuffle(arr) { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } function suitOf(card) { if (card === 'JOKER-COLOR' || card === 'JOKER-BLACK') return 'JOKER'; return card.split('-')[0]; } function rankOf(card) { return card.split('-')[1]; } function cardPoints(card) { if (card === 'JOKER-COLOR') return 20; if (card === 'JOKER-BLACK') return 15; const r = rankOf(card); if (r === 'A') return 10; if (r === '10') return 10; if (r === '5') return 5; return 0; } function isJoker(card) { return card === 'JOKER-COLOR' || card === 'JOKER-BLACK'; } function isTrump(card, trump) { if (!trump) return false; return isJoker(card) || suitOf(card) === trump; } // Higher = stronger trump. JOKER-COLOR=100, JOKER-BLACK=99, A=12..2=0 function trumpRank(card) { if (card === 'JOKER-COLOR') return 100; if (card === 'JOKER-BLACK') return 99; return RANK_IDX[rankOf(card)]; } // Suit display order: C D H S JOKER const SUIT_ORD = { C: 0, D: 1, H: 2, S: 3, JOKER: 4 }; function sortCards(hand) { return [...hand].sort((a, b) => { const sa = SUIT_ORD[suitOf(a)], sb = SUIT_ORD[suitOf(b)]; if (sa !== sb) return sa - sb; return RANK_IDX[rankOf(a)] - RANK_IDX[rankOf(b)]; }); } function teamOf(seat) { return seat % 2; } // 0,2 → team 0 | 1,3 → team 1 function widowSize(room) { return room.jokerMode ? 6 : 4; } function discardCount(room){ return widowSize(room); } function trickWinner(trick, trump) { const trumpPlayed = trick.filter(t => isTrump(t.card, trump)); if (trumpPlayed.length > 0) { return trumpPlayed.reduce((best, t) => trumpRank(t.card) > trumpRank(best.card) ? t : best).player; } const ls = suitOf(trick[0].card); const led = trick.filter(t => suitOf(t.card) === ls); return led.reduce((best, t) => RANK_IDX[rankOf(t.card)] > RANK_IDX[rankOf(best.card)] ? t : best).player; } function legalCards(room, player) { const hand = room.hands[player]; const trick = room.trick; const trump = room.trump; if (trick.length === 0) { // First card of the hand sets trump — jokers not allowed as the opening lead if (trump === null) return hand.filter(c => !isJoker(c)); return hand; } const ledCard = trick[0].card; const ledTrump = isTrump(ledCard, trump); if (ledTrump) { // Trump led — must follow trump const trumpCards = hand.filter(c => isTrump(c, trump)); return trumpCards.length > 0 ? trumpCards : hand; } // Non-trump led — must follow suit (jokers are NOT the led suit) const ls = suitOf(ledCard); const suitCards = hand.filter(c => suitOf(c) === ls); return suitCards.length > 0 ? suitCards : hand; } function makeToken() { return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); } function newRoom(id) { return { id, isPublic: false, state: 'WAITING', names: ['', '', '', ''], seats: [null, null, null, null], tokens: [null, null, null, null], userIds: [null, null, null, null], bots: [false, false, false, false], hands: [[], [], [], []], widow: [], handNumber: 0, dealer: 0, // Bidding bids: [null, null, null, null], currentBidder: -1, highBid: 0, highBidder: -1, consecutivePasses: 0, // Widow / trump declarer: -1, discarded: [], trump: null, // Trick-play trick: [], trickLead: -1, currentTurn: -1, tricksPlayed: 0, trickWins: [0, 0, 0, 0], teamCardPoints: [0, 0], lastTrick: null, lastTrickWinner: -1, handDeltas: null, isShelemHand: false, gameShelemsCount: 0, // Accumulated game scores (per team) scores: [0, 0], gameWinner: null, // Options jokerMode: true, winScore: 505, spectators: new Set(), trickTimer: null, }; } function publicInfo(room, seat) { return { id: room.id, state: room.state, names: room.names, bots: room.bots, hands: room.hands.map((h, i) => i === seat ? h : h.length), // Widow: declarer sees full cards during WIDOW phase, others see count widow: (seat === room.declarer && room.state === 'WIDOW') ? room.widow : (room.state === 'WIDOW' ? room.widow.length : []), widowSize: widowSize(room), handNumber: room.handNumber, dealer: room.dealer, bids: room.bids, currentBidder: room.currentBidder, highBid: room.highBid, highBidder: room.highBidder, declarer: room.declarer, trump: room.trump, trick: room.trick, trickLead: room.trickLead, currentTurn: room.currentTurn, tricksPlayed: room.tricksPlayed, trickWins: room.trickWins, teamCardPoints: room.teamCardPoints, scores: room.scores, handDeltas: room.handDeltas, isShelemHand: room.isShelemHand, lastTrick: room.lastTrick, lastTrickWinner: room.lastTrickWinner, gameWinner: room.gameWinner, jokerMode: room.jokerMode, winScore: room.winScore, isPublic: room.isPublic, spectatorCount: room.spectators.size, }; } function broadcastState(room, event = 'roomInfo') { for (let i = 0; i < 4; i++) { const sid = room.seats[i]; if (sid) io.to(sid).emit(event, publicInfo(room, i)); } for (const sid of room.spectators) { io.to(sid).emit(event, publicInfo(room, -1)); } } // ─── Deal ───────────────────────────────────────────────────── function dealHand(room) { const deck = shuffle(makeDeck(room.jokerMode)); const wSize = widowSize(room); const hSize = (deck.length - wSize) / 4; // always 12 for (let i = 0; i < 4; i++) { room.hands[i] = sortCards(deck.slice(i * hSize, (i + 1) * hSize)); } room.widow = deck.slice(4 * hSize); room.trick = []; room.lastTrick = null; room.lastTrickWinner = -1; room.trump = null; room.tricksPlayed = 0; room.trickWins = [0, 0, 0, 0]; room.teamCardPoints = [0, 0]; room.handDeltas = null; room.isShelemHand = false; room.bids = [null, null, null, null]; room.consecutivePasses = 0; room.highBid = 0; room.highBidder = -1; room.declarer = -1; room.discarded = []; } // ─── Bidding ────────────────────────────────────────────────── function startBidding(room) { room.state = 'BIDDING'; // Bidding starts at right of dealer (counter-clockwise first seat) room.currentBidder = (room.dealer + 3) % 4; broadcastState(room, 'roomInfo'); scheduleBotBid(room); } function onBid(room, player, amount) { if (room.state !== 'BIDDING') return; if (room.currentBidder !== player) return; if (room.bids[player] === 'pass') return; if (typeof amount === 'number') { const minBid = room.jokerMode ? 105 : 85; if (amount < minBid || amount % 5 !== 0 || amount <= room.highBid) return; room.bids[player] = amount; room.highBid = amount; room.highBidder = player; room.consecutivePasses = 0; // If every other player has already passed, no one can outbid — end now const canStillBid = room.bids.filter((b, i) => i !== player && b !== 'pass').length; if (canStillBid === 0) { room.declarer = room.highBidder; startWidow(room); return; } } else { // pass room.bids[player] = 'pass'; room.consecutivePasses++; // All non-high-bidder players have now passed → no one can challenge if (room.highBid > 0) { const othersPassed = room.bids.every((b, i) => i === room.highBidder || b === 'pass'); if (othersPassed) { room.declarer = room.highBidder; startWidow(room); return; } } } // All four passed → redeal with next dealer (counter-clockwise) if (room.highBid === 0 && room.consecutivePasses >= 4) { room.handNumber++; room.dealer = (room.dealer + 1) % 4; dealHand(room); startBidding(room); return; } // Three consecutive passes after a bid → bidding ends if (room.highBid > 0 && room.consecutivePasses >= 3) { room.declarer = room.highBidder; startWidow(room); return; } // Advance to next eligible bidder (anti-clockwise: right-first, skip passed) let next = (player + 3) % 4; let safety = 0; while (room.bids[next] === 'pass' && safety++ < 4) next = (next + 3) % 4; room.currentBidder = next; broadcastState(room, 'roomInfo'); scheduleBotBid(room); } // ─── Widow ──────────────────────────────────────────────────── function startWidow(room) { room.state = 'WIDOW'; // Give widow cards to declarer's hand (they see them all) room.hands[room.declarer] = sortCards([...room.hands[room.declarer], ...room.widow]); broadcastState(room, 'roomInfo'); if (room.bots[room.declarer]) setTimeout(() => botDiscard(room, room.declarer), 900); } function onDiscard(room, player, cards) { if (room.state !== 'WIDOW') return; if (room.declarer !== player) return; const needed = discardCount(room); if (!Array.isArray(cards) || cards.length !== needed) return; const unique = [...new Set(cards)]; if (unique.length !== needed) return; const hand = room.hands[player]; if (!unique.every(c => hand.includes(c))) return; // Remove from hand for (const c of unique) { const idx = room.hands[player].indexOf(c); room.hands[player].splice(idx, 1); } room.discarded = unique; // Widow discard counts as first trick for declarer team: 5 pts + card points const dTeam = teamOf(player); room.teamCardPoints[dTeam] += 5; for (const c of unique) room.teamCardPoints[dTeam] += cardPoints(c); startPlaying(room); } // ─── Playing ────────────────────────────────────────────────── function startPlaying(room) { room.state = 'PLAYING'; room.trickLead = room.declarer; room.currentTurn = room.declarer; room.trick = []; broadcastState(room, 'roomInfo'); scheduleBotPlay(room); } function onCardPlayed(room, player, card) { // First card played by the declarer sets trump (jokers excluded by legalCards) if (room.trump === null) room.trump = suitOf(card); room.hands[player] = room.hands[player].filter(c => c !== card); room.trick.push({ card, player }); if (room.trick.length < 4) { room.currentTurn = (player + 3) % 4; // anti-clockwise: next player is to the right broadcastState(room, 'cardPlayed'); scheduleBotPlay(room); return; } // Trick complete const winner = trickWinner(room.trick, room.trump); const winnerTeam = teamOf(winner); const trickPts = room.trick.reduce((s, t) => s + cardPoints(t.card), 0); room.teamCardPoints[winnerTeam] += trickPts + 5; // 5 per trick won room.trickWins[winner]++; room.tricksPlayed++; room.lastTrick = room.trick.slice(); room.lastTrickWinner = winner; if (room.tricksPlayed === 12) { broadcastState(room, 'trickWon'); if (room.trickTimer) clearTimeout(room.trickTimer); room.trickTimer = setTimeout(() => finishHand(room), 1400); } else { broadcastState(room, 'trickWon'); if (room.trickTimer) clearTimeout(room.trickTimer); room.trickTimer = setTimeout(() => { room.trickLead = winner; room.currentTurn = winner; room.trick = []; broadcastState(room, 'roomInfo'); scheduleBotPlay(room); }, 1400); } } // ─── Hand scoring ───────────────────────────────────────────── function finishHand(room) { const dTeam = teamOf(room.declarer); const oTeam = 1 - dTeam; const dPts = room.teamCardPoints[dTeam]; const oPts = room.teamCardPoints[oTeam]; // Declarer team won all 12 tricks + the widow trick = 13 total tricks const dTricks = room.trickWins.reduce((s, w, i) => teamOf(i) === dTeam ? s + w : s, 0) + 1; let dDelta = 0, oDelta = 0; if (dTricks === 13) { // Shelem — win every trick dDelta = 250; oDelta = 0; room.isShelemHand = true; room.gameShelemsCount++; } else if (dPts >= room.highBid) { // Made the bid — score exactly what was bid, not actual points earned dDelta = room.highBid; oDelta = oPts; } else { // Failed the bid dDelta = dPts >= oPts ? -room.highBid : -2 * room.highBid; oDelta = oPts; } room.handDeltas = [0, 0]; room.handDeltas[dTeam] = dDelta; room.handDeltas[oTeam] = oDelta; room.scores[0] += room.handDeltas[0]; room.scores[1] += room.handDeltas[1]; if (room.scores.some(s => s >= room.winScore)) { finishGame(room); return; } room.state = 'HAND_OVER'; broadcastState(room, 'handOver'); if (room.trickTimer) clearTimeout(room.trickTimer); room.trickTimer = setTimeout(() => { room.handNumber++; room.dealer = (room.dealer + 1) % 4; // counter-clockwise rotation dealHand(room); startBidding(room); }, 4500); } function finishGame(room) { const maxScore = Math.max(...room.scores); room.gameWinner = room.scores .map((s, i) => ({ s, i })) .filter(x => x.s === maxScore) .map(x => x.i); // team indices room.state = 'GAME_OVER'; broadcastState(room, 'gameOver'); if (room.bots.some(Boolean)) return; const winnerTeams = new Set(room.gameWinner); for (let seat = 0; seat < 4; seat++) { const uid = room.userIds[seat]; if (!uid) continue; const team = teamOf(seat); addStats(uid, { games_played: 1, games_won: winnerTeams.has(team) ? 1 : 0, shelemCount: room.gameShelemsCount, total_score: room.scores[team], }); } } // ─── Game init ──────────────────────────────────────────────── function tryStartGame(room) { const filled = room.seats.map((s, i) => !!s || room.bots[i]); if (!filled.every(Boolean)) return; dealHand(room); startBidding(room); } // ─── Bot: bidding ───────────────────────────────────────────── function scheduleBotBid(room) { if (room.state !== 'BIDDING') return; const bot = room.currentBidder; if (!room.bots[bot]) return; setTimeout(() => { if (room.state !== 'BIDDING' || room.currentBidder !== bot) return; botBid(room, bot); }, 600 + Math.random() * 500); } function botBid(room, bot) { const hand = room.hands[bot]; let estimate = 0; for (const card of hand) estimate += cardPoints(card); // Estimate trick wins from strong cards let tricks = 0; for (const card of hand) { if (card === 'JOKER-COLOR') tricks += 1; else if (card === 'JOKER-BLACK') tricks += 0.95; else if (rankOf(card) === 'A') tricks += 0.85; else if (rankOf(card) === 'K') tricks += 0.4; } // Long-suit bonus (potential trump) const sc = { C: 0, D: 0, H: 0, S: 0 }; for (const card of hand) { const s = suitOf(card); if (sc[s] !== undefined) sc[s]++; } const longest = Math.max(...Object.values(sc)); if (longest >= 5) tricks += 1.5; if (longest >= 6) tricks += 1; estimate += Math.round(tricks) * 5; let bidAmount = Math.round(estimate / 5) * 5; if (Math.random() > 0.6) bidAmount += 5; // slight aggression bidAmount = Math.round(bidAmount / 5) * 5; const minBid = room.jokerMode ? 105 : 85; if (bidAmount > room.highBid && bidAmount >= minBid) { onBid(room, bot, bidAmount); } else { onBid(room, bot, 'pass'); } } // ─── Bot: discard ───────────────────────────────────────────── function botDiscard(room, bot) { const hand = [...room.hands[bot]]; // Score each card — lower = prefer to discard const keepScore = (card) => { if (card === 'JOKER-COLOR') return 200; if (card === 'JOKER-BLACK') return 190; const cp = cardPoints(card); if (cp >= 10) return 100 + cp; // Aces and Tens: keep if (cp === 5) return 60; // Fives: keep but lower priority // Low cards: prefer to discard return RANK_IDX[rankOf(card)]; }; const sorted = [...hand].sort((a, b) => keepScore(a) - keepScore(b)); const toDiscard = sorted.slice(0, discardCount(room)); onDiscard(room, bot, toDiscard); } // ─── Bot: play ──────────────────────────────────────────────── function scheduleBotPlay(room) { if (room.state !== 'PLAYING') return; const bot = room.currentTurn; if (!room.bots[bot]) return; const delay = 500 + Math.random() * 600; setTimeout(() => { if (room.state !== 'PLAYING' || room.currentTurn !== bot) return; const card = botChooseCard(room, bot); if (card) onCardPlayed(room, bot, card); }, delay); } function botChooseCard(room, bot) { const legal = legalCards(room, bot); if (legal.length === 1) return legal[0]; const trick = room.trick; const trump = room.trump; const botTeam = teamOf(bot); // Leading if (trick.length === 0) { const trumpCards = legal.filter(c => isTrump(c, trump)); if (trumpCards.length >= 3) { // Lead highest trump to draw out opponents' return trumpCards.sort((a, b) => trumpRank(b) - trumpRank(a))[0]; } // Lead high card from longest suit const nonJoker = legal.filter(c => !isJoker(c)); if (nonJoker.length > 0) { return nonJoker.sort((a, b) => RANK_IDX[rankOf(b)] - RANK_IDX[rankOf(a)])[0]; } return legal[0]; } // Following const curWinner = trickWinner(trick, trump); const curWinnerTeam = teamOf(curWinner); const trickPts = trick.reduce((s, t) => s + cardPoints(t.card), 0); const partnerLeading = curWinnerTeam === botTeam; if (partnerLeading) { // Let partner win — play lowest card return legal.sort((a, b) => { const av = cardPoints(a) * 20 + trumpRank(a); const bv = cardPoints(b) * 20 + trumpRank(b); return av - bv; })[0]; } // Opponent leading — try to win const winning = legal.filter(c => { const hyp = [...trick, { card: c, player: bot }]; return trickWinner(hyp, trump) === bot; }); if (winning.length > 0) { // Win with cheapest winning card (preserve high trumps) return winning.sort((a, b) => { // Prefer non-joker wins to save jokers const ai = isJoker(a) ? 2 : 1, bi = isJoker(b) ? 2 : 1; if (ai !== bi) return ai - bi; return trumpRank(a) - trumpRank(b); })[0]; } // Can't win — discard lowest value card return legal.sort((a, b) => { const av = cardPoints(a) * 20 + RANK_IDX[rankOf(a)]; const bv = cardPoints(b) * 20 + RANK_IDX[rankOf(b)]; return av - bv; })[0]; } // ═══════════════════════════════════════════════════════════════ // SOCKET.IO // ═══════════════════════════════════════════════════════════════ const rooms = new Map(); const userSockets = new Map(); io.use((socket, next) => { const token = socket.handshake.auth?.token; if (token) { try { socket.data.user = jwt.verify(token, JWT_SECRET); } catch { /* guest */ } } next(); }); io.on('connection', (socket) => { const user = socket.data.user; if (user) { userSockets.set(user.id, socket.id); // Notify authenticated user if they have an in-progress game (enables cross-device rejoin) for (const [, room] of rooms) { const seat = room.userIds.indexOf(user.id); if (seat >= 0 && room.tokens[seat] !== null && room.state !== 'GAME_OVER') { socket.emit('hasActiveGame', { roomId: room.id, seat, token: room.tokens[seat] }); break; } } } // ── Create room ──────────────────────────────────────────── socket.on('create', ({ name, jokerMode, winScore, isPublic } = {}) => { if (!name?.trim()) return socket.emit('error', 'Name is required'); const id = Math.random().toString(36).slice(2, 8).toUpperCase(); const room = newRoom(id); room.jokerMode = jokerMode !== false; room.isPublic = isPublic === true; const ws = [205, 505, 1005]; room.winScore = ws.includes(+winScore) ? +winScore : 505; room.names[0] = name.trim().slice(0, 16); room.userIds[0] = user?.id || null; room.seats[0] = socket.id; room.tokens[0] = makeToken(); rooms.set(id, room); socket.join(id); socket.emit('created', { roomId: id, seat: 0, token: room.tokens[0] }); socket.emit('roomInfo', publicInfo(room, 0)); }); // ── Join room ────────────────────────────────────────────── socket.on('join', ({ name, roomId } = {}) => { if (!name?.trim()) return socket.emit('joinError', 'Name is required'); const room = rooms.get((roomId || '').toUpperCase()); if (!room) return socket.emit('joinError', 'Room not found'); if (room.state !== 'WAITING') return socket.emit('joinError', 'Game already in progress'); // Prevent the same socket, user, or name from occupying more than one seat const trimmedName = name.trim().toLowerCase(); for (let i = 0; i < 4; i++) { if (room.seats[i] === socket.id) return socket.emit('joinError', 'You are already in this room'); if (user?.id && room.userIds[i] === user.id) return socket.emit('joinError', 'You are already in this room'); if (room.seats[i] && room.names[i].toLowerCase() === trimmedName) return socket.emit('joinError', 'That name is already taken in this room'); } // For public rooms assign a random open seat; private rooms take first open seat const openSeats = []; for (let i = 0; i < 4; i++) { if (!room.seats[i] && !room.bots[i]) openSeats.push(i); } if (openSeats.length === 0) return socket.emit('joinError', 'Room is full'); const openSeat = room.isPublic ? openSeats[Math.floor(Math.random() * openSeats.length)] : openSeats[0]; room.names[openSeat] = name.trim().slice(0, 16); room.userIds[openSeat] = user?.id || null; room.seats[openSeat] = socket.id; room.tokens[openSeat] = makeToken(); socket.join(room.id); socket.emit('joined', { roomId: room.id, seat: openSeat, token: room.tokens[openSeat] }); broadcastState(room, 'roomInfo'); if (room.seats.filter(Boolean).length + room.bots.filter(Boolean).length === 4) tryStartGame(room); }); // ── Spectate ─────────────────────────────────────────────── socket.on('spectate', ({ roomId } = {}) => { const room = rooms.get((roomId || '').toUpperCase()); if (!room) return socket.emit('spectateError', 'Room not found'); room.spectators.add(socket.id); socket.join(room.id); socket.emit('spectating', { roomId: room.id }); socket.emit('roomInfo', publicInfo(room, -1)); }); // ── Rejoin ───────────────────────────────────────────────── socket.on('rejoin', ({ roomId, seat, token } = {}) => { const room = rooms.get((roomId || '').toUpperCase()); if (!room) return socket.emit('rejoinError', 'not_found'); const tokenOk = room.tokens[seat] === token; const userOk = user && room.userIds[seat] != null && room.userIds[seat] === user.id && room.tokens[seat] !== null; // null means player explicitly left if (!tokenOk && !userOk) return socket.emit('rejoinError', 'bad_token'); // Re-issue token when only userId matched (e.g. different device) if (!tokenOk) room.tokens[seat] = makeToken(); room.seats[seat] = socket.id; socket.join(room.id); socket.emit('rejoined', { roomId: room.id, seat, token: room.tokens[seat] }); socket.emit('roomInfo', publicInfo(room, seat)); broadcastState(room, 'roomInfo'); }); // ── Fill with bots ───────────────────────────────────────── socket.on('fillBots', ({ roomId } = {}) => { const room = rooms.get((roomId || '').toUpperCase()); if (!room || room.state !== 'WAITING') return; if (!room.seats.includes(socket.id)) return; const botNames = ['Ali', 'Mina', 'Reza', 'Sara']; for (let i = 0; i < 4; i++) { if (!room.seats[i] && !room.bots[i]) { room.bots[i] = true; room.names[i] = botNames[i]; } } broadcastState(room, 'roomInfo'); tryStartGame(room); }); // ── Swap seats (creator only) ────────────────────────────── socket.on('swapSeats', ({ roomId, seatA, seatB } = {}) => { const room = rooms.get((roomId || '').toUpperCase()); if (!room || room.state !== 'WAITING') return; const isCreator = room.seats[0] === socket.id || (user?.id && room.userIds[0] === user.id); if (!isCreator) return; if (seatA === seatB || ![0,1,2,3].includes(seatA) || ![0,1,2,3].includes(seatB)) return; for (const key of ['names', 'seats', 'tokens', 'userIds', 'bots']) { [room[key][seatA], room[key][seatB]] = [room[key][seatB], room[key][seatA]]; } // Tell each affected human player their new seat and token if (room.seats[seatA]) io.to(room.seats[seatA]).emit('seatChanged', { seat: seatA, token: room.tokens[seatA] }); if (room.seats[seatB]) io.to(room.seats[seatB]).emit('seatChanged', { seat: seatB, token: room.tokens[seatB] }); broadcastState(room, 'roomInfo'); }); // ── Force start (creator only, fills remaining seats with bots) ── socket.on('forceStart', ({ roomId } = {}) => { const room = rooms.get((roomId || '').toUpperCase()); if (!room || room.state !== 'WAITING') return; const isCreator = room.seats[0] === socket.id || (user?.id && room.userIds[0] === user.id); if (!isCreator) return; const botNames = ['Ali', 'Mina', 'Reza', 'Sara']; for (let i = 0; i < 4; i++) { if (!room.seats[i] && !room.bots[i]) { room.bots[i] = true; room.names[i] = botNames[i]; } } dealHand(room); startBidding(room); }); // ── Bid ──────────────────────────────────────────────────── socket.on('bid', ({ roomId, seat, token, amount } = {}) => { const room = rooms.get((roomId || '').toUpperCase()); if (!room) return; if (room.tokens[seat] !== token) return; onBid(room, seat, amount === 'pass' ? 'pass' : +amount); }); // ── Discard widow cards ──────────────────────────────────── socket.on('discard', ({ roomId, seat, token, cards } = {}) => { const room = rooms.get((roomId || '').toUpperCase()); if (!room) return; if (room.tokens[seat] !== token) return; onDiscard(room, seat, cards); }); // ── Play card ────────────────────────────────────────────── socket.on('play', ({ roomId, seat, token, card } = {}) => { const room = rooms.get((roomId || '').toUpperCase()); if (!room) return; if (room.state !== 'PLAYING') return; if (room.tokens[seat] !== token) return; if (room.currentTurn !== seat) return socket.emit('playError', 'Not your turn'); if (!legalCards(room, seat).includes(card)) return socket.emit('playError', 'Illegal card'); onCardPlayed(room, seat, card); }); // ── Leave ────────────────────────────────────────────────── socket.on('leave', ({ roomId, seat, token } = {}) => { const room = rooms.get((roomId || '').toUpperCase()); if (!room) return; if (room.tokens[seat] === token) { room.seats[seat] = null; room.tokens[seat] = null; room.userIds[seat] = null; // prevent hasActiveGame from pulling them back in } socket.leave(room.id); }); socket.on('disconnect', () => { if (user) userSockets.delete(user.id); }); }); // ─── Start ──────────────────────────────────────────────────── httpServer.listen(HTTP_PORT, () => console.log(`Shelem HTTP → http://localhost:${HTTP_PORT}`) ); if (httpsServer) { httpsServer.listen(HTTPS_PORT, () => console.log(`Shelem HTTPS → https://localhost:${HTTPS_PORT}`) ); }