'use strict';
// ─── State ────────────────────────────────────────────────────
let socket;
let mySeat = -1;
let myToken = null;
let myRoomId = null;
let spectating = false;
let authToken = localStorage.getItem('hearts_token') || null;
let authUser = localStorage.getItem('hearts_user') || null;
let lastState = null;
// Lobby selections
let selectedScore = 100;
let selectedPublic = false;
let swapSelectedSeat = -1; // seat highlighted for swap in the waiting room
// Pass (3-card exchange) state
let passSelected = [];
let passActive = false; // true while the pass overlay owns a live selection
// Turn reminder / escalation state
let turnReminderTimer = null;
let turnReminderActive = false;
let turnEscalationLevel = 0;
// AFK / AI control state
let afkBannerVoted = false;
let aiControlledSeats = new Set();
// PWA
let deferredInstallPrompt = null;
// ─── Audio (unlocked on first gesture — required by iOS) ──────
let _audioCtx = null;
function getAudioCtx() {
if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (_audioCtx.state === 'suspended') _audioCtx.resume();
return _audioCtx;
}
document.addEventListener('pointerdown', () => getAudioCtx(), { once: true });
function playTurnChime() {
try {
const ctx = getAudioCtx();
const now = ctx.currentTime;
[[784, 0], [988, 0.18]].forEach(([freq, delay]) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain); gain.connect(ctx.destination);
osc.type = 'sine';
osc.frequency.value = freq;
gain.gain.setValueAtTime(0, now + delay);
gain.gain.linearRampToValueAtTime(0.22, now + delay + 0.02);
gain.gain.exponentialRampToValueAtTime(0.001, now + delay + 0.45);
osc.start(now + delay);
osc.stop(now + delay + 0.5);
});
} catch { /* audio not available */ }
}
function applyTurnEscalation(level) {
const pm = $('phase-msg');
if (!pm) return;
pm.classList.remove('your-turn-lvl1', 'your-turn-lvl2', 'your-turn-lvl3');
if (level > 0) pm.classList.add(`your-turn-lvl${level}`);
}
function handleTurnReminder(isMyTurn) {
if (isMyTurn && !turnReminderActive) {
turnReminderActive = true;
turnEscalationLevel = 0;
$('my-area')?.classList.remove('turn-urgent');
applyTurnEscalation(0);
// Escalate every 5 seconds: level 1 → 2 → 3 (vibrate/chime at level 3)
turnReminderTimer = setInterval(() => {
turnEscalationLevel = Math.min(turnEscalationLevel + 1, 3);
applyTurnEscalation(turnEscalationLevel);
if (turnEscalationLevel >= 3) {
$('my-area')?.classList.add('turn-urgent');
if (navigator.vibrate) navigator.vibrate([300, 100, 300]);
else playTurnChime();
clearInterval(turnReminderTimer);
turnReminderTimer = null;
}
}, 5000);
} else if (!isMyTurn && turnReminderActive) {
if (turnReminderTimer) { clearInterval(turnReminderTimer); turnReminderTimer = null; }
turnReminderActive = false;
turnEscalationLevel = 0;
applyTurnEscalation(0);
$('my-area')?.classList.remove('turn-urgent');
}
}
// ─── Play / hand-display mode ─────────────────────────────────
function isTouchDevice() {
return window.matchMedia('(pointer: coarse)').matches || 'ontouchstart' in window;
}
function loadPlayMode() {
const s = localStorage.getItem('hearts_play_mode');
if (s === 'tap' || s === 'drag') return s;
return isTouchDevice() ? 'drag' : 'tap';
}
function savePlayMode(m) { localStorage.setItem('hearts_play_mode', m); }
function loadHandMode() {
const s = localStorage.getItem('hearts_hand_mode');
if (['scroll', 'fan', 'playables'].includes(s)) return s;
return isTouchDevice() ? 'fan' : 'scroll';
}
function saveHandMode(m) { localStorage.setItem('hearts_hand_mode', m); }
function loadBarBottom() { return localStorage.getItem('hearts_bar_bottom') === '1'; }
function saveBarBottom(v) { localStorage.setItem('hearts_bar_bottom', v ? '1' : '0'); }
let playMode = loadPlayMode();
let handMode = loadHandMode();
let barAtBottom = loadBarBottom();
function applyBarBottom() {
$('screen-game')?.classList.toggle('bar-bottom', barAtBottom);
const btn = $('btn-toggle-bar');
if (btn) btn.textContent = barAtBottom ? '⬆ Bar to top' : '⬇ Bar to bottom';
}
const HAND_MODES = ['scroll', 'fan', 'playables'];
const HAND_LABELS = { scroll: '📜 Scroll', fan: '🃏 Fan', playables: '✅ Playables' };
function updatePlayModeBtn() {
const btn = $('btn-play-mode');
if (!btn) return;
const drag = playMode === 'drag';
btn.textContent = drag ? '☝ Drag' : '👆 Tap';
btn.classList.toggle('drag-mode', drag);
btn.title = drag ? 'Switch to tap mode' : 'Switch to drag mode';
}
function updateHandModeBtn() {
const btn = $('btn-hand-mode');
if (!btn) return;
btn.textContent = HAND_LABELS[handMode] || '📜 Scroll';
btn.classList.toggle('fan-mode', handMode === 'fan' || handMode === 'playables');
}
function applyHandMode() {
const fanLike = handMode === 'fan' || handMode === 'playables';
const handEl = $('my-hand');
handEl?.classList.toggle('fan-mode', fanLike);
$('my-area')?.classList.toggle('fan-active', fanLike);
if (!fanLike && handEl) handEl.style.justifyContent = '';
}
// Dynamic overlap for fan/playables mode: overlap just enough when the cards
// don't fit, spread up to 16px when they do.
function updateHandSpacing() {
const handEl = $('my-hand');
if (!handEl || handMode === 'scroll') return;
const cards = Array.from(handEl.querySelectorAll('.card'));
const n = cards.length;
if (n === 0) return;
if (n === 1) { cards[0].style.marginLeft = '0'; return; }
const cardW = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--card-w')) || 68;
const containerW = handEl.offsetWidth || (window.innerWidth - 24);
const overflow = n * cardW - containerW;
let ml;
if (overflow > 0) {
ml = -(Math.ceil(overflow / (n - 1)) + 2);
handEl.style.justifyContent = 'flex-start';
} else {
ml = Math.min(16, Math.floor(-overflow / (n - 1)));
handEl.style.justifyContent = 'center';
}
cards.forEach((c, i) => { c.style.marginLeft = i === 0 ? '0' : ml + 'px'; });
}
// ═══════════════════════════════════════════════════════════════
// CARD ASSETS — loaded once, never refetched
// ═══════════════════════════════════════════════════════════════
// Cards used to visibly flash at the start of every trick because each render
// built brand-new elements. Now every face is decoded once into a
// template image; everything on screen is a clone of that template, and the
// renderers below reuse existing DOM nodes instead of rebuilding them.
const SUIT_FILE = { H: 'HEART', D: 'DIAMOND', C: 'CLUB', S: 'SPADE' };
const RANK_FILE = {
'A': '1', '2': '2', '3': '3', '4': '4', '5': '5', '6': '6',
'7': '7', '8': '8', '9': '9', '10': '10',
'J': '11-JACK', 'Q': '12-QUEEN', 'K': '13-KING',
};
const SUITS = ['C', 'D', 'H', 'S'];
const RANKS = ['2','3','4','5','6','7','8','9','10','J','Q','K','A'];
const ALL_CARDS = SUITS.flatMap(s => RANKS.map(r => `${s}-${r}`));
function cardSvg(code) {
const [s, r] = code.split('-');
return `/cards/${SUIT_FILE[s]}-${RANK_FILE[r]}.svg`;
}
const cardTemplates = new Map();
function cardTemplate(code) {
let t = cardTemplates.get(code);
if (!t) {
t = new Image();
t.src = cardSvg(code);
t.alt = code;
t.draggable = false;
t.dataset.card = code;
cardTemplates.set(code, t);
}
return t;
}
// Cloning a loaded image reuses the decoded bitmap — no request, no flash.
function cardImg(code) { return cardTemplate(code).cloneNode(true); }
function preloadCards() { ALL_CARDS.forEach(cardTemplate); }
function makeCardEl(code) {
const el = document.createElement('div');
el.className = 'card';
el.dataset.card = code;
el.appendChild(cardImg(code));
wireCardDrag(el);
return el;
}
// Reconcile a row of cards against `codes`, keeping the DOM nodes that are
// already there. Returns the elements in display order.
function syncCards(container, codes) {
const existing = new Map();
for (const el of Array.from(container.children)) {
if (el.dataset?.card) existing.set(el.dataset.card, el);
}
const wanted = codes.map(code => existing.get(code) || makeCardEl(code));
const keep = new Set(wanted);
for (const el of existing.values()) if (!keep.has(el)) el.remove();
// appendChild/insertBefore on an existing node moves it; the image is untouched
wanted.forEach((el, i) => {
if (container.children[i] !== el) container.insertBefore(el, container.children[i] || null);
});
return wanted;
}
// A trick slot holds at most one card. Leave it alone if it already shows it.
function setSlotCard(slotId, code) {
const el = $(slotId);
if (!el) return;
const cur = el.firstElementChild;
if (!code) { if (cur) el.replaceChildren(); return; }
if (cur && cur.dataset.card === code) return;
el.replaceChildren(cardImg(code));
}
// ─── Drag-to-play (touch devices) ─────────────────────────────
// Wired once per card element; the mode checks happen at touch time so a mode
// switch doesn't require rebuilding the hand.
function wireCardDrag(cardEl) {
let touchStartY = 0, isDragging = false, ghost = null, magnifying = false;
cardEl.addEventListener('touchstart', e => {
if (!cardEl.classList.contains('playable')) return;
touchStartY = e.touches[0].clientY;
isDragging = false;
const count = $('my-hand')?.querySelectorAll('.card').length ?? 0;
magnifying = isTouchDevice() && playMode === 'drag'
&& (handMode === 'fan' || handMode === 'playables') && count > 8;
if (magnifying) cardEl.classList.add('magnified');
}, { passive: true });
cardEl.addEventListener('touchmove', e => {
if (!cardEl.classList.contains('playable')) return;
const dy = touchStartY - e.touches[0].clientY;
// Horizontal sweep with the magnifier picks a card without playing it
if (magnifying && !isDragging && dy < 20) {
cardEl.classList.remove('magnified');
const under = document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY);
under?.closest('.card')?.classList.add('magnified');
return;
}
if (dy > 20) {
e.preventDefault();
isDragging = true;
$('my-hand')?.querySelectorAll('.card.magnified').forEach(c => c.classList.remove('magnified'));
if (!ghost) {
ghost = document.createElement('div');
ghost.className = 'drag-ghost';
ghost.appendChild(cardImg(cardEl.dataset.card));
document.body.appendChild(ghost);
$('trick-area')?.classList.add('drag-active');
}
ghost.style.left = (e.touches[0].clientX - cardEl.offsetWidth / 2) + 'px';
ghost.style.top = (e.touches[0].clientY - cardEl.offsetHeight * 0.7) + 'px';
}
}, { passive: false });
const endDrag = e => {
if (ghost) { ghost.remove(); ghost = null; }
$('trick-area')?.classList.remove('drag-active');
$('my-hand')?.querySelectorAll('.card.magnified').forEach(c => c.classList.remove('magnified'));
if (isDragging && cardEl.classList.contains('playable')) {
const endY = (e.changedTouches?.[0] ?? e.touches?.[0])?.clientY ?? touchStartY;
if (endY < window.innerHeight * 0.70) playCard(cardEl.dataset.card);
}
isDragging = false;
};
cardEl.addEventListener('touchend', endDrag);
cardEl.addEventListener('touchcancel', endDrag);
}
// ─── Helpers ──────────────────────────────────────────────────
function $(id) { return document.getElementById(id); }
function show(...ids) { ids.forEach(id => $(id)?.classList.remove('hidden')); }
function hide(...ids) { ids.forEach(id => $(id)?.classList.add('hidden')); }
function showScreen(id) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
$(id)?.classList.add('active');
}
function escHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
function suitOf(code) { return code.split('-')[0]; }
function isHeart(code) { return suitOf(code) === 'H'; }
function cardPoints(code) { return isHeart(code) ? 1 : code === 'S-Q' ? 13 : 0; }
const PASS_DIR_LABEL = {
left: '← Pass Left', right: '→ Pass Right', across: '↑ Pass Across', hold: 'No Pass (Hold)',
};
// Seats run clockwise from the viewer: left = +1, across = +2, right = +3.
// Play runs the other way (seat → seat+3), i.e. bottom → right → top → left.
function seatSlots() {
const base = mySeat < 0 ? 0 : mySeat;
return { bottom: base, left: (base + 1) % 4, top: (base + 2) % 4, right: (base + 3) % 4 };
}
// ─── Auth fetch helpers ───────────────────────────────────────
function authHeaders() { return authToken ? { Authorization: `Bearer ${authToken}` } : {}; }
async function apiFetch(url, opts = {}) {
const res = await fetch(url, {
...opts,
headers: { 'Content-Type': 'application/json', ...authHeaders(), ...(opts.headers || {}) },
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
return res.json();
}
// ─── Lobby init ───────────────────────────────────────────────
function initLobby() {
updateAuthBar();
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
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();
});
});
document.querySelectorAll('.score-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.score-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
selectedScore = +btn.dataset.score;
});
});
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';
});
});
$('btn-create').addEventListener('click', createGame);
$('btn-join').addEventListener('click', joinGame);
$('btn-spectate').addEventListener('click', spectateGame);
$('btn-refresh-rooms').addEventListener('click', loadPublicRooms);
$('btn-show-auth').addEventListener('click', () => {
show('overlay-auth');
$('auth-login-user').focus();
});
$('btn-auth-close').addEventListener('click', () => hide('overlay-auth'));
$('btn-show-leaderboard').addEventListener('click', showLeaderboard);
$('btn-show-profile').addEventListener('click', () => showProfile(authUser));
$('btn-logout').addEventListener('click', doLogout);
document.querySelectorAll('.auth-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.auth-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.auth-panel').forEach(p => p.classList.remove('active'));
tab.classList.add('active');
$('auth-panel-' + tab.dataset.authTab)?.classList.add('active');
});
});
$('btn-do-login').addEventListener('click', doLogin);
$('auth-login-pass').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
$('btn-do-register').addEventListener('click', doRegister);
$('btn-do-verify').addEventListener('click', doVerify);
$('btn-reg-back').addEventListener('click', () => {
hide('reg-step-verify'); show('reg-step-form');
$('auth-verify-error').textContent = '';
});
}
function createGame() {
const name = $('input-name').value.trim();
if (!name) { $('lobby-error').textContent = 'Please enter your name.'; return; }
$('lobby-error').textContent = '';
connectSocket(() => {
socket.emit('create', { name, winScore: selectedScore, isPublic: selectedPublic });
});
}
function joinGame() {
const name = $('input-name').value.trim();
const code = $('input-code').value.trim().toUpperCase();
if (!name) { $('lobby-error').textContent = 'Please enter your name.'; return; }
if (!code) { $('lobby-error').textContent = 'Please enter a room code.'; return; }
$('lobby-error').textContent = '';
connectSocket(() => socket.emit('join', { name, roomId: code }));
}
function spectateGame() {
const code = $('input-spectate-code').value.trim().toUpperCase();
if (!code) { $('lobby-error').textContent = 'Please enter a room code.'; return; }
$('lobby-error').textContent = '';
spectating = true;
connectSocket(() => socket.emit('spectate', { roomId: code }));
}
async function loadPublicRooms() {
const listEl = $('public-rooms-list');
listEl.innerHTML = '
Loading…
'; try { const list = await fetch('/api/rooms').then(r => r.json()); 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 = `Failed to load rooms.
'; } } // ─── Socket ─────────────────────────────────────────────────── function connectSocket(onReady) { if (socket?.connected) { onReady(); return; } if (!socket) { socket = io({ auth: { token: authToken } }); initSocketHandlers(); } else { socket.auth = { token: authToken }; } socket.once('connect', onReady); } // Auth is only read at (re)connect time, so bounce the socket after login. function reauthSocket() { if (!socket) return; socket.auth = { token: authToken }; if (socket.connected) socket.disconnect().connect(); } function initSocketHandlers() { // Auto-rejoin after any network drop mid-game socket.on('connect', () => { if (myRoomId && mySeat >= 0 && myToken) { socket.emit('rejoin', { roomId: myRoomId, seat: mySeat, token: myToken }); } }); socket.on('created', ({ roomId, seat, token }) => { myRoomId = roomId; mySeat = seat; myToken = token; saveSession(); showScreen('screen-waiting'); }); socket.on('joined', ({ roomId, seat, token }) => { myRoomId = roomId; mySeat = seat; myToken = token; saveSession(); showScreen('screen-waiting'); }); socket.on('spectating', ({ roomId }) => { myRoomId = roomId; mySeat = -1; spectating = true; showScreen('screen-game'); }); socket.on('rejoined', ({ roomId, seat, token }) => { myRoomId = roomId; mySeat = seat; myToken = token; saveSession(); }); socket.on('seatChanged', ({ seat, token }) => { mySeat = seat; myToken = token; saveSession(); }); // Pushed when an authenticated user connects with a game running elsewhere socket.on('hasActiveGame', ({ roomId, seat, token }) => { if (myRoomId) return; myRoomId = roomId; mySeat = seat; myToken = token; saveSession(); socket.emit('rejoin', { roomId, seat, token }); }); socket.on('joinError', msg => { $('lobby-error').textContent = msg || 'Could not join room.'; }); socket.on('spectateError', msg => { $('lobby-error').textContent = msg || 'Could not spectate.'; }); socket.on('rejoinError', () => { clearSession(); myRoomId = null; mySeat = -1; myToken = null; showScreen('screen-lobby'); }); socket.on('error', msg => { $('lobby-error').textContent = msg || 'Server error.'; }); socket.on('playError', msg => console.warn('playError:', msg)); socket.on('passError', msg => console.warn('passError:', msg)); socket.on('roomInfo', render); socket.on('cardPlayed', render); socket.on('trickWon', onTrickWon); socket.on('handOver', onHandOver); socket.on('gameOver', onGameOver); // The server rewound the hand because the card counts had drifted apart socket.on('stateReverted', ({ trick } = {}) => { $('revert-banner-msg').textContent = `⚠ Card counts didn't match — the hand was rewound to the start of trick ${trick}.`; show('revert-banner'); setTimeout(() => hide('revert-banner'), 6000); }); socket.on('afkWarning', ({ seat, name } = {}) => { afkBannerVoted = false; const isMe = seat === mySeat; $('afk-banner-msg').textContent = isMe ? "You haven't played for a minute!" : `${name} hasn't played for 1 minute.`; const btn = $('afk-vote-btn'); if (btn) { btn.disabled = false; btn.textContent = 'Let AI Play'; } if (isMe) hide('afk-vote-btn'); else show('afk-vote-btn'); show('afk-banner'); }); socket.on('afkResolved', () => { afkBannerVoted = false; hide('afk-banner'); }); socket.on('aiControl', ({ seat, active } = {}) => { if (active) aiControlledSeats.add(seat); else aiControlledSeats.delete(seat); updateAiControlBanner(); }); } // ─── Session persistence ────────────────────────────────────── // The seat token uses its own key — sharing 'hearts_token' with the auth JWT // would overwrite the login on every join. function saveSession() { localStorage.setItem('hearts_room', myRoomId); localStorage.setItem('hearts_seat', mySeat); localStorage.setItem('hearts_seat_token', myToken); } function clearSession() { localStorage.removeItem('hearts_room'); localStorage.removeItem('hearts_seat'); localStorage.removeItem('hearts_seat_token'); } function tryRejoin() { const room = localStorage.getItem('hearts_room'); const seat = localStorage.getItem('hearts_seat'); const token = localStorage.getItem('hearts_seat_token'); if (!room || seat === null || !token) return; connectSocket(() => { myRoomId = room; mySeat = +seat; myToken = token; socket.emit('rejoin', { roomId: room, seat: +seat, token }); }); } // ─── Waiting room ───────────────────────────────────────────── function renderWaitingRoom(st) { $('display-room-code').textContent = st.id; show('waiting-options'); $('waiting-score-label').textContent = `To ${st.winScore} pts`; $('waiting-public-badge').textContent = st.isPublic ? '🌐 Public' : '🔒 Private'; const isCreator = mySeat === 0; document.querySelectorAll('.seat-slot').forEach(slot => { const s = +slot.dataset.seat; const name = slot.querySelector('.seat-name'); name.textContent = st.names[s] || (st.bots[s] ? '🤖 Bot' : '—'); slot.classList.toggle('filled', !!st.names[s]); if (isCreator) { slot.classList.add('seat-swappable'); slot.classList.toggle('seat-swap-selected', swapSelectedSeat === s); slot.onclick = () => { if (swapSelectedSeat === -1) swapSelectedSeat = s; else if (swapSelectedSeat === s) swapSelectedSeat = -1; else { socket?.emit('swapSeats', { roomId: myRoomId, seatA: swapSelectedSeat, seatB: s }); swapSelectedSeat = -1; } renderWaitingRoom(lastState); }; } else { slot.classList.remove('seat-swappable', 'seat-swap-selected'); slot.onclick = null; } }); const filled = (st.seats ? st.seats.filter(Boolean).length : 0) + st.bots.filter(Boolean).length; let statusText = filled < 4 ? `Waiting for ${4 - filled} more player${4 - filled > 1 ? 's' : ''}…` : 'Ready to start!'; if (isCreator && swapSelectedSeat >= 0) statusText = 'Tap another seat to swap ↕'; $('waiting-status').textContent = statusText; if (isCreator) show('waiting-creator-controls'); else hide('waiting-creator-controls'); } // ─── Main render ────────────────────────────────────────────── function render(st) { if (!st) return; lastState = st; aiControlledSeats = new Set(st.aiControlledSeats || []); updateAiControlBanner(); if (st.state === 'WAITING') { if ($('screen-waiting')?.classList.contains('active')) renderWaitingRoom(st); return; } // Only switch screens when we actually need to — re-running showScreen on // every state push made the whole table blink. if (!$('screen-game')?.classList.contains('active')) showScreen('screen-game'); // Reloading or rejoining during a result screen must bring the result back, // not drop the player onto an empty table. if (st.state === 'GAME_OVER') { onGameOver(st); return; } if (st.state === 'HAND_OVER') { onHandOver(st); return; } hide('overlay-hand', 'overlay-gameover'); renderInfoBar(st); renderTable(st); renderOverlays(st); } // ─── Info bar ───────────────────────────────────────────────── function renderInfoBar(st) { const scoreDiv = $('score-display'); const minScore = Math.min(...st.scores); const soleLeader = st.scores.filter(s => s === minScore).length < 4; // Reuse the four entry nodes rather than rebuilding them each tick for (let i = 0; i < 4; i++) { let entry = scoreDiv.children[i]; if (!entry) { entry = document.createElement('div'); entry.innerHTML = ''; scoreDiv.appendChild(entry); } entry.className = 'score-entry' + (i === mySeat ? ' my-score' : '') + (soleLeader && st.scores[i] === minScore ? ' leading' : ''); entry.firstChild.textContent = st.names[i] || `P${i + 1}`; entry.lastChild.textContent = st.scores[i]; } if (st.heartsBroken && st.state === 'PLAYING') show('hearts-broken-display'); else hide('hearts-broken-display'); if (st.state === 'PASSING') { $('pass-dir-display').textContent = PASS_DIR_LABEL[st.passDirection] || st.passDirection; show('pass-dir-display'); } else { hide('pass-dir-display'); } } // ─── Table ──────────────────────────────────────────────────── function renderTable(st) { const slots = seatSlots(); renderOpponent('top', slots.top, st); renderOpponent('left', slots.left, st); renderOpponent('right', slots.right, st); renderTrick(st, slots); renderMyHand(st); if (mySeat < 0) { // Spectator: the bottom of the table is seat 0's place $('my-name').textContent = st.names[0] || 'P1'; $('my-score').textContent = st.scores[0]; hide('my-turn', 'my-hand-pts', 'btn-play-mode'); } else { $('my-name').textContent = st.names[mySeat] || 'You'; $('my-score').textContent = st.scores[mySeat]; (st.currentTurn === mySeat && st.state === 'PLAYING') ? show('my-turn') : hide('my-turn'); // Points taken so far this hand (only ever my own — the server hides the rest) const hp = st.handPoints[mySeat]; if (hp > 0) { $('my-hand-pts').textContent = `+${hp} this hand`; show('my-hand-pts'); } else { hide('my-hand-pts'); } } // Phase message let msg = '', myTurnNow = false; if (st.state === 'PASSING') { const pending = st.passReady.filter(r => !r).length; msg = pending > 0 ? `${pending} player${pending > 1 ? 's' : ''} still passing…` : 'Exchanging…'; } else if (st.state === 'PLAYING') { const justWon = st.trick.length === 0 && st.lastTrickWinner >= 0 && st.tricksPlayed > 0; if (justWon) { const w = st.names[st.lastTrickWinner] || '?'; msg = st.lastTrickWinner === mySeat ? 'You took it' : `${w.slice(0, 9)} took it`; if (st.currentTurn === mySeat) myTurnNow = true; } else if (st.currentTurn === mySeat) { msg = st.trick.length === 0 ? 'Your lead!' : 'Your turn!'; myTurnNow = true; } else if (st.currentTurn >= 0) { const n = (st.names[st.currentTurn] || '?').slice(0, 9); msg = st.trick.length === 0 ? `${n} leads` : `${n}'s turn`; } } const pm = $('phase-msg'); pm.textContent = msg; pm.classList.toggle('your-turn', myTurnNow); handleTurnReminder(myTurnNow && !spectating); spectating ? show('spectator-banner') : hide('spectator-banner'); } function renderOpponent(slot, seat, st) { $(`${slot}-name`).textContent = st.names[seat] || '—'; $(`${slot}-score`).textContent = st.scores[seat]; (st.currentTurn === seat && st.state === 'PLAYING') ? show(`${slot}-turn`) : hide(`${slot}-turn`); // Card-count badge instead of a fan of card backs — cheaper and clearer, // and it makes a count mismatch visible to the players immediately. const countEl = $(`${slot}-count`); if (countEl) { const h = st.hands[seat]; const n = typeof h === 'number' ? h : (Array.isArray(h) ? h.length : 0); countEl.textContent = n > 0 ? `${n}` : ''; } } function renderTrick(st, slots) { const played = {}; for (const t of st.trick) played[t.player] = t.card; setSlotCard('trick-bottom', played[slots.bottom]); setSlotCard('trick-top', played[slots.top]); setSlotCard('trick-left', played[slots.left]); setSlotCard('trick-right', played[slots.right]); } function renderMyHand(st) { const handEl = $('my-hand'); if (mySeat < 0) { handEl.replaceChildren(); return; } const myHand = st.hands[mySeat]; if (!Array.isArray(myHand)) { handEl.replaceChildren(); return; } const isMyTurn = st.state === 'PLAYING' && st.currentTurn === mySeat && !spectating; const legal = isMyTurn ? computeLegalCards(st, mySeat) : []; const legalSet = new Set(legal); const passing = st.state === 'PASSING' && !spectating; // "Playables" mode shows only the legal cards while it's my turn const display = (handMode === 'playables' && isMyTurn) ? legal : myHand; const els = syncCards(handEl, display); els.forEach((el, i) => { const code = display[i]; el.classList.toggle('illegal', isMyTurn && !legalSet.has(code)); el.classList.toggle('playable', isMyTurn && legalSet.has(code)); el.classList.toggle('selected', passing && passSelected.includes(code)); }); if (!spectating) { if (isTouchDevice() && st.state === 'PLAYING') show('btn-play-mode'); else hide('btn-play-mode'); updatePlayModeBtn(); updateHandModeBtn(); } applyHandMode(); if (handMode === 'fan' || handMode === 'playables') { requestAnimationFrame(updateHandSpacing); } else { requestAnimationFrame(() => { const overflow = handEl.scrollWidth - handEl.clientWidth; if (overflow > 0) handEl.scrollLeft = overflow / 2; }); } } function onTrickWon(st) { render(st); // Flash the slot that took the trick const slots = seatSlots(); const pos = Object.keys(slots).find(k => slots[k] === st.lastTrickWinner); const slot = pos ? $(`trick-${pos}`) : null; if (slot) { slot.classList.add('trick-winner-flash'); setTimeout(() => slot.classList.remove('trick-winner-flash'), 600); } } // ─── Legal cards (client-side mirror of the server rules) ───── function computeLegalCards(st, seat) { const hand = st.hands[seat]; if (!Array.isArray(hand)) return []; const trick = st.trick || []; // Opening lead of the hand must be 2♣ if (trick.length === 0 && st.tricksPlayed === 0) { return hand.includes('C-2') ? ['C-2'] : hand; } // Leading: no hearts until they're broken if (trick.length === 0) { const nonHearts = hand.filter(c => !isHeart(c)); if (!st.heartsBroken && nonHearts.length > 0) return nonHearts; return hand; } // Following: must follow suit if able const leadSuit = suitOf(trick[0].card); const suitCards = hand.filter(c => suitOf(c) === leadSuit); if (suitCards.length > 0) return suitCards; // Void on the first trick: no point cards if avoidable if (st.tricksPlayed === 0) { const safe = hand.filter(c => cardPoints(c) === 0); if (safe.length > 0) return safe; } return hand; } function playCard(code) { if (!socket || mySeat < 0) return; socket.emit('play', { roomId: myRoomId, seat: mySeat, token: myToken, card: code }); } // ─── Overlays ───────────────────────────────────────────────── function renderOverlays(st) { const passingNow = st.state === 'PASSING' && mySeat >= 0 && !spectating && st.passDirection !== 'hold'; if (!passingNow) { if (passActive) { passActive = false; passSelected = []; } hide('overlay-pass'); return; } renderPassOverlay(st); } function hideAllOverlays() { hide('overlay-pass', 'overlay-hand'); passActive = false; } // ─── Pass overlay ───────────────────────────────────────────── const PASS_SUIT_ORDER = ['C', 'D', 'S', 'H']; const PASS_SUIT_LABEL = { C: '♣', D: '♦', S: '♠', H: '♥' }; function renderPassOverlay(st) { const alreadyPassed = st.passReady[mySeat]; if (!passActive) { // First render of this PASSING phase — start from a clean selection passActive = true; passSelected = []; show('overlay-pass'); } else { // Re-render while open (a spectator joined, someone else passed, …). // Keep the selection; only drop cards that are no longer in hand. const hand = Array.isArray(st.hands[mySeat]) ? st.hands[mySeat] : []; passSelected = passSelected.filter(c => hand.includes(c)); } $('pass-title').textContent = 'Pass 3 cards'; $('pass-hint').textContent = PASS_DIR_LABEL[st.passDirection] || ''; renderPassHand(st, alreadyPassed); updatePassPreview(); const readyCount = st.passReady.filter(Boolean).length; if (alreadyPassed) { $('btn-confirm-pass').disabled = true; $('pass-waiting').textContent = `Waiting for others… (${readyCount}/4 ready)`; } else { $('btn-confirm-pass').disabled = passSelected.length !== 3; $('pass-waiting').textContent = readyCount > 0 ? `${readyCount}/4 ready…` : ''; } } function renderPassHand(st, locked) { const container = $('pass-hand'); const hand = st.hands[mySeat]; if (!Array.isArray(hand)) return; // One row per suit, reusing the card nodes across renders PASS_SUIT_ORDER.forEach(s => { const suitCards = hand.filter(c => suitOf(c) === s); let row = container.querySelector(`.pass-suit-row[data-suit="${s}"]`); if (suitCards.length === 0) { row?.remove(); return; } if (!row) { row = document.createElement('div'); row.className = 'pass-suit-row'; row.dataset.suit = s; row.innerHTML = `${PASS_SUIT_LABEL[s]}` + ''; container.appendChild(row); } const els = syncCards(row.querySelector('.pass-suit-cards'), suitCards); els.forEach((el, i) => { el.classList.toggle('selected', passSelected.includes(suitCards[i])); el.classList.toggle('illegal', locked); }); }); // Keep suit rows in a stable order PASS_SUIT_ORDER.forEach(s => { const row = container.querySelector(`.pass-suit-row[data-suit="${s}"]`); if (row) container.appendChild(row); }); } function togglePassSelect(code) { if (!lastState || lastState.passReady[mySeat]) return; const i = passSelected.indexOf(code); if (i >= 0) passSelected.splice(i, 1); else if (passSelected.length < 3) passSelected.push(code); else return; renderPassOverlay(lastState); renderMyHand(lastState); } function updatePassPreview() { const preview = $('pass-selected-preview'); preview.replaceChildren(); for (let i = 0; i < 3; i++) { if (passSelected[i]) preview.appendChild(makeCardEl(passSelected[i])); else { const ph = document.createElement('div'); ph.className = 'pass-placeholder'; preview.appendChild(ph); } } } function confirmPass() { if (passSelected.length !== 3) return; socket?.emit('passCards', { roomId: myRoomId, seat: mySeat, token: myToken, cards: passSelected }); } // ─── Hand over ──────────────────────────────────────────────── function onHandOver(st) { lastState = st; hideAllOverlays(); renderInfoBar(st); renderTable(st); const moon = st.moonShooter; if (moon !== -1) { $('hand-result-icon').textContent = '🌙'; $('hand-result-title').textContent = `${st.names[moon] || 'Someone'} shot the moon!`; $('hand-result-detail').textContent = moon === mySeat ? 'You score 0 — everyone else takes 26.' : 'Everyone else takes 26 points.'; } else { $('hand-result-icon').textContent = '🃏'; $('hand-result-title').textContent = 'Hand over'; const deltas = st.handDeltas || [0, 0, 0, 0]; const mine = mySeat >= 0 ? deltas[mySeat] : null; $('hand-result-detail').textContent = mine === 0 ? 'You took nothing this hand.' : mine != null ? `You took ${mine} point${mine === 1 ? '' : 's'}.` : ''; } const scoresEl = $('hand-result-scores'); scoresEl.replaceChildren(); const deltas = st.handDeltas || [0, 0, 0, 0]; const minDelta = Math.min(...deltas); st.names.forEach((name, i) => { const row = document.createElement('div'); row.className = 'result-score-row' + (moon === i ? ' winner' : '') + (i === mySeat ? ' mine' : ''); row.innerHTML = ` ${escHtml(name || `P${i + 1}`)} +${deltas[i]} → ${st.scores[i]}`; scoresEl.appendChild(row); }); renderRoundBreakdown($('hand-round-breakdown'), st.roundHistory, st.names, st.scores); show('overlay-hand'); } // ─── Game over ──────────────────────────────────────────────── function onGameOver(st) { lastState = st; hideAllOverlays(); hide('afk-banner'); renderInfoBar(st); renderTable(st); const winners = new Set(Array.isArray(st.gameWinner) ? st.gameWinner : [st.gameWinner]); const iWon = mySeat >= 0 && winners.has(mySeat); $('gameover-title').textContent = iWon ? '🏆 You win!' : winners.size > 1 ? 'Tie game!' : `${st.names[[...winners][0]] || 'Someone'} wins!`; const scoresEl = $('gameover-scores'); scoresEl.replaceChildren(); // Lowest score first — that's the winner in Hearts st.names .map((n, i) => i) .sort((a, b) => st.scores[a] - st.scores[b]) .forEach(i => { const row = document.createElement('div'); row.className = 'gameover-row' + (winners.has(i) ? ' winner' : ''); row.innerHTML = `${escHtml(st.names[i] || `P${i + 1}`)}` + `${st.scores[i]}${winners.has(i) ? ' 🏆' : ''}`; scoresEl.appendChild(row); }); renderRoundBreakdown($('gameover-breakdown'), st.roundHistory, st.names, st.scores); show('overlay-gameover'); } // ─── Round-by-round breakdown (shared) ──────────────────────── // One row per hand showing what each player picked up, then a ruled-off totals // row with the running score. Used at hand-over, game-over and in history. function renderRoundBreakdown(container, rounds, names, finalScores) { if (!container) return; if (!Array.isArray(rounds) || rounds.length === 0) { container.innerHTML = 'No hands played yet.
'; return; } const totals = finalScores || rounds[rounds.length - 1].scores; let html = '| '; for (let i = 0; i < 4; i++) html += ` | ${escHtml(names?.[i] || `P${i + 1}`)} | `; html += '
|---|---|
| H${r.hand} | `; for (let i = 0; i < 4; i++) { const cls = r.moonShooter === i ? 'rb-moon' : deltas[i] === best ? 'rb-best' : ''; html += `${r.moonShooter === i ? '🌙 ' : ''}+${deltas[i]} | `; } html += '
| Total | '; for (let i = 0; i < 4; i++) html += `${totals[i]} | `; html += '
Loading…
'; try { const games = await fetch(`/api/history/${encodeURIComponent(username)}`).then(r => r.json()); if (!Array.isArray(games) || games.length === 0) { listEl.innerHTML = 'No games played yet.
'; return; } listEl.innerHTML = ''; games.forEach(g => { const row = document.createElement('div'); row.className = 'history-item' + (g.won ? ' won' : ' lost'); const date = new Date(g.endedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); const others = g.playerNames.filter((_, i) => i !== g.mySeat).join(', '); row.innerHTML = `Failed to load history.
'; } } function showHistoryDetail(g) { hide('history-list'); show('history-detail'); $('history-detail-title').textContent = `${g.won ? '🏆 ' : ''}Game to ${g.winScore} — ${new Date(g.endedAt).toLocaleString()}`; const scoresEl = $('history-detail-scores'); scoresEl.replaceChildren(); g.playerNames .map((n, i) => i) .sort((a, b) => g.finalScores[a] - g.finalScores[b]) .forEach(i => { const row = document.createElement('div'); row.className = 'gameover-row' + (g.winners.includes(i) ? ' winner' : ''); row.innerHTML = `${escHtml(g.playerNames[i] || `P${i + 1}`)}${i === g.mySeat ? ' (you)' : ''}` + `${g.finalScores[i]}`; scoresEl.appendChild(row); }); renderRoundBreakdown($('history-detail-breakdown'), g.rounds, g.playerNames, g.finalScores); } // ─── Event wiring ───────────────────────────────────────────── function leaveAndReturnToLobby() { socket?.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken }); clearSession(); clearGameState(); mySeat = -1; myRoomId = null; myToken = null; spectating = false; swapSelectedSeat = -1; showScreen('screen-lobby'); } function wireGameEvents() { // ── Waiting room ── $('btn-leave-waiting').addEventListener('click', leaveAndReturnToLobby); $('btn-waiting-menu').addEventListener('click', e => { e.stopPropagation(); $('waiting-menu-dropdown').classList.toggle('hidden'); }); $('btn-waiting-reload').addEventListener('click', () => { if (myRoomId && mySeat >= 0 && myToken) { socket?.emit('rejoin', { roomId: myRoomId, seat: mySeat, token: myToken }); } hide('waiting-menu-dropdown'); }); $('btn-waiting-exit').addEventListener('click', () => { hide('waiting-menu-dropdown'); leaveAndReturnToLobby(); }); $('btn-copy').addEventListener('click', async () => { const code = $('display-room-code').textContent.trim(); const btn = $('btn-copy'); try { if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(code); else { const ta = document.createElement('textarea'); ta.value = code; ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0'; document.body.appendChild(ta); ta.focus(); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); } btn.textContent = '✓'; btn.style.color = '#69f0ae'; } catch { btn.textContent = '✗'; } setTimeout(() => { btn.textContent = '⧉'; btn.style.color = ''; }, 1500); }); $('btn-fill-bots').addEventListener('click', () => socket?.emit('fillBots', { roomId: myRoomId })); $('btn-start-game').addEventListener('click', () => socket?.emit('forceStart', { roomId: myRoomId })); // ── Game menu ── $('btn-game-menu').addEventListener('click', e => { e.stopPropagation(); $('game-menu-dropdown').classList.toggle('hidden'); }); document.addEventListener('click', e => { if (!e.target.closest('.game-menu-wrap')) { hide('game-menu-dropdown', 'waiting-menu-dropdown'); } }); $('btn-refresh-game').addEventListener('click', () => { if (myRoomId && mySeat >= 0 && myToken) { socket?.emit('rejoin', { roomId: myRoomId, seat: mySeat, token: myToken }); } hide('game-menu-dropdown'); }); $('btn-toggle-bar').addEventListener('click', () => { barAtBottom = !barAtBottom; saveBarBottom(barAtBottom); applyBarBottom(); hide('game-menu-dropdown'); }); $('btn-exit-game').addEventListener('click', () => { hide('game-menu-dropdown'); show('overlay-exit-confirm'); }); $('btn-leave-game').addEventListener('click', () => { if (spectating) { spectating = false; clearGameState(); showScreen('screen-lobby'); return; } show('overlay-exit-confirm'); }); // Leave buttons inside phase overlays — those cover the info bar, so players // need their own way out while passing. document.querySelectorAll('[data-overlay-leave]').forEach(btn => { btn.addEventListener('click', () => show('overlay-exit-confirm')); }); $('btn-exit-confirm-yes').addEventListener('click', () => { hide('overlay-exit-confirm'); leaveAndReturnToLobby(); }); $('btn-exit-confirm-no').addEventListener('click', () => hide('overlay-exit-confirm')); // ── Cards: one delegated tap handler each, so reused nodes never stack listeners ── $('my-hand').addEventListener('click', e => { const card = e.target.closest('.card'); if (!card || !card.classList.contains('playable')) return; if (playMode !== 'tap') return; playCard(card.dataset.card); }); $('pass-hand').addEventListener('click', e => { const card = e.target.closest('.card'); if (!card || card.classList.contains('illegal')) return; togglePassSelect(card.dataset.card); }); $('btn-confirm-pass').addEventListener('click', confirmPass); // ── Display modes ── $('btn-play-mode').addEventListener('click', () => { playMode = playMode === 'tap' ? 'drag' : 'tap'; savePlayMode(playMode); updatePlayModeBtn(); }); $('btn-hand-mode').addEventListener('click', () => { handMode = HAND_MODES[(HAND_MODES.indexOf(handMode) + 1) % HAND_MODES.length]; saveHandMode(handMode); updateHandModeBtn(); if (lastState) renderMyHand(lastState); }); // ── Post-game ── $('btn-new-game').addEventListener('click', () => { hide('overlay-gameover'); leaveAndReturnToLobby(); }); // ── Screens ── $('btn-profile-back').addEventListener('click', () => showScreen('screen-lobby')); $('btn-lb-back').addEventListener('click', () => showScreen('screen-lobby')); $('btn-details-close').addEventListener('click', () => hide('overlay-player-details')); $('btn-show-history').addEventListener('click', e => showHistory(e.currentTarget.dataset.username || authUser)); $('btn-history-back').addEventListener('click', () => showScreen('screen-profile')); $('btn-history-detail-back').addEventListener('click', () => { hide('history-detail'); show('history-list'); }); // ── Change password ── $('btn-show-change-pass').addEventListener('click', () => $('change-pass-form').classList.toggle('hidden')); $('btn-do-change-pass').addEventListener('click', async () => { const cur = $('change-pass-current').value; const nw = $('change-pass-new').value; $('change-pass-msg').textContent = ''; try { const d = await apiFetch('/api/change-password', { method: 'POST', body: { currentPassword: cur, newPassword: nw }, }); $('change-pass-msg').textContent = d.error ? d.error : '✓ Password updated'; $('change-pass-msg').style.color = d.error ? '#ff6b6b' : '#69f0ae'; } catch { $('change-pass-msg').textContent = 'Network error'; } }); // ── AFK ── $('afk-vote-btn').addEventListener('click', () => { if (afkBannerVoted) return; afkBannerVoted = true; socket?.emit('voteAITakeover', { roomId: myRoomId }); const btn = $('afk-vote-btn'); btn.disabled = true; btn.textContent = 'Voted ✓'; }); $('afk-dismiss-btn').addEventListener('click', () => hide('afk-banner')); // ── Admin ── $('btn-toggle-signups').addEventListener('click', async () => { const d = await apiFetch('/api/admin/toggle-signups', { method: 'POST', body: {} }); if (d.signupsOpen !== undefined) { const btn = $('btn-toggle-signups'); btn.textContent = d.signupsOpen ? 'Open ✓' : 'Closed ✗'; btn.classList.toggle('signup-open', d.signupsOpen); } }); // ── PWA ── $('btn-install').addEventListener('click', () => deferredInstallPrompt?.prompt()); $('pwa-banner-install').addEventListener('click', () => deferredInstallPrompt?.prompt()); $('pwa-banner-dismiss').addEventListener('click', () => $('pwa-banner').classList.remove('show')); window.addEventListener('resize', () => { if (handMode === 'fan' || handMode === 'playables') updateHandSpacing(); }); } window.addEventListener('beforeinstallprompt', e => { e.preventDefault(); deferredInstallPrompt = e; show('btn-install'); setTimeout(() => { const banner = $('pwa-banner'); banner.classList.remove('hidden'); banner.classList.add('show'); }, 3000); }); // ─── Version footer ─────────────────────────────────────────── async function loadConfig() { try { const d = await fetch('/api/config').then(r => r.json()); if (d.version) $('app-version').textContent = `v${d.version} · tap to update`; if (d.turnstileSiteKey) { const s = document.createElement('script'); s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'; s.onload = () => { if (window.turnstile) { window.turnstile.render('#auth-captcha-wrap', { sitekey: d.turnstileSiteKey }); show('auth-captcha-wrap'); } }; document.head.appendChild(s); } } catch { /* ignore */ } } // Tapping the version drops the service worker and every cache, so a phone // stuck on stale assets can pull the latest build. async function forceUpdate() { const el = $('app-version'); if (el) el.textContent = 'Updating…'; try { if ('serviceWorker' in navigator) { const regs = await navigator.serviceWorker.getRegistrations(); await Promise.all(regs.map(r => r.unregister())); } if (window.caches) { const keys = await caches.keys(); await Promise.all(keys.map(k => caches.delete(k))); } } catch { /* ignore */ } location.reload(); } // ─── Boot ───────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', async () => { preloadCards(); // decode every face once, up front initLobby(); wireGameEvents(); applyBarBottom(); updatePlayModeBtn(); updateHandModeBtn(); applyHandMode(); if (!isTouchDevice()) hide('btn-play-mode'); loadConfig(); $('app-version').addEventListener('click', forceUpdate); await validateAuth(); if (authUser) $('input-name').value = authUser; tryRejoin(); // Authenticated with no local session: connect anyway so the server can push // hasActiveGame for a cross-device rejoin if (authToken && !myRoomId) connectSocket(() => {}); });