Add features: escalating turn reminder, AFK AI takeover, public rooms, persistent rejoin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -348,6 +348,20 @@ app.get('/api/leaderboard', (_req, res) => {
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
app.get('/api/rooms', (_req, res) => {
|
||||
const list = [];
|
||||
for (const [, room] of rooms) {
|
||||
if (!room.isPublic || room.state !== 'WAITING') continue;
|
||||
list.push({
|
||||
id: room.id,
|
||||
playerCount: room.seats.filter(Boolean).length + room.bots.filter(Boolean).length,
|
||||
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;
|
||||
@@ -415,8 +429,14 @@ function newRoom(id) {
|
||||
moonShooter: -1,
|
||||
handDeltas: null,
|
||||
winScore: 100,
|
||||
spectators: new Set(),
|
||||
trickTimer: null,
|
||||
spectators: new Set(),
|
||||
isPublic: false,
|
||||
createdAt: Date.now(),
|
||||
trickTimer: null,
|
||||
afkTimer: null,
|
||||
afkSeat: -1,
|
||||
afkVotes: new Set(),
|
||||
aiControlledSeats: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -452,7 +472,9 @@ function publicInfo(room, seat = -1) {
|
||||
moonShooter: room.moonShooter,
|
||||
handDeltas: room.handDeltas,
|
||||
gameWinner: room.gameWinner,
|
||||
spectatorCount: room.spectators.size,
|
||||
spectatorCount: room.spectators.size,
|
||||
isPublic: room.isPublic,
|
||||
aiControlledSeats: [...room.aiControlledSeats],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -468,6 +490,46 @@ function broadcastState(room, event = 'roomInfo') {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── AFK / AI-takeover helpers ────────────────────────────────
|
||||
function broadcast(room, event, data) {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (room.seats[i]) io.to(room.seats[i]).emit(event, data);
|
||||
}
|
||||
for (const sid of room.spectators) io.to(sid).emit(event, data);
|
||||
}
|
||||
|
||||
function clearAfkTimer(room) {
|
||||
if (room.afkTimer) { clearTimeout(room.afkTimer); room.afkTimer = null; }
|
||||
if (room.afkSeat >= 0) {
|
||||
broadcast(room, 'afkResolved', {});
|
||||
room.afkSeat = -1;
|
||||
room.afkVotes.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastAiControl(room, seat, active) {
|
||||
broadcast(room, 'aiControl', { seat, active, name: room.names[seat] });
|
||||
}
|
||||
|
||||
function scheduleAfkTimer(room) {
|
||||
clearAfkTimer(room);
|
||||
const seat = room.currentTurn;
|
||||
if (room.bots[seat]) return;
|
||||
if (room.aiControlledSeats.has(seat)) {
|
||||
room.afkTimer = setTimeout(() => {
|
||||
if (room.state !== 'PLAYING' || room.currentTurn !== seat) return;
|
||||
const card = botChooseCard(room, seat);
|
||||
if (card) onCardPlayed(room, seat, card);
|
||||
}, 10000);
|
||||
} else {
|
||||
room.afkSeat = seat;
|
||||
room.afkTimer = setTimeout(() => {
|
||||
if (room.state !== 'PLAYING') return;
|
||||
broadcast(room, 'afkWarning', { seat, name: room.names[seat] });
|
||||
}, 60000);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Dealing ──────────────────────────────────────────────────
|
||||
function dealHand(room) {
|
||||
const deck = shuffle(makeDeck());
|
||||
@@ -563,6 +625,7 @@ function exchangePassCards(room) {
|
||||
|
||||
// ─── Game flow ────────────────────────────────────────────────
|
||||
function startPassing(room) {
|
||||
clearAfkTimer(room);
|
||||
room.state = 'PASSING';
|
||||
broadcastState(room, 'roomInfo');
|
||||
// Schedule bots to pass
|
||||
@@ -578,6 +641,7 @@ function startPlaying(room) {
|
||||
room.trick = [];
|
||||
broadcastState(room, 'roomInfo');
|
||||
scheduleBotPlay(room);
|
||||
scheduleAfkTimer(room);
|
||||
}
|
||||
|
||||
function onCardPlayed(room, player, card) {
|
||||
@@ -592,6 +656,7 @@ function onCardPlayed(room, player, card) {
|
||||
room.currentTurn = (player + 3) % 4; // anti-clockwise
|
||||
broadcastState(room, 'cardPlayed');
|
||||
scheduleBotPlay(room);
|
||||
scheduleAfkTimer(room);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -605,10 +670,12 @@ function onCardPlayed(room, player, card) {
|
||||
|
||||
if (room.tricksPlayed === 13) {
|
||||
broadcastState(room, 'trickWon');
|
||||
clearAfkTimer(room);
|
||||
if (room.trickTimer) clearTimeout(room.trickTimer);
|
||||
room.trickTimer = setTimeout(() => finishHand(room), 1400);
|
||||
} else {
|
||||
broadcastState(room, 'trickWon');
|
||||
clearAfkTimer(room);
|
||||
if (room.trickTimer) clearTimeout(room.trickTimer);
|
||||
room.trickTimer = setTimeout(() => {
|
||||
room.trickLead = winner;
|
||||
@@ -616,11 +683,15 @@ function onCardPlayed(room, player, card) {
|
||||
room.trick = [];
|
||||
broadcastState(room, 'roomInfo');
|
||||
scheduleBotPlay(room);
|
||||
scheduleAfkTimer(room);
|
||||
}, 1400);
|
||||
}
|
||||
}
|
||||
|
||||
function finishHand(room) {
|
||||
clearAfkTimer(room);
|
||||
for (const s of room.aiControlledSeats) broadcastAiControl(room, s, false);
|
||||
room.aiControlledSeats.clear();
|
||||
// Check shoot the moon (one player has all 26 pts)
|
||||
const shooter = room.handPoints.findIndex(p => p === 26);
|
||||
let deltas;
|
||||
@@ -815,14 +886,25 @@ io.use((socket, next) => {
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
const user = socket.data.user;
|
||||
if (user) userSockets.set(user.id, socket);
|
||||
if (user) {
|
||||
userSockets.set(user.id, socket);
|
||||
// Push active game to this user on any device (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, winScore } = {}) => {
|
||||
socket.on('create', ({ name, 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.winScore = Number.isFinite(+winScore) && winScore >= 50 ? Math.min(+winScore, 500) : 100;
|
||||
room.isPublic = isPublic === true;
|
||||
room.names[0] = name.trim().slice(0, 16);
|
||||
room.userIds[0] = user?.id || null;
|
||||
room.seats[0] = socket.id;
|
||||
@@ -840,12 +922,14 @@ io.on('connection', (socket) => {
|
||||
if (!room) return socket.emit('joinError', 'Room not found');
|
||||
if (room.state !== 'WAITING') return socket.emit('joinError', 'Game already in progress');
|
||||
|
||||
// Find first open seat (not filled by a human or bot)
|
||||
let openSeat = -1;
|
||||
const openSeats = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (!room.seats[i] && !room.bots[i]) { openSeat = i; break; }
|
||||
if (!room.seats[i] && !room.bots[i]) openSeats.push(i);
|
||||
}
|
||||
if (openSeat === -1) return socket.emit('joinError', 'Room is full');
|
||||
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;
|
||||
@@ -874,11 +958,21 @@ io.on('connection', (socket) => {
|
||||
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');
|
||||
|
||||
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', 'Invalid session token');
|
||||
|
||||
// Re-issue a fresh token when only userId matched (old token lives on the other device)
|
||||
if (!tokenOk) room.tokens[seat] = makeToken();
|
||||
|
||||
room.seats[seat] = socket.id;
|
||||
socket.join(room.id);
|
||||
socket.emit('rejoined', { roomId: room.id, seat, token });
|
||||
socket.emit('rejoined', { roomId: room.id, seat, token: room.tokens[seat] });
|
||||
socket.emit('roomInfo', publicInfo(room, seat));
|
||||
broadcastState(room, 'roomInfo');
|
||||
});
|
||||
@@ -929,16 +1023,49 @@ io.on('connection', (socket) => {
|
||||
if (room.tokens[seat] !== token) return;
|
||||
if (room.currentTurn !== seat) return socket.emit('playError', 'Not your turn');
|
||||
if (!isLegal(room, seat, card)) return socket.emit('playError', 'Illegal card');
|
||||
if (room.aiControlledSeats.has(seat)) {
|
||||
room.aiControlledSeats.delete(seat);
|
||||
broadcastAiControl(room, seat, false);
|
||||
clearAfkTimer(room);
|
||||
}
|
||||
onCardPlayed(room, seat, card);
|
||||
});
|
||||
|
||||
// ── Vote to let AI take over an AFK player ────────────────
|
||||
socket.on('voteAITakeover', ({ roomId } = {}) => {
|
||||
const room = rooms.get((roomId || '').toUpperCase());
|
||||
if (!room || room.state !== 'PLAYING') return;
|
||||
if (room.afkSeat < 0) return;
|
||||
|
||||
const voterSeat = room.seats.indexOf(socket.id);
|
||||
if (voterSeat < 0 || voterSeat === room.afkSeat || room.bots[voterSeat]) return;
|
||||
|
||||
room.afkVotes.add(socket.id);
|
||||
|
||||
const eligible = room.seats.filter((s, i) => s && !room.bots[i] && i !== room.afkSeat).length;
|
||||
const needed = eligible <= 1 ? 1 : Math.floor(eligible / 2) + 1;
|
||||
const votes = [...room.afkVotes].filter(sid => {
|
||||
const idx = room.seats.indexOf(sid);
|
||||
return idx >= 0 && idx !== room.afkSeat && !room.bots[idx];
|
||||
}).length;
|
||||
|
||||
if (votes >= needed) {
|
||||
const seat = room.afkSeat;
|
||||
room.aiControlledSeats.add(seat);
|
||||
broadcastAiControl(room, seat, true);
|
||||
clearAfkTimer(room);
|
||||
scheduleAfkTimer(room);
|
||||
}
|
||||
});
|
||||
|
||||
// ── 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.seats[seat] = null;
|
||||
room.tokens[seat] = null;
|
||||
room.userIds[seat] = null;
|
||||
}
|
||||
socket.leave(room.id);
|
||||
});
|
||||
@@ -949,6 +1076,19 @@ io.on('connection', (socket) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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);
|
||||
|
||||
// ─── Server startup ────────────────────────────────────────────
|
||||
httpServer.listen(HTTP_PORT, () =>
|
||||
console.log(`Hearts HTTP → http://localhost:${HTTP_PORT}`)
|
||||
|
||||
Reference in New Issue
Block a user