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
+100 -11
View File
@@ -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);
});