Files
shelem/public/app.js
T
goyban a4fefd92f1 Feature: public rooms, mobile UX, reconnection, and gameplay fixes
Rooms & lobby
- Rename docker-compose.yml → compose.yml
- Public/Private toggle on room creation; public rooms assign random seats
  to prevent team collusion
- GET /api/rooms API — lists open public rooms; Join tab shows live list
  with one-tap join
- Room creator: swap any two seats by tapping (select-to-swap UI); ▶ Start
  Game button force-starts with bots filling empty seats

Reconnection
- Session moved from sessionStorage → localStorage (survives browser close)
- Socket handlers split: socket.once for one-shot callbacks, persistent
  socket.on('connect') for auto-rejoin on network drops
- Server rejoin accepts userId match as fallback (cross-device rejoin for
  authenticated users); re-issues token on success
- Server emits hasActiveGame on connect so auth'd users on a new device are
  pulled back into their game automatically
- Explicit leave nulls seat/token/userIds so hasActiveGame never re-drags a
  player back in after they chose to leave

Mobile UX
- Remove all opponent/partner card backs; replace with compact card-count
  badge — frees ~120px of vertical space on small phones
- Screen height: 100dvh (dynamic viewport) instead of 100vh — fixes the
  "only top 1/5 visible" issue on phones with browser chrome
- Table grid side columns shrunk to 36px on touch devices; player names
  rotated vertically
- Bidding overlay: transparent non-blocking top panel on touch; hand stays
  visible and interactive; auto fan-mode during bidding
- touch-action: pan-x on hand scroll, none in fan/drag mode — suppresses
  Android back-gesture and Google Gemini conflicts
- user-select: none on game screen prevents long-press selection menus

Gameplay & notifications
- Center trick area now shows whose turn it is instead of trump (trump is
  already in the info bar); flashes gold when it's the player's turn
- Turn reminder after 5 s of inaction: gold glow pulse on hand area
  + Android vibration OR two-note Web Audio chime on iOS (vibrate API not
  supported by Apple)
- Fix: turn reminder was never triggered after winning a trick — justWon
  branch blocked myTurnNow from being set even when currentTurn === mySeat
- Waiting room ☰ menu: Reload and Exit accessible without entering the game
- Prevent duplicate room joins (same socket, same userId, or same name)

Service worker
- Bump to shelem-v2; pre-cache all 55 card SVGs at install time so cards
  are available instantly from the very first hand, including offline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:42:00 +00:00

1466 lines
52 KiB
JavaScript

'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 = [];
// Bid UI state
let currentBidAmount = 85;
let lastBidHandNumber = -1;
// Turn reminder state
let turnReminderTimer = null;
let turnReminderActive = false;
// 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 handleTurnReminder(isMyTurn) {
if (isMyTurn && !turnReminderActive) {
turnReminderActive = true;
$('my-area')?.classList.remove('turn-urgent');
turnReminderTimer = setTimeout(() => {
$('my-area')?.classList.add('turn-urgent');
// Vibrate on Android; chime on iOS (vibrate not supported there)
if (navigator.vibrate) {
navigator.vibrate([300, 100, 300]);
} else {
playTurnChime();
}
}, 5000);
} else if (!isMyTurn && (turnReminderActive || turnReminderTimer)) {
clearTimeout(turnReminderTimer);
turnReminderTimer = null;
turnReminderActive = false;
$('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');
return ['scroll','fan','playables'].includes(s) ? s : '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-1.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();
}
socket.once('connect', onReady);
}
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('disconnect', () => {});
}
// ─── Session persistence (localStorage so it survives tab/app close) ──────────
function saveSession() {
localStorage.setItem('shelem_room', myRoomId);
localStorage.setItem('shelem_seat', mySeat);
localStorage.setItem('shelem_token', myToken);
}
function clearSession() {
localStorage.removeItem('shelem_room');
localStorage.removeItem('shelem_seat');
localStorage.removeItem('shelem_token');
}
function tryRejoin() {
const room = localStorage.getItem('shelem_room');
const seat = localStorage.getItem('shelem_seat');
const token = localStorage.getItem('shelem_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 = `Trump: ${suitSymbol(st.trump)} ${suitName(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) {
hideAllOverlays();
if (st.state === 'BIDDING') {
renderBiddingOverlay(st);
} else if (st.state === 'WIDOW' && mySeat === st.declarer) {
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');
// 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) {
widowSelected = [];
show('overlay-widow');
const needed = st.widowSize;
$('widow-title').textContent = `Pick up widow — discard ${needed} cards`;
$('widow-hint').textContent = `Select exactly ${needed} cards to discard. They count as your first trick.`;
renderWidowHand(st);
updateWidowPreview(st);
}
const WIDOW_SUIT_ORDER = ['C', 'D', 'H', 'S'];
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);
}
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);
}
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();
if (socket) socket.auth = { token: authToken };
} 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();
} 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-games-played').textContent = d.games_played;
$('stat-games-won').textContent = d.games_won;
$('stat-shelem').textContent = d.shelemCount || 0;
$('stat-total-score').textContent = d.total_score;
// 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>${row.username}</td>
<td>${row.score_per_game ?? '—'}</td>
<td>${row.games_won}</td>
<td>${row.games_played}</td>
<td>${row.shelemCount || 0}</td>
`;
tbody.appendChild(tr);
});
} catch { /* ignore */ }
}
// ─── 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'));
$('btn-exit-confirm-yes').addEventListener('click', () => {
socket?.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken });
clearSession();
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'));
// 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'; }
});
$('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 */ }
});
}
// ─── Boot ─────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
initLobby();
wireGameEvents();
applyBarBottom();
// 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(() => {});
});