diff --git a/.gitignore b/.gitignore index 3ac63ef..71ea255 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .env data/ node_modules/ +push.sh \ No newline at end of file diff --git a/docker-compose.yml b/compose.build.yml similarity index 93% rename from docker-compose.yml rename to compose.build.yml index dd8bab5..573a77e 100644 --- a/docker-compose.yml +++ b/compose.build.yml @@ -1,5 +1,6 @@ services: hearts: + container_name: hearts build: . ports: - "4000:4000" diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..a698640 --- /dev/null +++ b/compose.yml @@ -0,0 +1,16 @@ +services: + hearts: + container_name: hearts + image: git.goyban.com/goyban/hearts:latest + ports: + - "4000:4000" + - "4443:4443" + restart: unless-stopped + volumes: + - ./data:/app/data + # Hokm's data dir (read-only) so Hearts can verify Hokm accounts + - /root/hokm/data:/hokm-data:ro + # Share Hokm's card SVGs — same card assets, no duplication + - /root/hokm/public/cards:/app/public/cards:ro + env_file: + - .env diff --git a/public/app.js b/public/app.js index f79aafb..975aa6e 100644 --- a/public/app.js +++ b/public/app.js @@ -11,7 +11,17 @@ let authToken = localStorage.getItem('hearts_token') || null; let authUser = localStorage.getItem('hearts_user') || null; let lastState = null; let deferredInstallPrompt = null; -let selectedScore = 100; + +// ─── Turn escalation state ──────────────────────────────────── +let turnEscalationInterval = null; +let turnEscalationLevel = 0; +let wasMyTurn = false; + +// ─── AFK / AI-takeover state ────────────────────────────────── +let aiControlledSeats = new Set(); +let afkWarnSeat = -1; +let selectedScore = 100; +let selectedPublic = false; // ─── Play mode & hand display mode ─────────────────────────── function isTouchDevice() { @@ -160,6 +170,55 @@ const RANK_FILE = { 'J': '11-JACK', 'Q': '12-QUEEN', 'K': '13-KING', }; +// ─── Turn escalation ────────────────────────────────────────── +function handleTurnReminder(isMyTurn) { + clearInterval(turnEscalationInterval); + turnEscalationLevel = 0; + applyTurnEscalation(0); + if (!isMyTurn) return; + turnEscalationInterval = setInterval(() => { + turnEscalationLevel = Math.min(turnEscalationLevel + 1, 3); + applyTurnEscalation(turnEscalationLevel); + if (turnEscalationLevel >= 3) { + if (navigator.vibrate) navigator.vibrate([200, 100, 200]); + try { + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + const o = ctx.createOscillator(); + const g = ctx.createGain(); + o.connect(g); g.connect(ctx.destination); + o.frequency.value = 880; + g.gain.setValueAtTime(0.3, ctx.currentTime); + g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4); + o.start(); o.stop(ctx.currentTime + 0.4); + } catch(e) {} + } + }, 5000); +} + +function applyTurnEscalation(level) { + const el = $('phase-msg'); + if (!el) return; + el.classList.remove('your-turn-lvl1', 'your-turn-lvl2', 'your-turn-lvl3'); + if (level > 0) el.classList.add(`your-turn-lvl${level}`); +} + +// ─── AFK / AI-takeover UI ───────────────────────────────────── +function updateAiControlBanner() { + const banner = $('ai-control-banner'); + const msg = $('ai-control-msg'); + if (!banner || !msg) return; + if (aiControlledSeats.size === 0) { hide('ai-control-banner'); return; } + if (aiControlledSeats.has(mySeat)) { + msg.textContent = 'AI is playing for you — play a card to resume control'; + banner.className = 'ai-control-banner ai-self'; + } else { + const names = [...aiControlledSeats].map(s => lastState?.names[s] || `P${s+1}`).join(', '); + msg.textContent = `AI is playing for ${names}`; + banner.className = 'ai-control-banner ai-other'; + } + show('ai-control-banner'); +} + // ─── Helpers ────────────────────────────────────────────────── function $(id) { return document.getElementById(id); } function show(...ids) { ids.forEach(id => $(id)?.classList.remove('hidden')); } @@ -295,6 +354,14 @@ function initSocket() { socket.on('rejoinError', (msg) => { clearSession(); $('lobby-error').textContent = msg || 'Could not rejoin'; + showScreen('screen-lobby'); + }); + + socket.on('hasActiveGame', ({ roomId, seat, token }) => { + if (myRoomId) return; // already in a session + myRoomId = roomId; mySeat = seat; myToken = token; + saveSession(roomId, seat, token); + socket.emit('rejoin', { roomId, seat, token }); }); // Core game events — all handled by renderState @@ -308,11 +375,44 @@ function initSocket() { socket.on('joinError', msg => { $('lobby-error').textContent = msg; }); socket.on('passError', msg => console.warn('passError:', msg)); socket.on('playError', msg => console.warn('playError:', msg)); + + socket.on('afkWarning', ({ seat, name }) => { + afkWarnSeat = seat; + const isMe = seat === mySeat; + $('afk-banner-msg').textContent = isMe + ? "You haven't played yet!" + : `${name} hasn't played. Let AI take over?`; + if (isMe) hide('afk-vote-btn'); else show('afk-vote-btn'); + show('afk-banner'); + }); + + socket.on('afkResolved', () => { + afkWarnSeat = -1; + hide('afk-banner'); + }); + + socket.on('aiControl', ({ seat, active, name }) => { + if (active) aiControlledSeats.add(seat); else aiControlledSeats.delete(seat); + updateAiControlBanner(); + }); } // ─── State rendering ────────────────────────────────────────── +function syncAiState(state) { + aiControlledSeats = new Set(state.aiControlledSeats || []); + updateAiControlBanner(); +} + function handleState(state) { lastState = state; + syncAiState(state); + + // Restore locally-selected pass cards that the server doesn't know about yet + if (state.state === 'PASSING' && mySeat >= 0 && !state.passReady[mySeat]) { + state.passSelected = passSelectedLocal; + } else { + passSelectedLocal = []; + } // Always dismiss result overlays when fresh state arrives hide('overlay-hand', 'overlay-gameover'); @@ -335,6 +435,7 @@ function handleState(state) { function handleTrickWon(state) { lastState = state; + syncAiState(state); renderInfoBar(state); renderTable(state); // Flash the winning slot briefly @@ -346,6 +447,8 @@ function handleTrickWon(state) { function handleHandOver(state) { lastState = state; + syncAiState(state); + hide('afk-banner'); renderInfoBar(state); renderTable(state); hide('overlay-pass'); @@ -354,6 +457,8 @@ function handleHandOver(state) { function handleGameOver(state) { lastState = state; + syncAiState(state); + hide('afk-banner'); renderInfoBar(state); renderTable(state); hide('overlay-pass', 'overlay-hand'); @@ -440,6 +545,9 @@ function renderWaitingRoom(state) { } else { hide('btn-fill-bots'); } + + const badge = $('waiting-public-badge'); + if (badge) badge.textContent = state.isPublic ? '🌐 Public' : '🔒 Private'; } // ── Game table ──────────────────────────────────────────────── @@ -616,6 +724,12 @@ function renderPhaseMsg(state) { const el = $('phase-msg'); if (!el) return; + const isMyTurn = state.state === 'PLAYING' && state.currentTurn === mySeat && !spectating; + if (isMyTurn !== wasMyTurn) { + wasMyTurn = isMyTurn; + handleTurnReminder(isMyTurn); + } + if (state.state === 'WAITING') { el.textContent = 'Waiting…'; } else if (state.state === 'PASSING') { @@ -728,8 +842,9 @@ function togglePassSelect(code, state) { return; // already 3 selected } - // Optimistic update for immediate feedback + // Optimistic update for immediate feedback; persist so server pushes don't wipe it state.passSelected = selected; + passSelectedLocal = selected; renderPassOverlay(state); // Also update my-hand in game table renderMyHand(myHand, state); @@ -740,6 +855,7 @@ function confirmPass() { if (!state) return; const selected = state.passSelected || []; if (selected.length !== 3) return; + passSelectedLocal = []; socket.emit('passCards', { roomId: myRoomId, seat: mySeat, token: myToken, cards: selected }); } @@ -987,6 +1103,48 @@ function escHtml(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } +// ─── Lobby helpers ──────────────────────────────────────────── +function joinGame() { + const name = $('input-name').value.trim(); + const roomId = $('input-code').value.trim().toUpperCase(); + if (!name) { $('lobby-error').textContent = 'Enter your name'; return; } + if (!roomId) { $('lobby-error').textContent = 'Enter a room code'; return; } + $('lobby-error').textContent = ''; + myName = name; + socket.emit('join', { name, roomId }); +} + +async function loadPublicRooms() { + const listEl = $('public-rooms-list'); + if (!listEl) return; + listEl.innerHTML = '

Loading…

'; + try { + const list = await apiFetch('/api/rooms'); + if (!Array.isArray(list) || list.length === 0) { + listEl.innerHTML = '

No public rooms right now.

'; + return; + } + listEl.innerHTML = ''; + list.forEach(room => { + const row = document.createElement('div'); + row.className = 'public-room-item'; + row.innerHTML = ` +
+ ${escHtml(room.hostName)}'s room + ${room.playerCount}/4 · score limit ${room.winScore} +
+ `; + row.querySelector('.btn-join-pub').addEventListener('click', () => { + $('input-code').value = room.id; + joinGame(); + }); + listEl.appendChild(row); + }); + } catch { + listEl.innerHTML = '

Failed to load rooms.

'; + } +} + // ─── PWA install ────────────────────────────────────────────── window.addEventListener('beforeinstallprompt', e => { e.preventDefault(); @@ -1038,28 +1196,33 @@ window.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); btn.classList.add('active'); $(`tab-${btn.dataset.tab}`)?.classList.add('active'); + if (btn.dataset.tab === 'join') loadPublicRooms(); }); }); + // Visibility toggle (public/private) + document.querySelectorAll('.visibility-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.visibility-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + selectedPublic = btn.dataset.public === 'true'; + }); + }); + + // Public rooms refresh + $('btn-refresh-rooms').addEventListener('click', loadPublicRooms); + // Create game $('btn-create').addEventListener('click', () => { const name = $('input-name').value.trim(); if (!name) { $('lobby-error').textContent = 'Enter your name'; return; } $('lobby-error').textContent = ''; myName = name; - socket.emit('create', { name, winScore: selectedScore }); + socket.emit('create', { name, winScore: selectedScore, isPublic: selectedPublic }); }); // Join game - $('btn-join').addEventListener('click', () => { - const name = $('input-name').value.trim(); - const roomId = $('input-code').value.trim().toUpperCase(); - if (!name) { $('lobby-error').textContent = 'Enter your name'; return; } - if (!roomId) { $('lobby-error').textContent = 'Enter a room code'; return; } - $('lobby-error').textContent = ''; - myName = name; - socket.emit('join', { name, roomId }); - }); + $('btn-join').addEventListener('click', joinGame); // Spectate $('btn-spectate').addEventListener('click', () => { @@ -1090,6 +1253,13 @@ window.addEventListener('DOMContentLoaded', () => { // Pass overlay confirm $('btn-confirm-pass').addEventListener('click', confirmPass); + // AFK banner + $('afk-vote-btn').addEventListener('click', () => { + socket.emit('voteAITakeover', { roomId: myRoomId }); + hide('afk-banner'); + }); + $('afk-dismiss-btn').addEventListener('click', () => hide('afk-banner')); + // Leave game $('btn-leave-game').addEventListener('click', () => { if (spectating) { @@ -1107,6 +1277,8 @@ window.addEventListener('DOMContentLoaded', () => { clearSession(); socket.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken }); myRoomId = null; mySeat = -1; myToken = null; spectating = false; + wasMyTurn = false; handleTurnReminder(false); + aiControlledSeats = new Set(); hide('afk-banner', 'ai-control-banner'); showScreen('screen-lobby'); }); $('btn-exit-confirm-no').addEventListener('click', () => hide('overlay-exit-confirm')); @@ -1116,6 +1288,8 @@ window.addEventListener('DOMContentLoaded', () => { hide('overlay-gameover'); clearSession(); myRoomId = null; mySeat = -1; myToken = null; spectating = false; + wasMyTurn = false; handleTurnReminder(false); + aiControlledSeats = new Set(); hide('afk-banner', 'ai-control-banner'); showScreen('screen-lobby'); }); diff --git a/public/index.html b/public/index.html index bc8048a..a3dfb11 100644 --- a/public/index.html +++ b/public/index.html @@ -51,15 +51,29 @@ +
+ +
+ + +
+
+
+
+ Public Rooms + +
+

Switch to this tab to load rooms.

+
- +
- +
@@ -77,6 +91,7 @@

Waiting for Players

+
🔒 Private
Room Code —— @@ -126,6 +141,18 @@ + + + + + +
diff --git a/public/style.css b/public/style.css index 6144d6e..8d21e4b 100644 --- a/public/style.css +++ b/public/style.css @@ -1019,3 +1019,165 @@ html, body { :root { --card-w: 52px; --card-h: 74px; } #my-area { padding-bottom: 4px; } } + +/* ── Visibility toggle (Create tab) ──────────── */ +.visibility-toggle { + display: flex; + gap: 8px; +} +.visibility-btn { + flex: 1; + padding: 8px; + border-radius: 8px; + border: 1px solid rgba(255,255,255,.2); + background: transparent; + color: rgba(255,255,255,.6); + font-size: .9rem; + cursor: pointer; + transition: all .2s; +} +.visibility-btn.active { + background: rgba(100,180,255,.2); + border-color: rgba(100,180,255,.5); + color: rgba(200,230,255,.95); + font-weight: 600; +} + +/* ── Public rooms list (Join tab) ─────────────── */ +.public-rooms-section { + display: flex; + flex-direction: column; + gap: 6px; +} +.rooms-header { + display: flex; + align-items: center; + justify-content: space-between; + font-size: .8rem; + color: rgba(255,255,255,.55); +} +.btn-refresh { + background: none; + border: 1px solid rgba(255,255,255,.2); + border-radius: 6px; + color: rgba(255,255,255,.6); + font-size: .75rem; + padding: 2px 8px; + cursor: pointer; + transition: background .15s; +} +.btn-refresh:hover { background: rgba(255,255,255,.1); color: #fff; } +#public-rooms-list { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 160px; + overflow-y: auto; + scrollbar-width: thin; +} +.public-room-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + background: rgba(255,255,255,.07); + border: 1px solid rgba(255,255,255,.12); + border-radius: 8px; + padding: 8px 10px; +} +.public-room-info { + display: flex; + flex-direction: column; + gap: 2px; + overflow: hidden; +} +.public-room-host { + font-size: .85rem; + font-weight: 600; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.public-room-meta { font-size: .72rem; color: rgba(255,255,255,.5); } +.btn-join-pub { + padding: 5px 12px; + background: var(--gold); + border: none; + border-radius: 6px; + color: #000; + font-size: .8rem; + font-weight: 700; + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; + transition: opacity .15s; +} +.btn-join-pub:hover { opacity: .85; } + +/* ── Waiting room public badge ─────────────────── */ +.waiting-public-badge { + font-size: .78rem; + color: rgba(255,255,255,.65); + background: rgba(255,255,255,.09); + border: 1px solid rgba(255,255,255,.15); + border-radius: 10px; + padding: 2px 10px; + align-self: center; +} + +/* ── Your-turn escalation ─────────────────────── */ +@keyframes your-turn-flash { + 0%,100% { opacity:1; } 50% { opacity:.55; } +} +.phase-msg.your-turn-lvl1 { + font-size: .96rem; color: #ffc400; + animation: your-turn-flash .6s ease-in-out infinite; +} +.phase-msg.your-turn-lvl2 { + font-size: 1.18rem; color: #ffeb3b; + text-shadow: 0 0 10px #ffeb3b88; + animation: your-turn-flash .4s ease-in-out infinite; +} +.phase-msg.your-turn-lvl3 { + font-size: 1.4rem; color: #fff; + text-shadow: 0 0 22px #ffffffcc, 0 0 8px #ffcc00cc; + animation: your-turn-flash .25s ease-in-out infinite; +} + +/* ── AFK warning banner ───────────────────────── */ +.afk-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 14px; + background: rgba(180,60,30,.88); + border-bottom: 1px solid rgba(255,120,60,.35); + font-size: .82rem; + flex-shrink: 0; +} +.afk-banner span { flex: 1; color: rgba(255,255,255,.9); } +.afk-banner button { + padding: 3px 10px; + border-radius: 6px; + border: 1px solid rgba(255,255,255,.35); + background: rgba(255,255,255,.18); + color: #fff; + font-size: .76rem; + cursor: pointer; + white-space: nowrap; +} +.afk-banner button:hover { background: rgba(255,255,255,.3); } + +/* ── AI control banner ────────────────────────── */ +.ai-control-banner { + display: flex; + align-items: center; + justify-content: center; + padding: 5px 14px; + font-size: .8rem; + flex-shrink: 0; + color: rgba(255,255,255,.9); +} +.ai-control-banner.ai-self { background: rgba(190,40,90,.75); border-bottom: 1px solid rgba(255,80,140,.25); } +.ai-control-banner.ai-other { background: rgba(70,45,160,.75); border-bottom: 1px solid rgba(120,90,220,.25); } diff --git a/server.js b/server.js index 098cd51..d1998b7 100644 --- a/server.js +++ b/server.js @@ -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}`)