23ae455ec1
Reorder the suit sort (hand + widow overlay) to alternate colours (clubs, diamonds, spades, hearts) so hearts and diamonds no longer sit next to each other. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1750 lines
64 KiB
JavaScript
1750 lines
64 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 = [];
|
||
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-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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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(() => {});
|
||
});
|