Feature: public rooms, mobile UX, reconnection, and gameplay fixes

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
goyban
2026-05-19 20:42:00 +00:00
parent 8e8478e45b
commit a4fefd92f1
7 changed files with 636 additions and 78 deletions
+1
View File
@@ -7,3 +7,4 @@ data/
# Node # Node
node_modules/ node_modules/
npm-debug.log* npm-debug.log*
View File
+260 -49
View File
@@ -12,15 +12,71 @@ let authUser = localStorage.getItem('shelem_user') || null;
let lastState = null; let lastState = null;
// Lobby selections // Lobby selections
let selectedJoker = true; let selectedJoker = true;
let selectedScore = 505; let selectedScore = 505;
let selectedPublic = false;
let swapSelectedSeat = -1; // seat highlighted for swap in waiting room
// Widow discard state // Widow discard state
let widowSelected = []; let widowSelected = [];
// Bid UI state // Bid UI state
let currentBidAmount = 85; 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) ────────────── // ─── Play / hand-display mode (mirror of Hearts) ──────────────
function isTouchDevice() { function isTouchDevice() {
@@ -261,6 +317,7 @@ function initLobby() {
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active'); btn.classList.add('active');
$('tab-' + btn.dataset.tab)?.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-create').addEventListener('click', createGame);
$('btn-join').addEventListener('click', joinGame); $('btn-join').addEventListener('click', joinGame);
$('btn-spectate').addEventListener('click', spectateGame); $('btn-spectate').addEventListener('click', spectateGame);
$('btn-refresh-rooms').addEventListener('click', loadPublicRooms);
$('btn-show-auth').addEventListener('click', () => { $('btn-show-auth').addEventListener('click', () => {
show('overlay-auth'); show('overlay-auth');
@@ -316,10 +383,45 @@ function createGame() {
if (!name) { $('lobby-error').textContent = 'Please enter your name.'; return; } if (!name) { $('lobby-error').textContent = 'Please enter your name.'; return; }
$('lobby-error').textContent = ''; $('lobby-error').textContent = '';
connectSocket(() => { 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 = '<p class="hint">Loading…</p>';
try {
const r = await fetch('/api/rooms');
const list = await r.json();
if (list.length === 0) {
listEl.innerHTML = '<p class="hint">No public rooms right now.</p>';
return;
}
listEl.innerHTML = '';
list.forEach(room => {
const row = document.createElement('div');
row.className = 'public-room-item';
row.innerHTML = `
<div class="public-room-info">
<span class="public-room-host">${escHtml(room.hostName)}'s room</span>
<span class="public-room-meta">${room.playerCount}/4 · ${room.jokerMode ? '🃏' : '♠'} ${room.winScore}</span>
</div>
<button class="btn-join-pub btn-primary" data-room="${escHtml(room.id)}">Join</button>`;
row.querySelector('.btn-join-pub').addEventListener('click', () => {
$('input-code').value = room.id;
joinGame();
});
listEl.appendChild(row);
});
} catch {
listEl.innerHTML = '<p class="hint" style="color:#ff6b6b">Failed to load rooms.</p>';
}
}
function escHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function joinGame() { function joinGame() {
const name = $('input-name').value.trim(); const name = $('input-name').value.trim();
const code = $('input-code').value.trim().toUpperCase(); const code = $('input-code').value.trim().toUpperCase();
@@ -344,9 +446,20 @@ function spectateGame() {
// ─── Socket ─────────────────────────────────────────────────── // ─── Socket ───────────────────────────────────────────────────
function connectSocket(onReady) { function connectSocket(onReady) {
if (socket?.connected) { onReady(); return; } 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 }) => { socket.on('created', ({ roomId, seat, token }) => {
myRoomId = roomId; mySeat = seat; myToken = token; myRoomId = roomId; mySeat = seat; myToken = token;
@@ -364,13 +477,34 @@ function connectSocket(onReady) {
}); });
socket.on('rejoined', ({ roomId, seat, token }) => { socket.on('rejoined', ({ roomId, seat, token }) => {
myRoomId = roomId; mySeat = seat; myToken = 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('joinError', ({ message } = {}) => { $('lobby-error').textContent = message || 'Could not join room.'; });
socket.on('spectateError',({ message } = {}) => { $('lobby-error').textContent = message || 'Could not spectate.'; }); socket.on('spectateError',({ message } = {}) => { $('lobby-error').textContent = message || 'Could not spectate.'; });
socket.on('rejoinError', () => { clearSession(); }); socket.on('rejoinError', () => {
socket.on('error', (msg) => { $('lobby-error').textContent = msg || 'Server error.'; }); // Always clear stale session and return to lobby — hasActiveGame will
socket.on('playError', (msg) => { console.warn('playError:', msg); }); // 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('roomInfo', render);
socket.on('cardPlayed',render); socket.on('cardPlayed',render);
@@ -381,24 +515,26 @@ function connectSocket(onReady) {
socket.on('disconnect', () => {}); socket.on('disconnect', () => {});
} }
// ─── Session persistence ────────────────────────────────────── // ─── Session persistence (localStorage so it survives tab/app close) ──────────
function saveSession() { function saveSession() {
sessionStorage.setItem('shelem_room', myRoomId); localStorage.setItem('shelem_room', myRoomId);
sessionStorage.setItem('shelem_seat', mySeat); localStorage.setItem('shelem_seat', mySeat);
sessionStorage.setItem('shelem_token', myToken); localStorage.setItem('shelem_token', myToken);
} }
function clearSession() { function clearSession() {
sessionStorage.removeItem('shelem_room'); localStorage.removeItem('shelem_room');
sessionStorage.removeItem('shelem_seat'); localStorage.removeItem('shelem_seat');
sessionStorage.removeItem('shelem_token'); localStorage.removeItem('shelem_token');
} }
function tryRejoin() { function tryRejoin() {
const room = sessionStorage.getItem('shelem_room'); const room = localStorage.getItem('shelem_room');
const seat = sessionStorage.getItem('shelem_seat'); const seat = localStorage.getItem('shelem_seat');
const token = sessionStorage.getItem('shelem_token'); const token = localStorage.getItem('shelem_token');
if (!room || seat === null || !token) return; 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(() => { connectSocket(() => {
myRoomId = room; mySeat = +seat; myToken = token;
socket.emit('rejoin', { roomId: room, seat: +seat, 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-mode-label').textContent = st.jokerMode ? '🃏 With Jokers' : '♠ No Jokers';
$('waiting-score-label').textContent = `Win: ${st.winScore}`; $('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'); const slots = document.querySelectorAll('.seat-slot');
slots.forEach(slot => { slots.forEach(slot => {
const s = +slot.dataset.seat; const s = +slot.dataset.seat;
const name = slot.querySelector('.seat-name'); const name = slot.querySelector('.seat-name');
name.textContent = st.names[s] || (st.bots[s] ? '🤖 Bot' : '—'); 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 humanCount = st.seats ? st.seats.filter(Boolean).length : 0;
const botCount = st.bots.filter(Boolean).length; const botCount = st.bots.filter(Boolean).length;
const filled = humanCount + botCount; const filled = humanCount + botCount;
$('waiting-status').textContent = filled < 4 let statusText = filled < 4
? `Waiting for ${4 - filled} more player${4 - filled > 1 ? 's' : ''}` ? `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 ────────────────────────────────────────────── // ─── Main render ──────────────────────────────────────────────
@@ -526,46 +698,63 @@ function renderTable(st) {
} }
} }
// Phase message // Phase message — whose turn it is
let msg = ''; let msg = '';
if (st.state === 'BIDDING') msg = 'Bidding…'; let myTurnNow = false;
else if (st.state === 'WIDOW') msg = 'Picking widow…'; if (st.state === 'BIDDING') {
else if (st.state === 'PLAYING' && !st.trump) msg = 'First card sets trump…'; const bidder = st.names[st.currentBidder] || '?';
else if (st.state === 'PLAYING' && st.trump) msg = `Trump: ${suitSymbol(st.trump)}`; msg = st.currentBidder === mySeat ? 'Your bid' : `${bidder} bidding`;
$('phase-msg').textContent = msg; } 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 // Spectator banner
spectating ? show('spectator-banner') : hide('spectator-banner'); spectating ? show('spectator-banner') : hide('spectator-banner');
} }
function renderOpponent(slot, seat, st) { function renderOpponent(slot, seat, st) {
const name = $(`${slot}-name`); const nameEl = $(`${slot}-name`);
const turn = $(`${slot}-turn`); const turnEl = $(`${slot}-turn`);
const score = $(`${slot}-score`); const scoreEl = $(`${slot}-score`);
const cards = $(`${slot}-cards`); const countEl = $(`${slot}-count`);
const partnerEl = $(`${slot}-partner`); const partnerEl = $(`${slot}-partner`);
name.textContent = st.names[seat] || '—'; nameEl.textContent = st.names[seat] || '—';
score.textContent = st.scores[teamOf(seat)]; scoreEl.textContent = st.scores[teamOf(seat)];
if (st.currentTurn === seat && st.state === 'PLAYING') show(`${slot}-turn`); if (st.currentTurn === seat && st.state === 'PLAYING') show(`${slot}-turn`);
else hide(`${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 // Partner badge for top player
if (slot === 'top' && mySeat >= 0 && partnerEl) { if (slot === 'top' && mySeat >= 0 && partnerEl) {
const isPartner = teamOf(seat) === teamOf(mySeat); const isPartner = teamOf(seat) === teamOf(mySeat);
isPartner ? show(`${slot}-partner`) : hide(`${slot}-partner`); 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) { function renderTrick(st, slots) {
@@ -586,9 +775,7 @@ function renderTrick(st, slots) {
} }
} }
if (st.lastTrickWinner >= 0 && st.trick.length === 0) { // phase-msg for trick winner is handled in renderTable
$('phase-msg').textContent = `${st.names[st.lastTrickWinner]} wins the trick`;
}
} }
function renderMyHand(st) { function renderMyHand(st) {
@@ -1076,6 +1263,21 @@ function wireGameEvents() {
clearSession(); clearSession();
showScreen('screen-lobby'); 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 () => { $('btn-copy').addEventListener('click', async () => {
const code = $('display-room-code').textContent.trim(); const code = $('display-room-code').textContent.trim();
const btn = $('btn-copy'); const btn = $('btn-copy');
@@ -1101,13 +1303,19 @@ function wireGameEvents() {
$('btn-fill-bots').addEventListener('click', () => { $('btn-fill-bots').addEventListener('click', () => {
socket?.emit('fillBots', { roomId: myRoomId }); socket?.emit('fillBots', { roomId: myRoomId });
}); });
$('btn-start-game').addEventListener('click', () => {
socket?.emit('forceStart', { roomId: myRoomId });
});
// Game menu // Game menu
$('btn-game-menu').addEventListener('click', () => { $('btn-game-menu').addEventListener('click', () => {
$('game-menu-dropdown').classList.toggle('hidden'); $('game-menu-dropdown').classList.toggle('hidden');
}); });
document.addEventListener('click', e => { 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', () => { $('btn-refresh-game').addEventListener('click', () => {
if (myRoomId && mySeat >= 0 && myToken) { if (myRoomId && mySeat >= 0 && myToken) {
@@ -1251,4 +1459,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Try to rejoin previous session // Try to rejoin previous session
tryRejoin(); tryRejoin();
// For authenticated users with no local session, connect so the server
// can push hasActiveGame (enables cross-device rejoin)
if (authToken && !myRoomId) connectSocket(() => {});
}); });
+32 -5
View File
@@ -57,6 +57,13 @@
<button class="score-btn" data-score="1005">1005</button> <button class="score-btn" data-score="1005">1005</button>
</div> </div>
</div> </div>
<div class="field">
<label>Visibility</label>
<div class="visibility-row">
<button class="visibility-btn active" data-public="false">🔒 Private</button>
<button class="visibility-btn" data-public="true">🌐 Public</button>
</div>
</div>
<button id="btn-create" class="btn-primary">Create Game</button> <button id="btn-create" class="btn-primary">Create Game</button>
</div> </div>
@@ -67,6 +74,14 @@
</div> </div>
<button id="btn-join" class="btn-primary">Join Game</button> <button id="btn-join" class="btn-primary">Join Game</button>
<hr class="divider"> <hr class="divider">
<div class="public-rooms-section">
<div class="rooms-header">
<span class="rooms-label">Public Rooms</span>
<button id="btn-refresh-rooms" class="btn-text"></button>
</div>
<div id="public-rooms-list"><p class="hint">Loading…</p></div>
</div>
<hr class="divider">
<div class="field"> <div class="field">
<label>Watch as spectator</label> <label>Watch as spectator</label>
<input id="input-spectate-code" type="text" maxlength="8" placeholder="Room code" style="text-transform:uppercase" autocomplete="off"> <input id="input-spectate-code" type="text" maxlength="8" placeholder="Room code" style="text-transform:uppercase" autocomplete="off">
@@ -81,7 +96,16 @@
<!-- ══════════════ WAITING ROOM ══════════════ --> <!-- ══════════════ WAITING ROOM ══════════════ -->
<div id="screen-waiting" class="screen"> <div id="screen-waiting" class="screen">
<div class="waiting-box"> <div class="waiting-box">
<button id="btn-leave-waiting" class="btn-leave-screen">← Leave</button> <div class="waiting-top-row">
<button id="btn-leave-waiting" class="btn-leave-screen">← Leave</button>
<div class="game-menu-wrap" style="margin-left:auto">
<button id="btn-waiting-menu" class="btn-leave-screen" title="Options"></button>
<div id="waiting-menu-dropdown" class="game-menu-dropdown hidden">
<button id="btn-waiting-reload" class="game-menu-item">↺ Reload</button>
<button id="btn-waiting-exit" class="game-menu-item game-menu-exit">🚪 Exit</button>
</div>
</div>
</div>
<h2>Waiting for Players</h2> <h2>Waiting for Players</h2>
<div class="room-code-box"> <div class="room-code-box">
<span class="label">Room Code</span> <span class="label">Room Code</span>
@@ -103,7 +127,10 @@
<span class="waiting-opt-label" id="waiting-mode-label"></span> <span class="waiting-opt-label" id="waiting-mode-label"></span>
<span class="waiting-opt-label" id="waiting-score-label"></span> <span class="waiting-opt-label" id="waiting-score-label"></span>
</div> </div>
<button id="btn-fill-bots" class="btn-fill-bots hidden">🤖 Fill empty seats with bots</button> <div id="waiting-creator-controls" class="waiting-creator-controls hidden">
<button id="btn-fill-bots" class="btn-fill-bots">🤖 Fill with bots</button>
<button id="btn-start-game" class="btn-start-game">▶ Start Game</button>
</div>
</div> </div>
</div> </div>
@@ -149,8 +176,8 @@
<span id="top-turn" class="turn-dot hidden"></span> <span id="top-turn" class="turn-dot hidden"></span>
<span id="top-partner" class="partner-badge">partner</span> <span id="top-partner" class="partner-badge">partner</span>
<span id="top-score" class="player-score-badge"></span> <span id="top-score" class="player-score-badge"></span>
<span id="top-count" class="card-count-badge"></span>
</div> </div>
<div id="top-cards" class="opp-cards"></div>
</div> </div>
<!-- Left player (seat 1) --> <!-- Left player (seat 1) -->
@@ -159,8 +186,8 @@
<span id="left-name"></span> <span id="left-name"></span>
<span id="left-turn" class="turn-dot hidden"></span> <span id="left-turn" class="turn-dot hidden"></span>
<span id="left-score" class="player-score-badge"></span> <span id="left-score" class="player-score-badge"></span>
<span id="left-count" class="card-count-badge"></span>
</div> </div>
<div id="left-cards" class="opp-cards vertical"></div>
</div> </div>
<!-- Center trick area --> <!-- Center trick area -->
@@ -182,8 +209,8 @@
<span id="right-name"></span> <span id="right-name"></span>
<span id="right-turn" class="turn-dot hidden"></span> <span id="right-turn" class="turn-dot hidden"></span>
<span id="right-score" class="player-score-badge"></span> <span id="right-score" class="player-score-badge"></span>
<span id="right-count" class="card-count-badge"></span>
</div> </div>
<div id="right-cards" class="opp-cards vertical"></div>
</div> </div>
</div><!-- /table-grid --> </div><!-- /table-grid -->
+216 -12
View File
@@ -28,7 +28,11 @@ html, body {
/* ── Screens ──────────────────────────────────── */ /* ── Screens ──────────────────────────────────── */
.screen { display: none; } .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 ────────────────────────────────────── */ /* ── Lobby ────────────────────────────────────── */
#screen-lobby { #screen-lobby {
@@ -119,6 +123,26 @@ input[type=text], input[type=password], input[type=email] {
} }
input:focus { border-color: var(--gold); } 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 / score buttons ─────────────────────── */
.mode-row, .score-row { display: flex; gap: 6px; } .mode-row, .score-row { display: flex; gap: 6px; }
.mode-btn, .score-btn { .mode-btn, .score-btn {
@@ -239,17 +263,60 @@ input:focus { border-color: var(--gold); }
border: 1px solid rgba(245,197,24,.25); border: 1px solid rgba(245,197,24,.25);
border-radius: 12px; 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 { .btn-fill-bots {
padding: 8px 16px; flex: 1;
padding: 8px 10px;
background: rgba(255,255,255,.08); background: rgba(255,255,255,.08);
border: 1px solid rgba(255,255,255,.16); border: 1px solid rgba(255,255,255,.16);
border-radius: 10px; border-radius: 10px;
color: var(--muted); color: var(--muted);
cursor: pointer; cursor: pointer;
font-size: .85rem; font-size: .82rem;
width: 100%;
} }
.btn-fill-bots:hover { background: rgba(255,255,255,.16); color: #fff; } .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 ──────────────────────────────── */ /* ── Game screen ──────────────────────────────── */
#screen-game { background: var(--felt); overflow: hidden; position: relative; } #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; display: flex; align-items: center; justify-content: center; gap: 4px;
} }
.trick-center-info { .trick-center-info {
width: 60px; text-align: center; flex-shrink: 0; flex: 0 0 80px;
text-align: center;
} }
.phase-msg { .phase-msg {
font-size: .72rem; color: rgba(255,255,255,.6); font-size: .72rem; color: rgba(255,255,255,.6);
line-height: 1.4; 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 */ /* 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 td { border-bottom: 1px solid rgba(255,255,255,.05); }
.lb-table tr:hover td { background: rgba(255,255,255,.04); } .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 ─────────────────────────────────────── */ /* ── Util ─────────────────────────────────────── */
.hidden { display: none !important; } .hidden { display: none !important; }
@@ -928,8 +1040,89 @@ input:focus { border-color: var(--gold); }
bottom: calc(100% + 6px); bottom: calc(100% + 6px);
} }
/* ── Mobile bidding: transparent panel at top so hand stays visible ── */ /* ═══════════════════════════════════════════════
MOBILE OVERHAUL (touch devices / small screens)
═══════════════════════════════════════════════ */
@media (pointer: coarse) { @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 { #overlay-bid {
align-items: flex-start; align-items: flex-start;
background: transparent; background: transparent;
@@ -942,17 +1135,28 @@ input:focus { border-color: var(--gold); }
max-width: 100%; max-width: 100%;
border-radius: 0 0 20px 20px; border-radius: 0 0 20px 20px;
border-top: none; border-top: none;
background: rgba(8, 22, 8, 0.97); background: rgba(6, 20, 6, 0.97);
box-shadow: 0 6px 28px rgba(0,0,0,.75); box-shadow: 0 6px 28px rgba(0,0,0,.8);
max-height: 62vh; 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 ────────────────────────────── */ /* ── Small phones (≤ 390px wide) ─────────────── */
@media (max-width: 480px) { @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; } :root { --card-w: 54px; --card-h: 78px; }
#table-grid { grid-template-columns: 70px 1fr 70px; } #table-grid { grid-template-columns: 70px 1fr 70px; }
.overlay-box { padding: 18px 16px; } .overlay-box { padding: 18px 16px; }
.bid-amount-display { font-size: 1.4rem; } .bid-amount-display { font-size: 1.4rem; }
.trump-suit-btn { font-size: .88rem; padding: 11px 6px; }
} }
+27 -1
View File
@@ -1,5 +1,28 @@
'use strict'; '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 = [ const PRECACHE = [
'/', '/',
@@ -10,6 +33,7 @@ const PRECACHE = [
'/icons/icon-192.png', '/icons/icon-192.png',
'/icons/icon-512.png', '/icons/icon-512.png',
'/icons/icon.svg', '/icons/icon.svg',
...CARD_PATHS,
]; ];
self.addEventListener('install', event => { self.addEventListener('install', event => {
@@ -33,6 +57,7 @@ self.addEventListener('fetch', event => {
if (event.request.method !== 'GET') return; if (event.request.method !== 'GET') return;
if (url.includes('/socket.io/')) return; if (url.includes('/socket.io/')) return;
// Cards: always serve from cache (never change)
if (url.includes('/cards/')) { if (url.includes('/cards/')) {
event.respondWith( event.respondWith(
caches.match(event.request).then(cached => { caches.match(event.request).then(cached => {
@@ -46,6 +71,7 @@ self.addEventListener('fetch', event => {
return; return;
} }
// Everything else: network-first, cache as offline fallback
event.respondWith( event.respondWith(
fetch(event.request) fetch(event.request)
.then(res => { .then(res => {
+100 -11
View File
@@ -216,6 +216,22 @@ app.get('/api/leaderboard', (_req, res) => {
res.json(rows); 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) => { app.post('/api/admin/toggle-signups', requireAuth, (req, res) => {
if (!isAdmin(req.user)) return res.status(403).json({ error: 'Forbidden' }); if (!isAdmin(req.user)) return res.status(403).json({ error: 'Forbidden' });
config.signupsOpen = !config.signupsOpen; config.signupsOpen = !config.signupsOpen;
@@ -337,6 +353,7 @@ function makeToken() {
function newRoom(id) { function newRoom(id) {
return { return {
id, id,
isPublic: false,
state: 'WAITING', state: 'WAITING',
names: ['', '', '', ''], names: ['', '', '', ''],
seats: [null, null, null, null], seats: [null, null, null, null],
@@ -414,6 +431,7 @@ function publicInfo(room, seat) {
gameWinner: room.gameWinner, gameWinner: room.gameWinner,
jokerMode: room.jokerMode, jokerMode: room.jokerMode,
winScore: room.winScore, winScore: room.winScore,
isPublic: room.isPublic,
spectatorCount: room.spectators.size, spectatorCount: room.spectators.size,
}; };
} }
@@ -844,14 +862,25 @@ io.use((socket, next) => {
io.on('connection', (socket) => { io.on('connection', (socket) => {
const user = socket.data.user; 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 ──────────────────────────────────────────── // ── 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'); if (!name?.trim()) return socket.emit('error', 'Name is required');
const id = Math.random().toString(36).slice(2, 8).toUpperCase(); const id = Math.random().toString(36).slice(2, 8).toUpperCase();
const room = newRoom(id); const room = newRoom(id);
room.jokerMode = jokerMode !== false; // default true room.jokerMode = jokerMode !== false;
room.isPublic = isPublic === true;
const ws = [205, 505, 1005]; const ws = [205, 505, 1005];
room.winScore = ws.includes(+winScore) ? +winScore : 505; room.winScore = ws.includes(+winScore) ? +winScore : 505;
room.names[0] = name.trim().slice(0, 16); 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) return socket.emit('joinError', 'Room not found');
if (room.state !== 'WAITING') return socket.emit('joinError', 'Game already in progress'); 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++) { 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.names[openSeat] = name.trim().slice(0, 16);
room.userIds[openSeat] = user?.id || null; room.userIds[openSeat] = user?.id || null;
@@ -901,11 +943,21 @@ io.on('connection', (socket) => {
// ── Rejoin ───────────────────────────────────────────────── // ── Rejoin ─────────────────────────────────────────────────
socket.on('rejoin', ({ roomId, seat, token } = {}) => { socket.on('rejoin', ({ roomId, seat, token } = {}) => {
const room = rooms.get((roomId || '').toUpperCase()); const room = rooms.get((roomId || '').toUpperCase());
if (!room) return socket.emit('rejoinError', 'Room no longer exists'); if (!room) return socket.emit('rejoinError', 'not_found');
if (room.tokens[seat] !== token) return socket.emit('rejoinError', 'Invalid session token');
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; room.seats[seat] = socket.id;
socket.join(room.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)); socket.emit('roomInfo', publicInfo(room, seat));
broadcastState(room, 'roomInfo'); broadcastState(room, 'roomInfo');
}); });
@@ -926,6 +978,42 @@ io.on('connection', (socket) => {
tryStartGame(room); 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 ──────────────────────────────────────────────────── // ── Bid ────────────────────────────────────────────────────
socket.on('bid', ({ roomId, seat, token, amount } = {}) => { socket.on('bid', ({ roomId, seat, token, amount } = {}) => {
const room = rooms.get((roomId || '').toUpperCase()); const room = rooms.get((roomId || '').toUpperCase());
@@ -958,8 +1046,9 @@ io.on('connection', (socket) => {
const room = rooms.get((roomId || '').toUpperCase()); const room = rooms.get((roomId || '').toUpperCase());
if (!room) return; if (!room) return;
if (room.tokens[seat] === token) { if (room.tokens[seat] === token) {
room.seats[seat] = null; room.seats[seat] = null;
room.tokens[seat] = null; room.tokens[seat] = null;
room.userIds[seat] = null; // prevent hasActiveGame from pulling them back in
} }
socket.leave(room.id); socket.leave(room.id);
}); });