Initial commit: Shelem card game

Full-stack multiplayer Shelem (Iranian trick-taking card game) with
Socket.IO, JWT auth, bot players, joker mode, and mobile-friendly UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
goyban
2026-05-08 16:17:37 +00:00
commit 8e8478e45b
12 changed files with 3869 additions and 0 deletions
+980
View File
@@ -0,0 +1,980 @@
'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 216 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.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,
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,
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);
// ── Create room ────────────────────────────────────────────
socket.on('create', ({ name, jokerMode, winScore } = {}) => {
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; // default 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');
let openSeat = -1;
for (let i = 0; i < 4; i++) {
if (!room.seats[i] && !room.bots[i]) { openSeat = i; break; }
}
if (openSeat === -1) return socket.emit('joinError', 'Room is full');
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', 'Room no longer exists');
if (room.tokens[seat] !== token) return socket.emit('rejoinError', 'Invalid session token');
room.seats[seat] = socket.id;
socket.join(room.id);
socket.emit('rejoined', { roomId: room.id, seat, token });
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);
});
// ── 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;
}
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}`)
);
}