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:
goyban
2026-05-24 17:06:44 +00:00
parent f78fbe2dfa
commit e499e89027
7 changed files with 548 additions and 27 deletions
+153 -13
View File
@@ -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}`)