'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; // Widow discard state let widowSelected = []; // Bid UI state let currentBidAmount = 85; let lastBidHandNumber = -1; // detect new hands so we can reset the bid amount // ─── 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'); }); }); // 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; }); }); $('btn-create').addEventListener('click', createGame); $('btn-join').addEventListener('click', joinGame); $('btn-spectate').addEventListener('click', spectateGame); $('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 }); }); } 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; } socket = io({ auth: { token: authToken } }); socket.on('connect', onReady); 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; }); 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', () => { clearSession(); }); 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 ────────────────────────────────────── function saveSession() { sessionStorage.setItem('shelem_room', myRoomId); sessionStorage.setItem('shelem_seat', mySeat); sessionStorage.setItem('shelem_token', myToken); } function clearSession() { sessionStorage.removeItem('shelem_room'); sessionStorage.removeItem('shelem_seat'); sessionStorage.removeItem('shelem_token'); } function tryRejoin() { const room = sessionStorage.getItem('shelem_room'); const seat = sessionStorage.getItem('shelem_seat'); const token = sessionStorage.getItem('shelem_token'); if (!room || seat === null || !token) return; myRoomId = room; mySeat = +seat; myToken = token; connectSocket(() => { 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}`; 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' : '—'); }); const humanCount = st.seats ? st.seats.filter(Boolean).length : 0; const botCount = st.bots.filter(Boolean).length; const filled = humanCount + botCount; $('waiting-status').textContent = filled < 4 ? `Waiting for ${4 - filled} more player${4 - filled > 1 ? 's' : ''}…` : 'Starting game…'; if (mySeat === 0) show('btn-fill-bots'); else hide('btn-fill-bots'); } // ─── 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 let msg = ''; if (st.state === 'BIDDING') msg = 'Bidding…'; else if (st.state === 'WIDOW') msg = 'Picking widow…'; else if (st.state === 'PLAYING' && !st.trump) msg = 'First card sets trump…'; else if (st.state === 'PLAYING' && st.trump) msg = `Trump: ${suitSymbol(st.trump)}`; $('phase-msg').textContent = msg; // Spectator banner spectating ? show('spectator-banner') : hide('spectator-banner'); } function renderOpponent(slot, seat, st) { const name = $(`${slot}-name`); const turn = $(`${slot}-turn`); const score = $(`${slot}-score`); const cards = $(`${slot}-cards`); const partnerEl = $(`${slot}-partner`); name.textContent = st.names[seat] || '—'; score.textContent = st.scores[teamOf(seat)]; if (st.currentTurn === seat && st.state === 'PLAYING') show(`${slot}-turn`); else hide(`${slot}-turn`); // Partner badge for top player if (slot === 'top' && mySeat >= 0 && partnerEl) { const isPartner = teamOf(seat) === teamOf(mySeat); isPartner ? show(`${slot}-partner`) : hide(`${slot}-partner`); } // Show card backs cards.innerHTML = ''; const count = typeof st.hands[seat] === 'number' ? st.hands[seat] : st.hands[seat].length; const vertical = slot === 'left' || slot === 'right'; for (let i = 0; i < count; i++) { const cb = makeCardBack(); if (vertical) cb.style.width = '14px'; cards.appendChild(cb); } } 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])); } } if (st.lastTrickWinner >= 0 && st.trick.length === 0) { $('phase-msg').textContent = `${st.names[st.lastTrickWinner]} wins the trick`; } } 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 = ` ${names} ${delta >= 0 ? '+' : ''}${delta} → ${st.scores[team]} `; 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 = `${names}${st.scores[team]}`; 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 = `