Files
shelem/public/app.js
T
goyban 3cf338d014 Use JOKER-3.svg for the colorful joker
Point JOKER-COLOR at /cards/JOKER-3.svg instead of JOKER-1.svg.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 09:22:08 +00:00

1750 lines
64 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// ─── State ────────────────────────────────────────────────────
let socket;
let myName = '';
let mySeat = -1;
let myToken = null;
let myRoomId = null;
let spectating = false;
let authToken = localStorage.getItem('shelem_token') || null;
let authUser = localStorage.getItem('shelem_user') || null;
let lastState = null;
// Lobby selections
let selectedJoker = true;
let selectedScore = 505;
let selectedPublic = false;
let swapSelectedSeat = -1; // seat highlighted for swap in waiting room
// Widow discard state
let widowSelected = [];
let widowActive = false; // true while the widow overlay is open for this WIDOW phase
// Bid UI state
let currentBidAmount = 85;
let lastBidHandNumber = -1;
// Turn reminder / escalation state
let turnReminderTimer = null;
let turnReminderActive = false;
let turnEscalationLevel = 0;
// AFK state
let afkBannerVoted = false;
// AI control state (which seats are AI-controlled)
let aiControlledSeats = new Set();
// Unlock Web Audio on first user 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;
// Two-note chime: G5 then B5
[[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 (e) { /* 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 (mirror of Hearts) ──────────────
function isTouchDevice() {
return window.matchMedia('(pointer: coarse)').matches || 'ontouchstart' in window;
}
function loadPlayMode() {
const s = localStorage.getItem('shelem_play_mode');
if (s === 'tap' || s === 'drag') return s;
return isTouchDevice() ? 'drag' : 'tap';
}
function savePlayMode(m) { localStorage.setItem('shelem_play_mode', m); }
function loadHandMode() {
const s = localStorage.getItem('shelem_hand_mode');
if (['scroll','fan','playables'].includes(s)) return s;
return isTouchDevice() ? 'fan' : 'scroll';
}
function saveHandMode(m) { localStorage.setItem('shelem_hand_mode', m); }
function loadBarBottom() { return localStorage.getItem('shelem_bar_bottom') === '1'; }
function saveBarBottom(v) { localStorage.setItem('shelem_bar_bottom', v ? '1' : '0'); }
let playMode = loadPlayMode();
let handMode = loadHandMode();
let barAtBottom = loadBarBottom();
function applyBarBottom() {
document.getElementById('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 = '';
}
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'; });
}
// Drag-to-play (touch devices)
function addDragHandlers(cardEl, code, onPlay) {
const cardCount = $('my-hand')?.querySelectorAll('.card').length ?? 0;
const useMagnifier = isTouchDevice() && playMode === 'drag'
&& (handMode === 'fan' || handMode === 'playables')
&& cardCount > 8;
let touchStartY = 0, touchStartX = 0, isDragging = false, ghost = null;
cardEl.addEventListener('touchstart', e => {
touchStartY = e.touches[0].clientY;
touchStartX = e.touches[0].clientX;
isDragging = false;
if (useMagnifier) cardEl.classList.add('magnified');
}, { passive: true });
cardEl.addEventListener('touchmove', e => {
const dy = touchStartY - e.touches[0].clientY;
if (useMagnifier && !isDragging && dy < 20) {
cardEl.classList.remove('magnified');
const el2 = document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY);
const tgt = el2?.closest('.card');
if (tgt) tgt.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';
const gi = document.createElement('img');
gi.src = cardSvg(code); gi.alt = code; gi.draggable = false;
ghost.appendChild(gi);
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) {
const endY = (e.changedTouches?.[0] ?? e.touches?.[0])?.clientY ?? touchStartY;
if (endY < window.innerHeight * 0.70) onPlay(code);
}
isDragging = false;
};
cardEl.addEventListener('touchend', endDrag);
cardEl.addEventListener('touchcancel', endDrag);
}
// ─── Helpers ──────────────────────────────────────────────────
function $(id) { return document.getElementById(id); }
function show(id) { const el = $(id); if (el) el.classList.remove('hidden'); }
function hide(id) { const el = $(id); if (el) el.classList.add('hidden'); }
function cardSvg(code) {
if (code === 'JOKER-COLOR') return '/cards/JOKER-3.svg';
if (code === 'JOKER-BLACK') return '/cards/JOKER-2.svg';
const [suit, rank] = code.split('-');
const suitMap = { C: 'CLUB', D: 'DIAMOND', H: 'HEART', S: 'SPADE' };
const name = suitMap[suit];
if (rank === 'A') return `/cards/${name}-1.svg`;
if (rank === 'J') return `/cards/${name}-11-JACK.svg`;
if (rank === 'Q') return `/cards/${name}-12-QUEEN.svg`;
if (rank === 'K') return `/cards/${name}-13-KING.svg`;
return `/cards/${name}-${rank}.svg`;
}
function suitSymbol(suit) {
return { C: '♣', D: '♦', H: '♥', S: '♠' }[suit] || suit;
}
function suitName(suit) {
return { C: 'Clubs', D: 'Diamonds', H: 'Hearts', S: 'Spades' }[suit] || suit;
}
function teamOf(seat) { return seat % 2; }
// Map logical seats to visual positions relative to mySeat
// Returns the seat number for a visual slot
function visualSeat(slot) {
// slot: 'bottom'=me, 'top'=partner, 'left', 'right'
if (mySeat < 0) return -1;
const offsets = { bottom: 0, left: 1, top: 2, right: 3 };
return (mySeat + offsets[slot]) % 4;
}
// ─── Screens ──────────────────────────────────────────────────
function showScreen(id) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
const el = $(id);
if (el) el.classList.add('active');
}
// ─── Card elements ─────────────────────────────────────────────
// Card element for trick-area slots (includes joker badge)
function makeTrickImg(code) {
if (code === 'JOKER-COLOR' || code === 'JOKER-BLACK') {
const wrap = document.createElement('div');
wrap.className = 'trick-card-wrap';
const img = document.createElement('img');
img.src = cardSvg(code);
img.alt = code;
img.draggable = false;
wrap.appendChild(img);
const badge = document.createElement('div');
badge.className = 'joker-badge ' + (code === 'JOKER-COLOR' ? 'joker-color-badge' : 'joker-black-badge');
badge.textContent = code === 'JOKER-COLOR' ? '🌈 Color · 20' : '⚫ Black · 15';
wrap.appendChild(badge);
return wrap;
}
const img = document.createElement('img');
img.src = cardSvg(code);
img.alt = code;
img.draggable = false;
return img;
}
function makeCardEl(code, opts = {}) {
const el = document.createElement('div');
el.className = 'card';
el.dataset.card = code;
const img = document.createElement('img');
img.src = cardSvg(code);
img.alt = code;
el.appendChild(img);
// Overlay badge so players can instantly tell the two jokers apart
if (code === 'JOKER-COLOR') {
const badge = document.createElement('div');
badge.className = 'joker-badge joker-color-badge';
badge.textContent = '🌈 Color · 20';
el.appendChild(badge);
} else if (code === 'JOKER-BLACK') {
const badge = document.createElement('div');
badge.className = 'joker-badge joker-black-badge';
badge.textContent = '⚫ Black · 15';
el.appendChild(badge);
}
if (opts.onClick) el.addEventListener('click', () => opts.onClick(code, el));
if (opts.illegal) el.classList.add('illegal');
return el;
}
function makeCardBack() {
const el = document.createElement('div');
el.className = 'card-back';
return el;
}
// ─── Lobby init ───────────────────────────────────────────────
function initLobby() {
updateAuthBar();
// Tabs
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();
});
});
// Mode buttons
document.querySelectorAll('.mode-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
selectedJoker = btn.dataset.joker === 'true';
});
});
// Score buttons
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;
});
});
// Visibility toggle
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);
// Auth tabs
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);
$('auth-reg-pass').addEventListener('keydown', e => { if (e.key === 'Enter') doRegister(); });
}
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, jokerMode: selectedJoker, winScore: selectedScore, isPublic: selectedPublic });
});
}
async function loadPublicRooms() {
const listEl = $('public-rooms-list');
listEl.innerHTML = '<p class="hint">Loading…</p>';
try {
const r = await fetch('/api/rooms');
const list = await r.json();
if (list.length === 0) {
listEl.innerHTML = '<p class="hint">No public rooms right now.</p>';
return;
}
listEl.innerHTML = '';
list.forEach(room => {
const row = document.createElement('div');
row.className = 'public-room-item';
row.innerHTML = `
<div class="public-room-info">
<span class="public-room-host">${escHtml(room.hostName)}'s room</span>
<span class="public-room-meta">${room.playerCount}/4 · ${room.jokerMode ? '🃏' : '♠'} ${room.winScore}</span>
</div>
<button class="btn-join-pub btn-primary" data-room="${escHtml(room.id)}">Join</button>`;
row.querySelector('.btn-join-pub').addEventListener('click', () => {
$('input-code').value = room.id;
joinGame();
});
listEl.appendChild(row);
});
} catch {
listEl.innerHTML = '<p class="hint" style="color:#ff6b6b">Failed to load rooms.</p>';
}
}
function escHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
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 });
});
}
// ─── Socket ───────────────────────────────────────────────────
function connectSocket(onReady) {
if (socket?.connected) { onReady(); return; }
if (!socket) {
socket = io({ auth: { token: authToken } });
initSocketHandlers();
} else {
// Reuse the socket but make sure it carries the current auth token
socket.auth = { token: authToken };
}
socket.once('connect', onReady);
}
// Re-authenticate an existing socket after login/register so the server sees
// the user on this same connection (auth is only read at (re)connect time).
function reauthSocket() {
if (!socket) return;
socket.auth = { token: authToken };
if (socket.connected) socket.disconnect().connect();
}
function initSocketHandlers() {
// Persistent reconnect handler — 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();
showWaitingRoom();
});
socket.on('joined', ({ roomId, seat, token }) => {
myRoomId = roomId; mySeat = seat; myToken = token;
saveSession();
showWaitingRoom();
});
socket.on('spectating', ({ roomId }) => {
myRoomId = roomId; mySeat = -1;
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();
});
// Server pushes this when an authenticated user connects with an in-progress game
// on a different device (token on that device is unknown / stale)
socket.on('hasActiveGame', ({ roomId, seat, token }) => {
if (myRoomId) return; // already have a session, ignore
myRoomId = roomId; mySeat = seat; myToken = token;
saveSession();
socket.emit('rejoin', { roomId, seat, token });
});
socket.on('joinError', ({ message } = {}) => { $('lobby-error').textContent = message || 'Could not join room.'; });
socket.on('spectateError',({ message } = {}) => { $('lobby-error').textContent = message || 'Could not spectate.'; });
socket.on('rejoinError', () => {
// Always clear stale session and return to lobby — hasActiveGame will
// re-establish it for authenticated users who have a game on another device
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('roomInfo', render);
socket.on('cardPlayed',render);
socket.on('trickWon', render);
socket.on('handOver', onHandOver);
socket.on('gameOver', onGameOver);
socket.on('afkWarning', ({ name } = {}) => {
afkBannerVoted = false;
$('afk-banner-msg').textContent = `${name} hasn't played for 1 minute.`;
const btn = $('afk-vote-btn');
if (btn) { btn.disabled = false; btn.textContent = 'Let AI Play'; }
show('afk-banner');
});
socket.on('afkResolved', () => {
afkBannerVoted = false;
hide('afk-banner');
});
socket.on('aiControl', ({ seat, active, name } = {}) => {
if (active) aiControlledSeats.add(seat);
else aiControlledSeats.delete(seat);
updateAiControlBanner();
});
socket.on('disconnect', () => {});
}
// ─── Session persistence (localStorage so it survives tab/app close) ──────────
// NOTE: the game-session (seat) token uses its OWN key. It must never share a
// key with the auth JWT ('shelem_token'), or joining a game would overwrite the
// login token and drop the player to an unauthenticated guest.
function saveSession() {
localStorage.setItem('shelem_room', myRoomId);
localStorage.setItem('shelem_seat', mySeat);
localStorage.setItem('shelem_seat_token', myToken);
}
function clearSession() {
localStorage.removeItem('shelem_room');
localStorage.removeItem('shelem_seat');
localStorage.removeItem('shelem_seat_token');
}
function tryRejoin() {
const room = localStorage.getItem('shelem_room');
const seat = localStorage.getItem('shelem_seat');
const token = localStorage.getItem('shelem_seat_token');
if (!room || seat === null || !token) return;
// Set inside the callback so the persistent 'connect' handler doesn't
// also emit rejoin on the very first connection (would be a duplicate)
connectSocket(() => {
myRoomId = room; mySeat = +seat; myToken = token;
socket.emit('rejoin', { roomId: room, seat: +seat, token });
});
}
// ─── Waiting Room ──────────────────────────────────────────────
function showWaitingRoom() {
showScreen('screen-waiting');
}
function renderWaitingRoom(st) {
$('display-room-code').textContent = st.id;
show('waiting-options');
$('waiting-mode-label').textContent = st.jokerMode ? '🃏 With Jokers' : '♠ No Jokers';
$('waiting-score-label').textContent = `Win: ${st.winScore}`;
// Public badge
let pubBadge = $('waiting-public-badge');
if (!pubBadge) {
pubBadge = document.createElement('span');
pubBadge.id = 'waiting-public-badge';
pubBadge.className = 'waiting-opt-label';
$('waiting-options').appendChild(pubBadge);
}
pubBadge.textContent = st.isPublic ? '🌐 Public' : '🔒 Private';
const isCreator = mySeat === 0;
// Seat slots — with swap UI for creator
const slots = document.querySelectorAll('.seat-slot');
slots.forEach(slot => {
const s = +slot.dataset.seat;
const name = slot.querySelector('.seat-name');
name.textContent = st.names[s] || (st.bots[s] ? '🤖 Bot' : '—');
if (isCreator) {
slot.classList.toggle('seat-swappable', true);
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;
}
});
// Status text
const humanCount = st.seats ? st.seats.filter(Boolean).length : 0;
const botCount = st.bots.filter(Boolean).length;
const filled = humanCount + botCount;
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;
// Creator controls
if (isCreator) show('waiting-creator-controls'); else hide('waiting-creator-controls');
}
// ─── Main render ──────────────────────────────────────────────
function render(st) {
if (!st) return;
lastState = st;
if (st.state === 'WAITING') {
if (document.getElementById('screen-waiting')?.classList.contains('active')) {
renderWaitingRoom(st);
}
return;
}
// Transition to game screen if needed
if (!document.getElementById('screen-game')?.classList.contains('active')) {
showScreen('screen-game');
}
renderInfoBar(st);
renderTable(st);
renderOverlays(st);
}
// ─── Info bar ──────────────────────────────────────────────────
function renderInfoBar(st) {
// Team scores
const t0names = [st.names[0], st.names[2]].filter(Boolean).join(' & ');
const t1names = [st.names[1], st.names[3]].filter(Boolean).join(' & ');
const ts0 = $('team-score-0');
const ts1 = $('team-score-1');
ts0.className = 'team-score team-a';
ts1.className = 'team-score team-b';
ts0.textContent = `${t0names || 'Team A'}: ${st.scores[0]}`;
ts1.textContent = `${t1names || 'Team B'}: ${st.scores[1]}`;
// Trump + bid
const td = $('trump-display');
const bd = $('bid-display');
if (st.trump) {
td.textContent = `Hokm:\n${suitSymbol(st.trump)}`;
show('trump-display');
} else {
hide('trump-display');
}
if (st.highBid > 0 && st.state !== 'HAND_OVER' && st.state !== 'GAME_OVER') {
bd.textContent = `Bid: ${st.highBid} (${st.names[st.highBidder] || '?'})`;
show('bid-display');
} else {
hide('bid-display');
}
}
// ─── Table ────────────────────────────────────────────────────
function renderTable(st) {
const slots = { bottom: mySeat < 0 ? 0 : mySeat,
top: mySeat < 0 ? 2 : (mySeat + 2) % 4,
left: mySeat < 0 ? 1 : (mySeat + 1) % 4,
right: mySeat < 0 ? 3 : (mySeat + 3) % 4 };
// Player labels & card backs for opponents
renderOpponent('top', slots.top, st);
renderOpponent('left', slots.left, st);
renderOpponent('right', slots.right, st);
// Trick area
renderTrick(st, slots);
// My hand
renderMyHand(st);
// My info
if (mySeat >= 0) {
$('my-name').textContent = st.names[mySeat] || 'You';
const myTeam = teamOf(mySeat);
const partnerSeat = (mySeat + 2) % 4;
$('my-score').textContent = st.scores[myTeam];
// Partner label color
const pm = $('my-partner-label');
pm.textContent = `${st.names[partnerSeat] || ''}`;
pm.classList.remove('hidden');
// Turn dot
st.currentTurn === mySeat && st.state === 'PLAYING'
? show('my-turn') : hide('my-turn');
// Tricks badge
const tricks = st.trickWins[mySeat];
if (tricks > 0) {
$('my-tricks').textContent = `${tricks} trick${tricks > 1 ? 's' : ''}`;
show('my-tricks');
} else {
hide('my-tricks');
}
}
// Phase message — whose turn it is
let msg = '';
let myTurnNow = false;
if (st.state === 'BIDDING') {
const bidder = st.names[st.currentBidder] || '?';
msg = st.currentBidder === mySeat ? 'Your bid' : `${bidder} bidding`;
} else if (st.state === 'WIDOW') {
const dec = st.names[st.declarer] || '?';
msg = st.declarer === mySeat ? 'Pick widow' : `${dec} picking widow`;
} 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 won!' : `${w.slice(0,9)} won`;
// Still need to lead — turn reminder must fire even during the "won" flash
if (st.currentTurn === mySeat) myTurnNow = true;
} else if (st.currentTurn === mySeat) {
msg = 'Your turn!';
myTurnNow = true;
} else if (st.currentTurn >= 0) {
msg = `${(st.names[st.currentTurn] || '?').slice(0,9)}'s turn`;
}
}
const pm = $('phase-msg');
pm.textContent = msg;
pm.classList.toggle('your-turn', myTurnNow);
handleTurnReminder(myTurnNow && !spectating);
// Spectator banner
spectating ? show('spectator-banner') : hide('spectator-banner');
}
function renderOpponent(slot, seat, st) {
const nameEl = $(`${slot}-name`);
const turnEl = $(`${slot}-turn`);
const scoreEl = $(`${slot}-score`);
const countEl = $(`${slot}-count`);
const partnerEl = $(`${slot}-partner`);
nameEl.textContent = st.names[seat] || '—';
scoreEl.textContent = st.scores[teamOf(seat)];
if (st.currentTurn === seat && st.state === 'PLAYING') show(`${slot}-turn`);
else hide(`${slot}-turn`);
// Card count badge
if (countEl) {
const n = typeof st.hands[seat] === 'number' ? st.hands[seat] : st.hands[seat].length;
countEl.textContent = n > 0 ? `${n}` : '';
}
// Partner badge for top player
if (slot === 'top' && mySeat >= 0 && partnerEl) {
const isPartner = teamOf(seat) === teamOf(mySeat);
isPartner ? show(`${slot}-partner`) : hide(`${slot}-partner`);
}
}
function renderTrick(st, slots) {
const trickMap = {};
for (const t of st.trick) trickMap[t.player] = t.card;
const positions = [
{ id: 'trick-bottom', seat: slots.bottom },
{ id: 'trick-top', seat: slots.top },
{ id: 'trick-left', seat: slots.left },
{ id: 'trick-right', seat: slots.right },
];
for (const { id, seat } of positions) {
const el = $(id);
el.innerHTML = '';
if (trickMap[seat]) {
el.appendChild(makeTrickImg(trickMap[seat]));
}
}
// phase-msg for trick winner is handled in renderTable
}
function renderMyHand(st) {
const handEl = $('my-hand');
handEl.innerHTML = '';
if (mySeat < 0) return;
const myHand = st.hands[mySeat];
if (!Array.isArray(myHand) || myHand.length === 0) return;
const isMyTurn = st.state === 'PLAYING' && st.currentTurn === mySeat;
const legal = isMyTurn ? computeLegalCards(st, mySeat) : [];
// In "playables" mode only show the legal cards when it's my turn
const display = (handMode === 'playables' && isMyTurn) ? legal : myHand;
function doPlay(code) {
if (!isMyTurn) return;
if (!computeLegalCards(st, mySeat).includes(code)) return;
socket.emit('play', { roomId: myRoomId, seat: mySeat, token: myToken, card: code });
}
for (const code of display) {
const isLegal = legal.length === 0 || legal.includes(code);
const cardEl = makeCardEl(code, {
illegal: isMyTurn && !isLegal,
});
if (isMyTurn && isLegal) {
if (playMode === 'tap') {
cardEl.addEventListener('click', () => doPlay(code));
}
addDragHandlers(cardEl, code, doPlay);
}
handEl.appendChild(cardEl);
}
// Show/hide play-mode toggle (touch-only, during play)
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;
});
}
}
// ─── Legal cards (client-side mirror of server logic) ─────────
function computeLegalCards(st, seat) {
const hand = st.hands[seat];
const trick = st.trick;
const trump = st.trump;
if (!Array.isArray(hand)) return [];
function isTrump(c) {
if (c === 'JOKER-COLOR' || c === 'JOKER-BLACK') return true;
return c.split('-')[0] === trump;
}
function suitOf(c) {
if (c === 'JOKER-COLOR' || c === 'JOKER-BLACK') return 'JOKER';
return c.split('-')[0];
}
if (trick.length === 0) {
// First lead of the hand sets trump — jokers not allowed
if (!trump) return hand.filter(c => c !== 'JOKER-COLOR' && c !== 'JOKER-BLACK');
return hand;
}
const ledCard = trick[0].card;
const ledTrump = isTrump(ledCard);
if (ledTrump) {
const tc = hand.filter(c => isTrump(c));
return tc.length > 0 ? tc : hand;
}
const ls = suitOf(ledCard);
const sc = hand.filter(c => suitOf(c) === ls);
return sc.length > 0 ? sc : hand;
}
// ─── Overlays ─────────────────────────────────────────────────
function renderOverlays(st) {
const widowDeclarer = st.state === 'WIDOW' && mySeat === st.declarer;
// Hide the bid/hand overlays every render, but manage the widow overlay
// separately so an unrelated re-render (a spectator joining, a rejoin, etc.)
// does not wipe the declarer's in-progress card selection.
hide('overlay-bid');
hide('overlay-hand');
if (!widowDeclarer && widowActive) { widowActive = false; hide('overlay-widow'); }
// Restore actual hand mode if we temporarily forced fan mode during bidding
if (isTouchDevice()) applyHandMode();
if (st.state === 'BIDDING') {
renderBiddingOverlay(st);
} else if (widowDeclarer) {
renderWidowOverlay(st);
} else if (st.state === 'WIDOW' && mySeat !== st.declarer) {
$('phase-msg').textContent = `${st.names[st.declarer]} is picking up the widow…`;
}
}
function hideAllOverlays() {
hide('overlay-bid');
hide('overlay-widow');
hide('overlay-hand');
widowActive = false;
// Restore actual hand mode if we temporarily forced fan mode during bidding
if (isTouchDevice()) applyHandMode();
}
// ─── Bidding overlay ───────────────────────────────────────────
function renderBiddingOverlay(st) {
show('overlay-bid');
// On touch devices show hand in fan mode so players can see their cards while bidding
if (isTouchDevice()) {
$('my-hand')?.classList.add('fan-mode');
$('my-area')?.classList.add('fan-active');
requestAnimationFrame(updateHandSpacing);
}
const history = $('bid-history');
history.innerHTML = '';
for (let s = 0; s < 4; s++) {
const row = document.createElement('div');
row.className = 'bid-row' + (st.currentBidder === s ? ' active-bidder' : '');
const nameSpan = document.createElement('span');
nameSpan.className = 'bid-name';
nameSpan.textContent = (st.names[s] || `Seat ${s+1}`) + (s === mySeat ? ' (You)' : '');
const valSpan = document.createElement('span');
valSpan.className = 'bid-val';
if (st.bids[s] === null) {
valSpan.className += ' waiting';
valSpan.textContent = st.currentBidder === s ? '⟵ bidding' : '—';
} else if (st.bids[s] === 'pass') {
valSpan.className += ' passed';
valSpan.textContent = 'Pass';
} else {
valSpan.textContent = st.bids[s];
}
row.appendChild(nameSpan);
row.appendChild(valSpan);
history.appendChild(row);
}
const myBid = st.bids[mySeat];
// myBid can be a previous bid (number) — player may rebid higher; only 'pass' locks them out
const isMyTurn = st.currentBidder === mySeat && myBid !== 'pass' && !spectating;
if (isMyTurn) {
show('bid-controls');
hide('bid-waiting-msg');
const floorBid = st.jokerMode ? 105 : 85;
const minBid = st.highBid > 0 ? st.highBid + 5 : floorBid;
// Reset to minimum at the start of every new hand
if (st.handNumber !== lastBidHandNumber) {
currentBidAmount = minBid;
lastBidHandNumber = st.handNumber;
}
if (currentBidAmount < minBid) currentBidAmount = minBid;
$('bid-amount-display').textContent = currentBidAmount;
} else {
hide('bid-controls');
if (myBid === 'pass') {
$('bid-waiting-msg').textContent = 'You passed — waiting for bidding to finish…';
} else {
const bidder = st.names[st.currentBidder] || 'Another player';
$('bid-waiting-msg').textContent = `${bidder} is bidding…`;
}
show('bid-waiting-msg');
}
}
// ─── Widow overlay ────────────────────────────────────────────
function renderWidowOverlay(st) {
const needed = st.widowSize;
if (!widowActive) {
// First render of this WIDOW phase — initialize a fresh selection
widowActive = true;
widowSelected = [];
show('overlay-widow');
$('widow-title').textContent = `Pick up widow — discard ${needed} cards`;
$('widow-hint').textContent = `Select exactly ${needed} cards to discard. They count as your first trick.`;
} else {
// Re-render while already open — preserve the current selection, but drop
// any card that is somehow no longer in hand as a safety measure.
const hand = Array.isArray(st.hands[mySeat]) ? st.hands[mySeat] : [];
widowSelected = widowSelected.filter(c => hand.includes(c));
}
renderWidowHand(st);
updateWidowPreview(st);
}
const WIDOW_SUIT_ORDER = ['C', 'D', 'S', 'H'];
const WIDOW_SUIT_LABEL = { C: '♣', D: '♦', H: '♥', S: '♠' };
const WIDOW_JOKER_LABEL = { 'JOKER-COLOR': '🌈', 'JOKER-BLACK': '⚫' };
function renderWidowHand(st) {
const container = $('widow-hand');
container.innerHTML = '';
const hand = st.hands[mySeat];
if (!Array.isArray(hand)) return;
function onCardClick(c, el) {
const idx = widowSelected.indexOf(c);
if (idx >= 0) {
widowSelected.splice(idx, 1);
el.classList.remove('selected');
} else {
const needed = lastState?.widowSize || 4;
if (widowSelected.length < needed) {
widowSelected.push(c);
el.classList.add('selected');
}
}
updateWidowPreview(lastState);
}
// Render cards grouped by suit (like Hearts pass overlay)
WIDOW_SUIT_ORDER.forEach(s => {
const suitCards = hand.filter(c => c.startsWith(s + '-'));
if (suitCards.length === 0) return;
const row = document.createElement('div');
row.className = 'pass-suit-row';
const lbl = document.createElement('span');
lbl.className = 'pass-suit-label suit-' + s.toLowerCase();
lbl.textContent = WIDOW_SUIT_LABEL[s];
row.appendChild(lbl);
const wrap = document.createElement('div');
wrap.className = 'pass-suit-cards';
suitCards.forEach(code => {
const card = makeCardEl(code, { onClick: onCardClick });
if (widowSelected.includes(code)) card.classList.add('selected');
wrap.appendChild(card);
});
row.appendChild(wrap);
container.appendChild(row);
});
// Jokers as their own row at the end
const jokers = hand.filter(c => c.startsWith('JOKER-'));
if (jokers.length > 0) {
const row = document.createElement('div');
row.className = 'pass-suit-row';
const lbl = document.createElement('span');
lbl.className = 'pass-suit-label';
lbl.textContent = '🃏';
row.appendChild(lbl);
const wrap = document.createElement('div');
wrap.className = 'pass-suit-cards';
jokers.forEach(code => {
const card = makeCardEl(code, { onClick: onCardClick });
if (widowSelected.includes(code)) card.classList.add('selected');
wrap.appendChild(card);
});
row.appendChild(wrap);
container.appendChild(row);
}
}
function updateWidowPreview(st) {
const needed = st?.widowSize || 4;
const preview = $('widow-selected-preview');
preview.innerHTML = '';
for (let i = 0; i < needed; i++) {
if (widowSelected[i]) {
preview.appendChild(makeCardEl(widowSelected[i]));
} else {
const ph = document.createElement('div');
ph.className = 'pass-placeholder';
preview.appendChild(ph);
}
}
$('btn-confirm-discard').disabled = widowSelected.length !== needed;
}
// ─── Trump overlay ────────────────────────────────────────────
function renderTrumpOverlay(st) {
show('overlay-trump');
if (mySeat === st.declarer && !spectating) {
show('trump-declare-inner');
hide('trump-waiting-inner');
} else {
hide('trump-declare-inner');
show('trump-waiting-inner');
$('trump-waiting-msg').textContent =
`${st.names[st.declarer] || 'Declarer'} is choosing the trump suit…`;
}
}
// ─── Hand Over ────────────────────────────────────────────────
function onHandOver(st) {
lastState = st;
hideAllOverlays();
renderInfoBar(st);
const dTeam = st.declarer >= 0 ? st.declarer % 2 : 0;
const oTeam = 1 - dTeam;
const dNames = [st.names[dTeam === 0 ? 0 : 1], st.names[dTeam === 0 ? 2 : 3]].join(' & ');
const oNames = [st.names[oTeam === 0 ? 0 : 1], st.names[oTeam === 0 ? 2 : 3]].join(' & ');
const dDelta = st.handDeltas?.[dTeam] ?? 0;
const oDelta = st.handDeltas?.[oTeam] ?? 0;
let icon = '🎴', title = 'Hand Over';
if (st.isShelemHand) {
icon = '🎯';
title = 'SHELEM! All tricks won!';
} else if (dDelta < 0) {
icon = '😬';
title = `${dNames} failed the bid`;
} else {
icon = '✅';
title = `${dNames} made the bid`;
}
$('hand-result-icon').textContent = icon;
$('hand-result-title').textContent = title;
$('hand-result-detail').textContent =
`Bid: ${st.highBid} | Declarer earned: ${st.teamCardPoints?.[dTeam] ?? 0} pts`;
const scoresEl = $('hand-result-scores');
scoresEl.innerHTML = '';
for (let team = 0; team < 2; team++) {
const delta = st.handDeltas?.[team] ?? 0;
const names = [st.names[team === 0 ? 0 : 1], st.names[team === 0 ? 2 : 3]]
.filter(Boolean).join(' & ') || `Team ${team + 1}`;
const row = document.createElement('div');
row.className = 'result-score-row';
const isWinner = mySeat >= 0 && teamOf(mySeat) === team && delta > 0;
if (isWinner) row.classList.add('winner');
row.innerHTML = `
<span class="team-label">${names}</span>
<span class="delta ${delta >= 0 ? 'pos' : 'neg'}">${delta >= 0 ? '+' : ''}${delta}</span>
<span style="color:rgba(255,255,255,.6);font-size:.8rem">→ ${st.scores[team]}</span>
`;
scoresEl.appendChild(row);
}
// Round-by-round running totals (all rounds so far, current totals after the divider)
renderRoundBreakdown($('hand-round-breakdown'), st.roundHistory, st.teamNames, st.scores);
show('overlay-hand');
// overlay-hand is hidden by hideAllOverlays() when the next render() fires (BIDDING state)
}
// ─── Game Over ────────────────────────────────────────────────
function onGameOver(st) {
lastState = st;
hideAllOverlays();
hide('overlay-hand');
const winTeams = new Set(st.gameWinner || []);
const myTeam = mySeat >= 0 ? teamOf(mySeat) : -1;
const iWon = winTeams.has(myTeam);
$('gameover-title').textContent = iWon ? '🏆 Your team wins!' : '🎴 Game Over';
const scoresEl = $('gameover-scores');
scoresEl.innerHTML = '';
for (let team = 0; team < 2; team++) {
const names = [st.names[team === 0 ? 0 : 1], st.names[team === 0 ? 2 : 3]]
.filter(Boolean).join(' & ') || `Team ${team + 1}`;
const row = document.createElement('div');
row.className = 'gameover-row' + (winTeams.has(team) ? ' winner' : '');
row.innerHTML = `<span>${names}</span><span style="font-weight:700;color:${team===0?'var(--team-a)':'var(--team-b)'}">${st.scores[team]}</span>`;
scoresEl.appendChild(row);
}
// The round-by-round breakdown every player sees at the end of the game
renderRoundBreakdown($('gameover-breakdown'), st.roundHistory, st.teamNames, st.scores);
show('overlay-gameover');
}
// ─── Auth ─────────────────────────────────────────────────────
function updateAuthBar() {
if (authUser) {
$('auth-status').textContent = `Logged in as ${authUser}`;
show('btn-show-profile'); show('btn-logout'); hide('btn-show-auth');
} else {
$('auth-status').textContent = 'Playing as guest';
hide('btn-show-profile'); hide('btn-logout'); show('btn-show-auth');
}
}
async function doLogin() {
const username = $('auth-login-user').value.trim();
const password = $('auth-login-pass').value;
$('auth-login-error').textContent = '';
try {
const r = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const d = await r.json();
if (!r.ok) { $('auth-login-error').textContent = d.error || 'Login failed'; return; }
authToken = d.token; authUser = d.username;
localStorage.setItem('shelem_token', authToken);
localStorage.setItem('shelem_user', authUser);
hide('overlay-auth');
updateAuthBar();
reauthSocket();
} catch { $('auth-login-error').textContent = 'Network error'; }
}
async function doRegister() {
const username = $('auth-reg-user').value.trim();
const password = $('auth-reg-pass').value;
$('auth-reg-error').textContent = '';
if (!username || !password) { $('auth-reg-error').textContent = 'All fields required'; return; }
try {
const r = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const d = await r.json();
if (!r.ok) { $('auth-reg-error').textContent = d.error || 'Registration failed'; return; }
authToken = d.token; authUser = d.username;
localStorage.setItem('shelem_token', authToken);
localStorage.setItem('shelem_user', authUser);
hide('overlay-auth');
updateAuthBar();
reauthSocket();
} catch { $('auth-reg-error').textContent = 'Network error'; }
}
function doLogout() {
authToken = null; authUser = null;
localStorage.removeItem('shelem_token');
localStorage.removeItem('shelem_user');
updateAuthBar();
}
// ─── Profile ──────────────────────────────────────────────────
async function showProfile(username) {
if (!username) return;
showScreen('screen-profile');
$('profile-username').textContent = username;
try {
const r = await fetch(`/api/profile/${encodeURIComponent(username)}`);
const d = await r.json();
if (!r.ok) return;
$('stat-win-rate').textContent = (d.win_rate != null ? d.win_rate : 0) + '%';
$('stat-games-won').textContent = d.games_won;
$('stat-games-played').textContent = d.games_played;
$('stat-shelem').textContent = d.shelemCount || 0;
$('stat-bids-won').textContent = d.bids_won || 0;
$('stat-avg-score').textContent = d.avg_score != null ? d.avg_score : 0;
// History button only makes sense for a user who has an identity
$('btn-show-history').dataset.username = username;
// Change password button (only own profile)
if (username === authUser) show('btn-show-change-pass');
else hide('btn-show-change-pass');
// Admin panel
if (d.isAdmin) {
show('admin-panel');
const toggleBtn = $('btn-toggle-signups');
toggleBtn.textContent = d.signupsOpen ? 'Open ✓' : 'Closed ✗';
toggleBtn.classList.toggle('signup-open', d.signupsOpen);
} else {
hide('admin-panel');
}
} catch { /* ignore */ }
}
async function showLeaderboard() {
showScreen('screen-leaderboard');
try {
const r = await fetch('/api/leaderboard');
const rows = await r.json();
const tbody = $('lb-body');
tbody.innerHTML = '';
rows.forEach((row, i) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${i + 1}</td>
<td>${escHtml(row.username)}</td>
<td>${row.win_rate != null ? row.win_rate + '%' : '—'}</td>
<td>${row.games_won}</td>
<td>${row.games_played}</td>
<td>${row.avg_score ?? '—'}</td>
<td>${row.bids_won || 0}</td>
<td>${row.shelemCount || 0}</td>
`;
tbody.appendChild(tr);
});
} catch { /* ignore */ }
}
// ─── Round-by-round breakdown (shared) ────────────────────────
// Renders each round's point swing per team, then a divider column with the
// running/current total. Used at hand-over, game-over, and in game history.
function renderRoundBreakdown(container, rounds, teamNames, finalScores) {
if (!container) return;
if (!Array.isArray(rounds) || rounds.length === 0) {
container.innerHTML = '<p class="hint">No rounds played yet.</p>';
return;
}
const names = teamNames || ['Team 1', 'Team 2'];
const totals = finalScores || rounds[rounds.length - 1].scores;
// Rounds run down the page (one row each), teams are the two columns, and a
// horizontal line separates the final totals row at the bottom.
let html = '<div class="rb-scroll"><table class="round-breakdown"><thead><tr>' +
'<th class="rb-round"></th>' +
`<th>${escHtml(names[0] || 'Team 1')}</th>` +
`<th>${escHtml(names[1] || 'Team 2')}</th>` +
'</tr></thead><tbody>';
rounds.forEach(r => {
html += `<tr><td class="rb-round">R${r.hand}</td>`;
for (let t = 0; t < 2; t++) {
const d = (r.deltas && r.deltas[t]) || 0;
html += `<td class="${d > 0 ? 'pos' : d < 0 ? 'neg' : ''}">${d > 0 ? '+' : ''}${d}</td>`;
}
html += '</tr>';
});
html += '</tbody><tfoot><tr class="rb-final">' +
'<td class="rb-round">Total</td>' +
`<td>${totals[0]}</td><td>${totals[1]}</td>` +
'</tr></tfoot></table></div>';
container.innerHTML = html;
}
// ─── Game History ─────────────────────────────────────────────
async function showHistory(username) {
if (!username) return;
showScreen('screen-history');
$('history-title').textContent = username === authUser ? 'Your Game History' : `${username}'s History`;
hide('history-detail');
show('history-list');
const listEl = $('history-list');
listEl.innerHTML = '<p class="hint">Loading…</p>';
try {
const r = await fetch(`/api/history/${encodeURIComponent(username)}`);
const games = await r.json();
if (!Array.isArray(games) || games.length === 0) {
listEl.innerHTML = '<p class="hint">No games played yet.</p>';
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' });
row.innerHTML = `
<div class="history-item-main">
<span class="history-item-result">${g.won ? '🏆 Won' : '✖ Lost'}</span>
<span class="history-item-teams">${escHtml(g.teamNames[0])} vs ${escHtml(g.teamNames[1])}</span>
</div>
<div class="history-item-meta">
<span>${g.finalScores[0]} ${g.finalScores[1]}</span>
<span class="history-item-date">${date}</span>
</div>`;
row.addEventListener('click', () => showHistoryDetail(g));
listEl.appendChild(row);
});
} catch {
listEl.innerHTML = '<p class="hint" style="color:#ff6b6b">Failed to load history.</p>';
}
}
function showHistoryDetail(g) {
hide('history-list');
show('history-detail');
const date = new Date(g.endedAt).toLocaleString();
$('history-detail-title').textContent =
`${g.won ? '🏆 ' : ''}${g.teamNames[0]} vs ${g.teamNames[1]}${date}`;
const scoresEl = $('history-detail-scores');
scoresEl.innerHTML = '';
for (let t = 0; t < 2; t++) {
const won = (g.winnerTeams || []).includes(t);
const row = document.createElement('div');
row.className = 'gameover-row' + (won ? ' winner' : '');
row.innerHTML = `<span>${escHtml(g.teamNames[t])}</span><span style="font-weight:700">${g.finalScores[t]}</span>`;
scoresEl.appendChild(row);
}
renderRoundBreakdown($('history-detail-breakdown'), g.rounds, g.teamNames, g.finalScores);
}
function updateAiControlBanner() {
if (aiControlledSeats.size === 0) {
hide('ai-control-banner');
return;
}
const isMe = aiControlledSeats.has(mySeat);
let msg;
if (isMe) {
msg = '🤖 AI is playing for you! Play a card to take back control.';
} else {
const names = [...aiControlledSeats].map(s => lastState?.names[s] || `Seat ${s + 1}`);
msg = `🤖 AI is playing for: ${names.join(', ')}`;
}
$('ai-control-msg').textContent = msg;
$('ai-control-banner').classList.toggle('is-me', isMe);
show('ai-control-banner');
}
function clearGameState() {
aiControlledSeats.clear();
hide('ai-control-banner');
hide('afk-banner');
// Dismiss any phase overlay so it can't linger over the lobby after leaving
hide('overlay-bid');
hide('overlay-widow');
hide('overlay-trump');
hide('overlay-hand');
hide('overlay-gameover');
widowActive = false;
}
// ─── Event wiring ──────────────────────────────────────────────
function wireGameEvents() {
// Waiting room
$('btn-leave-waiting').addEventListener('click', () => {
socket?.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken });
clearSession();
showScreen('screen-lobby');
});
$('btn-waiting-menu').addEventListener('click', () => {
$('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');
socket?.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken });
clearSession();
showScreen('screen-lobby');
});
$('btn-copy').addEventListener('click', async () => {
const code = $('display-room-code').textContent.trim();
const btn = $('btn-copy');
try {
if (navigator.clipboard && 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(e) {
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', () => {
$('game-menu-dropdown').classList.toggle('hidden');
});
document.addEventListener('click', e => {
if (!e.target.closest('.game-menu-wrap')) {
hide('game-menu-dropdown');
hide('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', () => show('overlay-exit-confirm'));
// Leave buttons inside the bidding / widow / trump overlays (these overlays
// cover the info bar, so players need their own exit path during those phases)
document.querySelectorAll('[data-overlay-leave]').forEach(btn => {
btn.addEventListener('click', () => show('overlay-exit-confirm'));
});
$('btn-exit-confirm-yes').addEventListener('click', () => {
socket?.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken });
clearSession();
clearGameState();
hide('overlay-exit-confirm');
hide('overlay-hand');
hide('overlay-gameover');
mySeat = -1; myRoomId = null; myToken = null; spectating = false;
showScreen('screen-lobby');
});
$('btn-exit-confirm-no').addEventListener('click', () => hide('overlay-exit-confirm'));
// Bid controls
$('btn-bid-plus').addEventListener('click', () => {
currentBidAmount += 5;
$('bid-amount-display').textContent = currentBidAmount;
});
$('btn-bid-minus').addEventListener('click', () => {
const floorBid = lastState?.jokerMode ? 105 : 85;
const minBid = Math.max(floorBid, (lastState?.highBid || 0) + 5);
if (currentBidAmount - 5 >= minBid) currentBidAmount -= 5;
$('bid-amount-display').textContent = currentBidAmount;
});
$('btn-do-bid').addEventListener('click', () => {
if (!lastState) return;
const minBid = Math.max(85, (lastState.highBid || 80) + 5);
if (currentBidAmount < minBid) { currentBidAmount = minBid; }
socket?.emit('bid', { roomId: myRoomId, seat: mySeat, token: myToken, amount: currentBidAmount });
});
$('btn-do-pass').addEventListener('click', () => {
socket?.emit('bid', { roomId: myRoomId, seat: mySeat, token: myToken, amount: 'pass' });
});
// Discard confirm
$('btn-confirm-discard').addEventListener('click', () => {
if (widowSelected.length !== (lastState?.widowSize || 4)) return;
socket?.emit('discard', { roomId: myRoomId, seat: mySeat, token: myToken, cards: widowSelected });
});
// Trump declaration
document.querySelectorAll('.trump-suit-btn').forEach(btn => {
btn.addEventListener('click', () => {
socket?.emit('declareTrump', { roomId: myRoomId, seat: mySeat, token: myToken, trump: btn.dataset.suit });
});
});
// Play mode toggle (tap ↔ drag)
$('btn-play-mode')?.addEventListener('click', () => {
playMode = playMode === 'tap' ? 'drag' : 'tap';
savePlayMode(playMode);
updatePlayModeBtn();
if (lastState) renderMyHand(lastState);
});
// Hand display mode cycle: scroll → fan → playables → scroll
$('btn-hand-mode')?.addEventListener('click', () => {
const idx = HAND_MODES.indexOf(handMode);
handMode = HAND_MODES[(idx + 1) % HAND_MODES.length];
saveHandMode(handMode);
updateHandModeBtn();
if (lastState) renderMyHand(lastState);
});
// New game
$('btn-new-game').addEventListener('click', () => {
hide('overlay-gameover');
socket?.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken });
clearSession();
mySeat = -1; myRoomId = null; myToken = null;
showScreen('screen-lobby');
});
// Profile / leaderboard back buttons
$('btn-profile-back').addEventListener('click', () => showScreen('screen-lobby'));
$('btn-lb-back').addEventListener('click', () => showScreen('screen-lobby'));
// Game history
$('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 r = await fetch('/api/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`,
},
body: JSON.stringify({ currentPassword: cur, newPassword: nw }),
});
const d = await r.json();
$('change-pass-msg').textContent = r.ok ? '✓ Password updated' : (d.error || 'Failed');
$('change-pass-msg').style.color = r.ok ? '#69f0ae' : '#ff6b6b';
} catch { $('change-pass-msg').textContent = 'Network error'; }
});
$('afk-vote-btn')?.addEventListener('click', () => {
if (afkBannerVoted) return;
afkBannerVoted = true;
socket?.emit('voteAITakeover', { roomId: myRoomId, seat: mySeat, token: myToken });
const btn = $('afk-vote-btn');
if (btn) { btn.disabled = true; btn.textContent = 'Voted ✓'; }
});
$('afk-dismiss-btn')?.addEventListener('click', () => hide('afk-banner'));
$('btn-toggle-signups').addEventListener('click', async () => {
try {
const r = await fetch('/api/admin/toggle-signups', {
method: 'POST',
headers: { 'Authorization': `Bearer ${authToken}` },
});
const d = await r.json();
if (r.ok) {
const btn = $('btn-toggle-signups');
btn.textContent = d.signupsOpen ? 'Open ✓' : 'Closed ✗';
btn.classList.toggle('signup-open', d.signupsOpen);
}
} catch { /* ignore */ }
});
}
// Validate the stored auth token before trusting it. Catches tokens that are
// expired, or corrupted by the old localStorage key collision, so we don't run
// around as a "logged in" ghost whose socket is actually an unauthenticated guest.
async function validateAuth() {
if (!authToken) return;
try {
const r = await fetch('/api/me', { headers: { Authorization: `Bearer ${authToken}` } });
if (!r.ok) throw new Error('invalid');
const d = await r.json();
authUser = d.username;
localStorage.setItem('shelem_user', authUser);
} catch {
authToken = null; authUser = null;
localStorage.removeItem('shelem_token');
localStorage.removeItem('shelem_user');
}
updateAuthBar();
}
// Show the running app version in the lobby footer (served from /api/config).
// Tapping it force-updates: clears the service-worker caches and reloads, so a
// phone stuck on stale cached assets can pull the latest build.
async function loadAppVersion() {
try {
const d = await fetch('/api/config').then(r => r.json());
if (d.version) $('app-version').textContent = `v${d.version} · tap to update`;
} catch { /* ignore */ }
}
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 */ }
// SW gone and caches cleared — a plain reload now refetches everything fresh
location.reload();
}
// ─── Boot ─────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
initLobby();
wireGameEvents();
applyBarBottom();
loadAppVersion();
$('app-version').addEventListener('click', forceUpdate);
// Drop any stale/corrupted auth token before we use it to connect
await validateAuth();
// Pre-fill name from auth
if (authUser) $('input-name').value = authUser;
// Try to rejoin previous session
tryRejoin();
// For authenticated users with no local session, connect so the server
// can push hasActiveGame (enables cross-device rejoin)
if (authToken && !myRoomId) connectSocket(() => {});
});