Files
shelem/server.js
T
2026-05-24 15:59:24 +00:00

1189 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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.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,
// AFK tracking
afkTimer: null,
afkSeat: -1,
afkVotes: new Set(),
// AI control (persistent per seat until player acts)
aiControlledSeats: new Set(),
createdAt: Date.now(),
};
}
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) {
clearAfkTimer(room);
for (const seat of room.aiControlledSeats) broadcastAiControl(room, seat, false);
room.aiControlledSeats.clear();
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');
scheduleAfkTimer(room);
scheduleBotPlay(room);
}
function onCardPlayed(room, player, card) {
clearAfkTimer(room);
// 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');
scheduleAfkTimer(room);
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');
scheduleAfkTimer(room);
scheduleBotPlay(room);
}, 1400);
}
}
// ─── Hand scoring ─────────────────────────────────────────────
function finishHand(room) {
clearAfkTimer(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],
});
}
}
// ─── AFK timer ────────────────────────────────────────────────
function clearAfkTimer(room) {
if (room.afkTimer) { clearTimeout(room.afkTimer); room.afkTimer = null; }
if (room.afkSeat >= 0) {
for (let i = 0; i < 4; i++) {
if (room.seats[i]) io.to(room.seats[i]).emit('afkResolved', {});
}
room.afkSeat = -1;
room.afkVotes = new Set();
}
}
function broadcastAiControl(room, seat, active) {
const data = { seat, active, name: room.names[seat] };
for (let i = 0; i < 4; i++) {
if (room.seats[i]) io.to(room.seats[i]).emit('aiControl', data);
}
for (const sid of room.spectators) io.to(sid).emit('aiControl', data);
}
function scheduleAfkTimer(room) {
clearAfkTimer(room);
if (room.state !== 'PLAYING') return;
const seat = room.currentTurn;
if (room.bots[seat] || !room.seats[seat]) return;
// Seat is AI-controlled: play automatically after 10s
if (room.aiControlledSeats.has(seat)) {
room.afkTimer = setTimeout(() => {
if (room.state !== 'PLAYING' || room.currentTurn !== seat) return;
if (!room.aiControlledSeats.has(seat)) return;
const card = botChooseCard(room, seat);
if (card) onCardPlayed(room, seat, card);
}, 10000);
return;
}
// Normal 60s AFK warning timer
room.afkTimer = setTimeout(() => {
if (room.state !== 'PLAYING' || room.currentTurn !== seat) return;
room.afkSeat = seat;
room.afkVotes = new Set();
const otherHumans = room.seats
.map((sid, i) => ({ sid, i }))
.filter(({ sid, i }) => sid && i !== seat);
if (otherHumans.length === 0) {
room.afkSeat = -1;
const card = botChooseCard(room, seat);
if (card) onCardPlayed(room, seat, card);
return;
}
for (const { sid } of otherHumans) {
io.to(sid).emit('afkWarning', { seat, name: room.names[seat] });
}
}, 60000);
}
// ─── 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');
// Player acted themselves — release AI control
if (room.aiControlledSeats.has(seat)) {
room.aiControlledSeats.delete(seat);
broadcastAiControl(room, seat, false);
}
onCardPlayed(room, seat, card);
});
// ── Vote AI takeover (AFK) ─────────────────────────────────
socket.on('voteAITakeover', ({ roomId, seat, token } = {}) => {
const room = rooms.get((roomId || '').toUpperCase());
if (!room || room.state !== 'PLAYING') return;
if (room.tokens[seat] !== token) return;
if (room.afkSeat < 0 || seat === room.afkSeat) return;
room.afkVotes.add(seat);
const otherHumanSeats = room.seats
.map((sid, i) => ({ sid, i }))
.filter(({ sid, i }) => sid && i !== room.afkSeat);
const needed = Math.ceil(otherHumanSeats.length / 2);
if (room.afkVotes.size >= needed) {
const afkSeat = room.afkSeat;
clearAfkTimer(room);
room.aiControlledSeats.add(afkSeat);
broadcastAiControl(room, afkSeat, true);
if (room.state === 'PLAYING' && room.currentTurn === afkSeat) {
const card = botChooseCard(room, afkSeat);
if (card) onCardPlayed(room, afkSeat, 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);
});
});
// ─── Stale public room cleanup ────────────────────────────────
const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
setInterval(() => {
const now = Date.now();
for (const [id, room] of rooms) {
if (room.isPublic && room.state === 'WAITING' && now - room.createdAt > SIX_HOURS_MS) {
if (room.trickTimer) clearTimeout(room.trickTimer);
if (room.afkTimer) clearTimeout(room.afkTimer);
rooms.delete(id);
}
}
}, 30 * 60 * 1000); // check every 30 minutes
// ─── 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}`)
);
}