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:
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user