diff --git a/.gitignore b/.gitignore
index 517063a..8b13a87 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,4 @@ data/
# Node
node_modules/
npm-debug.log*
+
diff --git a/docker-compose.yml b/compose.yml
similarity index 100%
rename from docker-compose.yml
rename to compose.yml
diff --git a/public/app.js b/public/app.js
index 7297426..64b18a5 100644
--- a/public/app.js
+++ b/public/app.js
@@ -12,15 +12,71 @@ let authUser = localStorage.getItem('shelem_user') || null;
let lastState = null;
// Lobby selections
-let selectedJoker = true;
-let selectedScore = 505;
+let selectedJoker = true;
+let selectedScore = 505;
+let selectedPublic = false;
+let swapSelectedSeat = -1; // seat highlighted for swap in waiting room
// Widow discard state
let widowSelected = [];
// Bid UI state
let currentBidAmount = 85;
-let lastBidHandNumber = -1; // detect new hands so we can reset the bid amount
+let lastBidHandNumber = -1;
+
+// Turn reminder state
+let turnReminderTimer = null;
+let turnReminderActive = false;
+
+// Unlock Web Audio on first user gesture (required by iOS)
+let _audioCtx = null;
+function getAudioCtx() {
+ if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
+ if (_audioCtx.state === 'suspended') _audioCtx.resume();
+ return _audioCtx;
+}
+document.addEventListener('pointerdown', () => getAudioCtx(), { once: true });
+
+function playTurnChime() {
+ try {
+ const ctx = getAudioCtx();
+ const now = ctx.currentTime;
+ // Two-note chime: G5 then B5
+ [[784, 0], [988, 0.18]].forEach(([freq, delay]) => {
+ const osc = ctx.createOscillator();
+ const gain = ctx.createGain();
+ osc.connect(gain); gain.connect(ctx.destination);
+ osc.type = 'sine';
+ osc.frequency.value = freq;
+ gain.gain.setValueAtTime(0, now + delay);
+ gain.gain.linearRampToValueAtTime(0.22, now + delay + 0.02);
+ gain.gain.exponentialRampToValueAtTime(0.001, now + delay + 0.45);
+ osc.start(now + delay);
+ osc.stop(now + delay + 0.5);
+ });
+ } catch (e) { /* audio not available */ }
+}
+
+function handleTurnReminder(isMyTurn) {
+ if (isMyTurn && !turnReminderActive) {
+ turnReminderActive = true;
+ $('my-area')?.classList.remove('turn-urgent');
+ turnReminderTimer = setTimeout(() => {
+ $('my-area')?.classList.add('turn-urgent');
+ // Vibrate on Android; chime on iOS (vibrate not supported there)
+ if (navigator.vibrate) {
+ navigator.vibrate([300, 100, 300]);
+ } else {
+ playTurnChime();
+ }
+ }, 5000);
+ } else if (!isMyTurn && (turnReminderActive || turnReminderTimer)) {
+ clearTimeout(turnReminderTimer);
+ turnReminderTimer = null;
+ turnReminderActive = false;
+ $('my-area')?.classList.remove('turn-urgent');
+ }
+}
// ─── Play / hand-display mode (mirror of Hearts) ──────────────
function isTouchDevice() {
@@ -261,6 +317,7 @@ function initLobby() {
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();
});
});
@@ -282,9 +339,19 @@ function initLobby() {
});
});
+ // 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');
@@ -316,10 +383,45 @@ function createGame() {
if (!name) { $('lobby-error').textContent = 'Please enter your name.'; return; }
$('lobby-error').textContent = '';
connectSocket(() => {
- socket.emit('create', { name, jokerMode: selectedJoker, winScore: selectedScore });
+ socket.emit('create', { name, jokerMode: selectedJoker, winScore: selectedScore, isPublic: selectedPublic });
});
}
+async function loadPublicRooms() {
+ const listEl = $('public-rooms-list');
+ listEl.innerHTML = '
Loading…
';
+ try {
+ const r = await fetch('/api/rooms');
+ const list = await r.json();
+ if (list.length === 0) {
+ listEl.innerHTML = 'No public rooms right now.
';
+ return;
+ }
+ listEl.innerHTML = '';
+ list.forEach(room => {
+ const row = document.createElement('div');
+ row.className = 'public-room-item';
+ row.innerHTML = `
+
+ ${escHtml(room.hostName)}'s room
+ ${room.playerCount}/4 · ${room.jokerMode ? '🃏' : '♠'} ${room.winScore}
+
+ `;
+ row.querySelector('.btn-join-pub').addEventListener('click', () => {
+ $('input-code').value = room.id;
+ joinGame();
+ });
+ listEl.appendChild(row);
+ });
+ } catch {
+ listEl.innerHTML = 'Failed to load rooms.
';
+ }
+}
+
+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();
@@ -344,9 +446,20 @@ function spectateGame() {
// ─── Socket ───────────────────────────────────────────────────
function connectSocket(onReady) {
if (socket?.connected) { onReady(); return; }
- socket = io({ auth: { token: authToken } });
+ if (!socket) {
+ socket = io({ auth: { token: authToken } });
+ initSocketHandlers();
+ }
+ socket.once('connect', onReady);
+}
- socket.on('connect', onReady);
+function initSocketHandlers() {
+ // Persistent reconnect handler — auto-rejoin after any network drop mid-game
+ socket.on('connect', () => {
+ if (myRoomId && mySeat >= 0 && myToken) {
+ socket.emit('rejoin', { roomId: myRoomId, seat: mySeat, token: myToken });
+ }
+ });
socket.on('created', ({ roomId, seat, token }) => {
myRoomId = roomId; mySeat = seat; myToken = token;
@@ -364,13 +477,34 @@ function connectSocket(onReady) {
});
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', () => { clearSession(); });
- socket.on('error', (msg) => { $('lobby-error').textContent = msg || 'Server error.'; });
- socket.on('playError', (msg) => { console.warn('playError:', msg); });
+ 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);
@@ -381,24 +515,26 @@ function connectSocket(onReady) {
socket.on('disconnect', () => {});
}
-// ─── Session persistence ──────────────────────────────────────
+// ─── Session persistence (localStorage so it survives tab/app close) ──────────
function saveSession() {
- sessionStorage.setItem('shelem_room', myRoomId);
- sessionStorage.setItem('shelem_seat', mySeat);
- sessionStorage.setItem('shelem_token', myToken);
+ localStorage.setItem('shelem_room', myRoomId);
+ localStorage.setItem('shelem_seat', mySeat);
+ localStorage.setItem('shelem_token', myToken);
}
function clearSession() {
- sessionStorage.removeItem('shelem_room');
- sessionStorage.removeItem('shelem_seat');
- sessionStorage.removeItem('shelem_token');
+ localStorage.removeItem('shelem_room');
+ localStorage.removeItem('shelem_seat');
+ localStorage.removeItem('shelem_token');
}
function tryRejoin() {
- const room = sessionStorage.getItem('shelem_room');
- const seat = sessionStorage.getItem('shelem_seat');
- const token = sessionStorage.getItem('shelem_token');
+ const room = localStorage.getItem('shelem_room');
+ const seat = localStorage.getItem('shelem_seat');
+ const token = localStorage.getItem('shelem_token');
if (!room || seat === null || !token) return;
- myRoomId = room; mySeat = +seat; myToken = token;
+ // 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 });
});
}
@@ -414,21 +550,57 @@ function renderWaitingRoom(st) {
$('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;
- $('waiting-status').textContent = filled < 4
+ let statusText = filled < 4
? `Waiting for ${4 - filled} more player${4 - filled > 1 ? 's' : ''}…`
- : 'Starting game…';
+ : 'Ready to start!';
+ if (isCreator && swapSelectedSeat >= 0) statusText = 'Tap another seat to swap ↕';
+ $('waiting-status').textContent = statusText;
- if (mySeat === 0) show('btn-fill-bots'); else hide('btn-fill-bots');
+ // Creator controls
+ if (isCreator) show('waiting-creator-controls'); else hide('waiting-creator-controls');
}
// ─── Main render ──────────────────────────────────────────────
@@ -526,46 +698,63 @@ function renderTable(st) {
}
}
- // Phase message
+ // Phase message — whose turn it is
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;
+ 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 name = $(`${slot}-name`);
- const turn = $(`${slot}-turn`);
- const score = $(`${slot}-score`);
- const cards = $(`${slot}-cards`);
+ const nameEl = $(`${slot}-name`);
+ const turnEl = $(`${slot}-turn`);
+ const scoreEl = $(`${slot}-score`);
+ const countEl = $(`${slot}-count`);
const partnerEl = $(`${slot}-partner`);
- name.textContent = st.names[seat] || '—';
- score.textContent = st.scores[teamOf(seat)];
+ 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`);
}
-
- // 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) {
@@ -586,9 +775,7 @@ function renderTrick(st, slots) {
}
}
- if (st.lastTrickWinner >= 0 && st.trick.length === 0) {
- $('phase-msg').textContent = `${st.names[st.lastTrickWinner]} wins the trick`;
- }
+ // phase-msg for trick winner is handled in renderTable
}
function renderMyHand(st) {
@@ -1076,6 +1263,21 @@ function wireGameEvents() {
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');
@@ -1101,13 +1303,19 @@ function wireGameEvents() {
$('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');
+ 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) {
@@ -1251,4 +1459,7 @@ document.addEventListener('DOMContentLoaded', () => {
// 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(() => {});
});
diff --git a/public/index.html b/public/index.html
index 4365af7..8ce33d5 100644
--- a/public/index.html
+++ b/public/index.html
@@ -57,6 +57,13 @@
+
+
+
+
+
+
+
@@ -67,6 +74,14 @@
+
+
@@ -81,7 +96,16 @@
-
+
+
+
+
Waiting for Players
Room Code
@@ -103,7 +127,10 @@
-
+
+
+
+
@@ -149,8 +176,8 @@
●
partner
+
-
@@ -159,8 +186,8 @@
—
●
+
-
@@ -182,8 +209,8 @@
—
●
+
-
diff --git a/public/style.css b/public/style.css
index 2323262..5cb0f0b 100644
--- a/public/style.css
+++ b/public/style.css
@@ -28,7 +28,11 @@ html, body {
/* ── Screens ──────────────────────────────────── */
.screen { display: none; }
-.screen.active { display: flex; flex-direction: column; height: 100vh; }
+.screen.active {
+ display: flex; flex-direction: column;
+ height: 100vh; /* fallback */
+ height: 100dvh; /* modern: excludes browser chrome / address bar */
+}
/* ── Lobby ────────────────────────────────────── */
#screen-lobby {
@@ -119,6 +123,26 @@ input[type=text], input[type=password], input[type=email] {
}
input:focus { border-color: var(--gold); }
+/* ── Visibility toggle (Private / Public) ─────── */
+.visibility-row { display: flex; gap: 6px; }
+.visibility-btn {
+ flex: 1;
+ padding: 8px 6px;
+ background: rgba(255,255,255,.07);
+ border: 1px solid rgba(255,255,255,.14);
+ border-radius: 8px;
+ color: var(--muted);
+ cursor: pointer;
+ font-size: .82rem;
+ text-align: center;
+ transition: background .15s;
+}
+.visibility-btn.active, .visibility-btn:hover {
+ background: rgba(245,197,24,.2);
+ border-color: var(--gold);
+ color: var(--gold);
+}
+
/* ── Mode / score buttons ─────────────────────── */
.mode-row, .score-row { display: flex; gap: 6px; }
.mode-btn, .score-btn {
@@ -239,17 +263,60 @@ input:focus { border-color: var(--gold); }
border: 1px solid rgba(245,197,24,.25);
border-radius: 12px;
}
+/* ── Public rooms list ────────────────────────── */
+.public-rooms-section { display: flex; flex-direction: column; gap: 6px; width: 100%; }
+.rooms-header {
+ display: flex; align-items: center; justify-content: space-between;
+ font-size: .78rem; color: var(--muted);
+}
+.rooms-label { font-weight: 600; letter-spacing: .5px; }
+.public-room-item {
+ display: flex; align-items: center; justify-content: space-between; gap: 8px;
+ padding: 8px 10px;
+ background: rgba(255,255,255,.05);
+ border: 1px solid rgba(255,255,255,.1);
+ border-radius: 8px;
+}
+.public-room-info { display: flex; flex-direction: column; gap: 2px; text-align: left; }
+.public-room-host { font-size: .85rem; font-weight: 600; color: #fff; }
+.public-room-meta { font-size: .72rem; color: var(--muted); }
+.btn-join-pub { padding: 5px 14px; font-size: .82rem; flex-shrink: 0; }
+
+/* ── Creator controls in waiting room ────────── */
+.waiting-creator-controls {
+ display: flex; gap: 8px; width: 100%;
+}
.btn-fill-bots {
- padding: 8px 16px;
+ flex: 1;
+ padding: 8px 10px;
background: rgba(255,255,255,.08);
border: 1px solid rgba(255,255,255,.16);
border-radius: 10px;
color: var(--muted);
cursor: pointer;
- font-size: .85rem;
- width: 100%;
+ font-size: .82rem;
}
.btn-fill-bots:hover { background: rgba(255,255,255,.16); color: #fff; }
+.btn-start-game {
+ flex: 1;
+ padding: 8px 10px;
+ background: rgba(100,200,100,.15);
+ border: 1px solid rgba(100,200,100,.35);
+ border-radius: 10px;
+ color: #a5d6a7;
+ cursor: pointer;
+ font-size: .82rem;
+ font-weight: 600;
+}
+.btn-start-game:hover { background: rgba(100,200,100,.28); color: #c8e6c9; }
+
+/* ── Seat swap (creator taps to reorder) ─────── */
+.seat-slot.seat-swappable { cursor: pointer; transition: background .12s, border-color .12s; }
+.seat-slot.seat-swappable:hover { background: rgba(255,255,255,.1); }
+.seat-slot.seat-swap-selected {
+ border-color: var(--gold);
+ background: rgba(245,197,24,.12);
+}
/* ── Game screen ──────────────────────────────── */
#screen-game { background: var(--felt); overflow: hidden; position: relative; }
@@ -523,11 +590,25 @@ input:focus { border-color: var(--gold); }
display: flex; align-items: center; justify-content: center; gap: 4px;
}
.trick-center-info {
- width: 60px; text-align: center; flex-shrink: 0;
+ flex: 0 0 80px;
+ text-align: center;
}
.phase-msg {
font-size: .72rem; color: rgba(255,255,255,.6);
line-height: 1.4;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.phase-msg.your-turn {
+ color: var(--gold);
+ font-weight: 700;
+ font-size: .78rem;
+ animation: your-turn-flash .8s ease-in-out infinite alternate;
+}
+@keyframes your-turn-flash {
+ from { opacity: .7; }
+ to { opacity: 1; }
}
/* Drop-zone pulse when dragging */
@@ -914,6 +995,37 @@ input:focus { border-color: var(--gold); }
.lb-table td { border-bottom: 1px solid rgba(255,255,255,.05); }
.lb-table tr:hover td { background: rgba(255,255,255,.04); }
+/* ── Waiting room top row (Leave + ☰ menu) ───── */
+.waiting-top-row {
+ display: flex;
+ width: 100%;
+ align-items: center;
+}
+
+/* ── Turn urgent reminder ──────────────────────── */
+@keyframes turn-urgent-glow {
+ 0%, 100% { border-top-color: rgba(0,0,0,.3); box-shadow: none; }
+ 50% {
+ border-top-color: rgba(245,197,24,.7);
+ box-shadow: 0 -2px 16px rgba(245,197,24,.3);
+ }
+}
+#my-area.turn-urgent {
+ animation: turn-urgent-glow .65s ease-in-out 6;
+}
+
+/* ── Card count badge (replaces card backs) ──── */
+.card-count-badge {
+ font-size: .65rem;
+ color: rgba(255,255,255,.55);
+ padding: 1px 5px;
+ background: rgba(255,255,255,.08);
+ border-radius: 8px;
+ min-width: 20px;
+ text-align: center;
+ flex-shrink: 0;
+}
+
/* ── Util ─────────────────────────────────────── */
.hidden { display: none !important; }
@@ -928,8 +1040,89 @@ input:focus { border-color: var(--gold); }
bottom: calc(100% + 6px);
}
-/* ── Mobile bidding: transparent panel at top so hand stays visible ── */
+/* ═══════════════════════════════════════════════
+ MOBILE OVERHAUL (touch devices / small screens)
+ ═══════════════════════════════════════════════ */
@media (pointer: coarse) {
+
+ /* Prevent text selection and system long-press menus on game elements */
+ #screen-game {
+ user-select: none;
+ -webkit-user-select: none;
+ }
+
+ /* Allow horizontal scroll in scroll mode, block vertical pull-to-refresh */
+ #my-hand {
+ touch-action: pan-x;
+ overscroll-behavior-x: contain;
+ }
+
+ /* In fan mode cards need full touch control for drag */
+ #my-hand.fan-mode {
+ touch-action: none;
+ }
+ #my-hand.fan-mode .card {
+ touch-action: none;
+ }
+
+ /* ── Info bar: compact ─────────────────────── */
+ #info-bar { padding: 3px 8px; gap: 5px; }
+ .team-score { font-size: .7rem; padding: 2px 5px; }
+ .trump-display, .bid-display { font-size: .68rem; padding: 2px 6px; }
+ .btn-leave-screen { font-size: .75rem; padding: 3px 7px; }
+
+ /* ── Table grid: narrow side columns ──────── */
+ #table-grid {
+ grid-template-columns: 36px 1fr 36px;
+ gap: 2px;
+ padding: 3px;
+ }
+
+ /* ── Top player label: minimal height ──────── */
+ .area-top .player-label {
+ font-size: .7rem;
+ padding: 2px 6px;
+ gap: 4px;
+ flex-wrap: wrap;
+ justify-content: center;
+ }
+ .area-top .player-score-badge { display: none; }
+
+ /* ── Side player labels: vertical, very narrow */
+ .player-label.vertical {
+ font-size: .6rem;
+ padding: 3px 2px;
+ gap: 3px;
+ writing-mode: vertical-rl;
+ transform: rotate(180deg);
+ overflow: hidden;
+ max-height: 140px;
+ text-align: center;
+ }
+ /* Rotate back the children that shouldn't be rotated */
+ .player-label.vertical .turn-dot {
+ writing-mode: horizontal-tb;
+ transform: none;
+ font-size: .55rem;
+ }
+ .player-label.vertical .player-score-badge { display: none; }
+ .player-label.vertical .card-count-badge {
+ writing-mode: horizontal-tb;
+ transform: none;
+ font-size: .55rem;
+ min-width: 16px;
+ }
+
+ /* ── My area: ensure it's always fully visible */
+ #my-area {
+ padding: 3px 6px calc(6px + env(safe-area-inset-bottom));
+ gap: 3px;
+ flex-shrink: 0;
+ }
+ #my-label { font-size: .72rem; gap: 4px; }
+ .btn-hand-mode, .btn-play-mode { font-size: .65rem; padding: 2px 6px; }
+
+ /* ── Bidding overlay: compact panel at top so hand stays visible ── */
#overlay-bid {
align-items: flex-start;
background: transparent;
@@ -942,17 +1135,28 @@ input:focus { border-color: var(--gold); }
max-width: 100%;
border-radius: 0 0 20px 20px;
border-top: none;
- background: rgba(8, 22, 8, 0.97);
- box-shadow: 0 6px 28px rgba(0,0,0,.75);
- max-height: 62vh;
+ background: rgba(6, 20, 6, 0.97);
+ box-shadow: 0 6px 28px rgba(0,0,0,.8);
+ max-height: 55vh;
}
+ /* Shrink bid history on mobile so it doesn't swallow the screen */
+ .bid-history { max-height: 110px; }
+ .bid-box h3 { font-size: .95rem; margin-bottom: 0; }
}
-/* ── Mobile tweaks ────────────────────────────── */
-@media (max-width: 480px) {
+/* ── Small phones (≤ 390px wide) ─────────────── */
+@media (pointer: coarse) and (max-width: 390px) {
+ :root { --card-w: 48px; --card-h: 70px; }
+ #table-grid { grid-template-columns: 32px 1fr 32px; }
+ .overlay-box { padding: 14px 12px; }
+ .bid-amount-display { font-size: 1.3rem; }
+ .trump-suit-btn { font-size: .82rem; padding: 10px 4px; }
+}
+
+/* ── Desktop / mouse-pointer fallback tweaks ─── */
+@media (max-width: 480px) and (pointer: fine) {
:root { --card-w: 54px; --card-h: 78px; }
#table-grid { grid-template-columns: 70px 1fr 70px; }
.overlay-box { padding: 18px 16px; }
.bid-amount-display { font-size: 1.4rem; }
- .trump-suit-btn { font-size: .88rem; padding: 11px 6px; }
}
diff --git a/public/sw.js b/public/sw.js
index 4eb65f6..3d6ca4d 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -1,5 +1,28 @@
'use strict';
-const CACHE_NAME = 'shelem-v1';
+const CACHE_NAME = 'shelem-v2';
+
+// ── Generate all 55 card paths ──────────────────────────────────
+const SUITS = ['CLUB', 'DIAMOND', 'HEART', 'SPADE'];
+const CARD_PATHS = [
+ '/cards/JOKER-1.svg',
+ '/cards/JOKER-2.svg',
+ '/cards/JOKER-3.svg',
+ ...SUITS.flatMap(s => [
+ `/cards/${s}-1.svg`,
+ `/cards/${s}-2.svg`,
+ `/cards/${s}-3.svg`,
+ `/cards/${s}-4.svg`,
+ `/cards/${s}-5.svg`,
+ `/cards/${s}-6.svg`,
+ `/cards/${s}-7.svg`,
+ `/cards/${s}-8.svg`,
+ `/cards/${s}-9.svg`,
+ `/cards/${s}-10.svg`,
+ `/cards/${s}-11-JACK.svg`,
+ `/cards/${s}-12-QUEEN.svg`,
+ `/cards/${s}-13-KING.svg`,
+ ]),
+];
const PRECACHE = [
'/',
@@ -10,6 +33,7 @@ const PRECACHE = [
'/icons/icon-192.png',
'/icons/icon-512.png',
'/icons/icon.svg',
+ ...CARD_PATHS,
];
self.addEventListener('install', event => {
@@ -33,6 +57,7 @@ self.addEventListener('fetch', event => {
if (event.request.method !== 'GET') return;
if (url.includes('/socket.io/')) return;
+ // Cards: always serve from cache (never change)
if (url.includes('/cards/')) {
event.respondWith(
caches.match(event.request).then(cached => {
@@ -46,6 +71,7 @@ self.addEventListener('fetch', event => {
return;
}
+ // Everything else: network-first, cache as offline fallback
event.respondWith(
fetch(event.request)
.then(res => {
diff --git a/server.js b/server.js
index 1367ea3..74e0d6e 100644
--- a/server.js
+++ b/server.js
@@ -216,6 +216,22 @@ app.get('/api/leaderboard', (_req, res) => {
res.json(rows);
});
+app.get('/api/rooms', (_req, res) => {
+ const list = [];
+ for (const [, room] of rooms) {
+ if (!room.isPublic || room.state !== 'WAITING') continue;
+ const playerCount = room.seats.filter(Boolean).length + room.bots.filter(Boolean).length;
+ list.push({
+ id: room.id,
+ playerCount,
+ jokerMode: room.jokerMode,
+ winScore: room.winScore,
+ hostName: room.names[0] || '?',
+ });
+ }
+ res.json(list);
+});
+
app.post('/api/admin/toggle-signups', requireAuth, (req, res) => {
if (!isAdmin(req.user)) return res.status(403).json({ error: 'Forbidden' });
config.signupsOpen = !config.signupsOpen;
@@ -337,6 +353,7 @@ function makeToken() {
function newRoom(id) {
return {
id,
+ isPublic: false,
state: 'WAITING',
names: ['', '', '', ''],
seats: [null, null, null, null],
@@ -414,6 +431,7 @@ function publicInfo(room, seat) {
gameWinner: room.gameWinner,
jokerMode: room.jokerMode,
winScore: room.winScore,
+ isPublic: room.isPublic,
spectatorCount: room.spectators.size,
};
}
@@ -844,14 +862,25 @@ io.use((socket, next) => {
io.on('connection', (socket) => {
const user = socket.data.user;
- if (user) userSockets.set(user.id, socket.id);
+ if (user) {
+ userSockets.set(user.id, socket.id);
+ // Notify authenticated user if they have an in-progress game (enables cross-device rejoin)
+ for (const [, room] of rooms) {
+ const seat = room.userIds.indexOf(user.id);
+ if (seat >= 0 && room.tokens[seat] !== null && room.state !== 'GAME_OVER') {
+ socket.emit('hasActiveGame', { roomId: room.id, seat, token: room.tokens[seat] });
+ break;
+ }
+ }
+ }
// ── Create room ────────────────────────────────────────────
- socket.on('create', ({ name, jokerMode, winScore } = {}) => {
+ socket.on('create', ({ name, jokerMode, winScore, isPublic } = {}) => {
if (!name?.trim()) return socket.emit('error', 'Name is required');
const id = Math.random().toString(36).slice(2, 8).toUpperCase();
const room = newRoom(id);
- room.jokerMode = jokerMode !== false; // default true
+ room.jokerMode = jokerMode !== false;
+ room.isPublic = isPublic === true;
const ws = [205, 505, 1005];
room.winScore = ws.includes(+winScore) ? +winScore : 505;
room.names[0] = name.trim().slice(0, 16);
@@ -871,11 +900,24 @@ io.on('connection', (socket) => {
if (!room) return socket.emit('joinError', 'Room not found');
if (room.state !== 'WAITING') return socket.emit('joinError', 'Game already in progress');
- let openSeat = -1;
+ // Prevent the same socket, user, or name from occupying more than one seat
+ const trimmedName = name.trim().toLowerCase();
for (let i = 0; i < 4; i++) {
- if (!room.seats[i] && !room.bots[i]) { openSeat = i; break; }
+ if (room.seats[i] === socket.id) return socket.emit('joinError', 'You are already in this room');
+ if (user?.id && room.userIds[i] === user.id) return socket.emit('joinError', 'You are already in this room');
+ if (room.seats[i] && room.names[i].toLowerCase() === trimmedName)
+ return socket.emit('joinError', 'That name is already taken in this room');
}
- if (openSeat === -1) return socket.emit('joinError', 'Room is full');
+
+ // For public rooms assign a random open seat; private rooms take first open seat
+ const openSeats = [];
+ for (let i = 0; i < 4; i++) {
+ if (!room.seats[i] && !room.bots[i]) openSeats.push(i);
+ }
+ if (openSeats.length === 0) return socket.emit('joinError', 'Room is full');
+ const openSeat = room.isPublic
+ ? openSeats[Math.floor(Math.random() * openSeats.length)]
+ : openSeats[0];
room.names[openSeat] = name.trim().slice(0, 16);
room.userIds[openSeat] = user?.id || null;
@@ -901,11 +943,21 @@ io.on('connection', (socket) => {
// ── Rejoin ─────────────────────────────────────────────────
socket.on('rejoin', ({ roomId, seat, token } = {}) => {
const room = rooms.get((roomId || '').toUpperCase());
- if (!room) return socket.emit('rejoinError', 'Room no longer exists');
- if (room.tokens[seat] !== token) return socket.emit('rejoinError', 'Invalid session token');
+ if (!room) return socket.emit('rejoinError', 'not_found');
+
+ const tokenOk = room.tokens[seat] === token;
+ const userOk = user && room.userIds[seat] != null
+ && room.userIds[seat] === user.id
+ && room.tokens[seat] !== null; // null means player explicitly left
+
+ if (!tokenOk && !userOk) return socket.emit('rejoinError', 'bad_token');
+
+ // Re-issue token when only userId matched (e.g. different device)
+ if (!tokenOk) room.tokens[seat] = makeToken();
+
room.seats[seat] = socket.id;
socket.join(room.id);
- socket.emit('rejoined', { roomId: room.id, seat, token });
+ socket.emit('rejoined', { roomId: room.id, seat, token: room.tokens[seat] });
socket.emit('roomInfo', publicInfo(room, seat));
broadcastState(room, 'roomInfo');
});
@@ -926,6 +978,42 @@ io.on('connection', (socket) => {
tryStartGame(room);
});
+ // ── Swap seats (creator only) ──────────────────────────────
+ socket.on('swapSeats', ({ roomId, seatA, seatB } = {}) => {
+ const room = rooms.get((roomId || '').toUpperCase());
+ if (!room || room.state !== 'WAITING') return;
+ const isCreator = room.seats[0] === socket.id ||
+ (user?.id && room.userIds[0] === user.id);
+ if (!isCreator) return;
+ if (seatA === seatB || ![0,1,2,3].includes(seatA) || ![0,1,2,3].includes(seatB)) return;
+
+ for (const key of ['names', 'seats', 'tokens', 'userIds', 'bots']) {
+ [room[key][seatA], room[key][seatB]] = [room[key][seatB], room[key][seatA]];
+ }
+ // Tell each affected human player their new seat and token
+ if (room.seats[seatA]) io.to(room.seats[seatA]).emit('seatChanged', { seat: seatA, token: room.tokens[seatA] });
+ if (room.seats[seatB]) io.to(room.seats[seatB]).emit('seatChanged', { seat: seatB, token: room.tokens[seatB] });
+ broadcastState(room, 'roomInfo');
+ });
+
+ // ── Force start (creator only, fills remaining seats with bots) ──
+ socket.on('forceStart', ({ roomId } = {}) => {
+ const room = rooms.get((roomId || '').toUpperCase());
+ if (!room || room.state !== 'WAITING') return;
+ const isCreator = room.seats[0] === socket.id ||
+ (user?.id && room.userIds[0] === user.id);
+ if (!isCreator) return;
+ const botNames = ['Ali', 'Mina', 'Reza', 'Sara'];
+ for (let i = 0; i < 4; i++) {
+ if (!room.seats[i] && !room.bots[i]) {
+ room.bots[i] = true;
+ room.names[i] = botNames[i];
+ }
+ }
+ dealHand(room);
+ startBidding(room);
+ });
+
// ── Bid ────────────────────────────────────────────────────
socket.on('bid', ({ roomId, seat, token, amount } = {}) => {
const room = rooms.get((roomId || '').toUpperCase());
@@ -958,8 +1046,9 @@ io.on('connection', (socket) => {
const room = rooms.get((roomId || '').toUpperCase());
if (!room) return;
if (room.tokens[seat] === token) {
- room.seats[seat] = null;
- room.tokens[seat] = null;
+ room.seats[seat] = null;
+ room.tokens[seat] = null;
+ room.userIds[seat] = null; // prevent hasActiveGame from pulling them back in
}
socket.leave(room.id);
});