From 8e8478e45bf56c00a4831f1af3f2dde65874d315 Mon Sep 17 00:00:00 2001 From: goyban Date: Fri, 8 May 2026 16:17:37 +0000 Subject: [PATCH] Initial commit: Shelem card game Full-stack multiplayer Shelem (Iranian trick-taking card game) with Socket.IO, JWT auth, bot players, joker mode, and mobile-friendly UI. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 5 + .gitignore | 9 + Dockerfile | 25 + docker-compose.yml | 16 + gen-icons.js | 111 ++++ package.json | 15 + public/app.js | 1254 ++++++++++++++++++++++++++++++++++++++++++ public/index.html | 425 ++++++++++++++ public/manifest.json | 14 + public/style.css | 958 ++++++++++++++++++++++++++++++++ public/sw.js | 57 ++ server.js | 980 +++++++++++++++++++++++++++++++++ 12 files changed, 3869 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 gen-icons.js create mode 100644 package.json create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/manifest.json create mode 100644 public/style.css create mode 100644 public/sw.js create mode 100644 server.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8c8fc17 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +JWT_SECRET=shelem-secret-change-me +ADMIN_USERNAME= +SHARED_USERS_FILE=/hokm-data/users.json +PORT=4000 +HTTPS_PORT=4443 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..517063a --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Environment +.env + +# Runtime data +data/ + +# Node +node_modules/ +npm-debug.log* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..576b6a5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json ./ +RUN npm install --omit=dev + +RUN apk add --no-cache openssl && \ + mkdir -p /app/ssl && \ + openssl req -x509 -newkey rsa:2048 \ + -keyout /app/ssl/key.pem -out /app/ssl/cert.pem \ + -days 3650 -nodes -subj "/CN=shelem-game" && \ + apk del openssl + +COPY server.js ./ +COPY gen-icons.js ./ +COPY public/ ./public/ +# cards/ supplied at runtime via docker-compose volume mount +RUN rm -rf /app/public/cards && mkdir -p /app/public/cards + +RUN node gen-icons.js + +EXPOSE 4000 4443 + +CMD ["node", "server.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a8c4e77 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + shelem: + container_name: shelem + build: . + ports: + - "4001:4000" + - "4444:4443" + restart: unless-stopped + volumes: + - ./data:/app/data + # Hokm's data dir (read-only) — allows Hokm accounts to log into Shelem + - /root/hokm/data:/hokm-data:ro + # Share card SVGs from Hearts/Hokm — same assets, no duplication + - /root/hearts/public/cards:/app/public/cards:ro + env_file: + - .env diff --git a/gen-icons.js b/gen-icons.js new file mode 100644 index 0000000..abe44f9 --- /dev/null +++ b/gen-icons.js @@ -0,0 +1,111 @@ +'use strict'; +// Generates PNG icons with a Joker card motif on dark green background. +const fs = require('fs'); +const path = require('path'); +const zlib = require('zlib'); + +const outDir = path.join(__dirname, 'public', 'icons'); +if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); + +function writePNG(filePath, size) { + const channels = 4; + const row = size * channels; + const raw = Buffer.alloc(size * row); + + // Background: dark forest green #133025 + const bgR = 0x13, bgG = 0x30, bgB = 0x25; + // Gold color for Joker diamond: #f5c518 + const gR = 0xf5, gG = 0xc5, gB = 0x18; + + const cx = size / 2, cy = size / 2; + const dh = size * 0.35; // half-height of diamond + const dw = size * 0.28; // half-width + + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const px = (y * size + x) * channels; + const nx = x + 0.5, ny = y + 0.5; + + // Rounded corners + const cr = size * 0.2; + const inCorner = + (nx < cr && ny < cr && Math.hypot(nx - cr, ny - cr) > cr) || + (nx > size - cr && ny < cr && Math.hypot(nx - (size - cr), ny - cr) > cr) || + (nx < cr && ny > size - cr && Math.hypot(nx - cr, ny - (size - cr)) > cr) || + (nx > size - cr && ny > size - cr && Math.hypot(nx - (size - cr), ny - (size - cr)) > cr); + if (inCorner) { raw[px + 3] = 0; continue; } + + // Draw a diamond / rhombus shape (Joker card symbol) + const dx = Math.abs(nx - cx) / dw; + const dy = Math.abs(ny - cy) / dh; + const inDiamond = dx + dy <= 1; + + if (inDiamond) { + raw[px] = gR; + raw[px + 1] = gG; + raw[px + 2] = gB; + raw[px + 3] = 255; + } else { + raw[px] = bgR; + raw[px + 1] = bgG; + raw[px + 2] = bgB; + raw[px + 3] = 255; + } + } + } + + const chunks = []; + chunks.push(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + + function crc32(buf) { + let c = 0xffffffff; + const table = crc32.table || (crc32.table = (() => { + const t = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + let v = i; + for (let k = 0; k < 8; k++) v = v & 1 ? 0xedb88320 ^ (v >>> 1) : v >>> 1; + t[i] = v; + } + return t; + })()); + for (let i = 0; i < buf.length; i++) c = table[(c ^ buf[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; + } + + function chunk(type, data) { + const len = Buffer.alloc(4); len.writeUInt32BE(data.length); + const tp = Buffer.from(type); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(Buffer.concat([tp, data]))); + return Buffer.concat([len, tp, data, crc]); + } + + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(size, 0); ihdr.writeUInt32BE(size, 4); + ihdr[8] = 8; ihdr[9] = 6; + chunks.push(chunk('IHDR', ihdr)); + + const filtered = Buffer.alloc(size * (row + 1)); + for (let y = 0; y < size; y++) { + filtered[y * (row + 1)] = 0; + raw.copy(filtered, y * (row + 1) + 1, y * row, (y + 1) * row); + } + chunks.push(chunk('IDAT', zlib.deflateSync(filtered))); + chunks.push(chunk('IEND', Buffer.alloc(0))); + + fs.writeFileSync(filePath, Buffer.concat(chunks)); + console.log(`${path.basename(filePath)} written (${size}×${size})`); +} + +writePNG(path.join(outDir, 'icon-192.png'), 192); +writePNG(path.join(outDir, 'icon-512.png'), 512); + +// SVG icon (scalable, used as favicon) +const svg = ` + + + J +`; +fs.writeFileSync(path.join(outDir, 'icon.svg'), svg); +console.log('icon.svg written'); +console.log('Icons generated.'); diff --git a/package.json b/package.json new file mode 100644 index 0000000..b305dae --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "shelem", + "version": "1.0.0", + "description": "Shelem card game — multiplayer", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "express": "^4.18.2", + "socket.io": "^4.7.2", + "bcryptjs": "^2.4.3", + "jsonwebtoken": "^9.0.2" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..7297426 --- /dev/null +++ b/public/app.js @@ -0,0 +1,1254 @@ +'use strict'; + +// ─── State ──────────────────────────────────────────────────── +let socket; +let myName = ''; +let mySeat = -1; +let myToken = null; +let myRoomId = null; +let spectating = false; +let authToken = localStorage.getItem('shelem_token') || null; +let authUser = localStorage.getItem('shelem_user') || null; +let lastState = null; + +// Lobby selections +let selectedJoker = true; +let selectedScore = 505; + +// Widow discard state +let widowSelected = []; + +// Bid UI state +let currentBidAmount = 85; +let lastBidHandNumber = -1; // detect new hands so we can reset the bid amount + +// ─── Play / hand-display mode (mirror of Hearts) ────────────── +function isTouchDevice() { + return window.matchMedia('(pointer: coarse)').matches || 'ontouchstart' in window; +} +function loadPlayMode() { + const s = localStorage.getItem('shelem_play_mode'); + if (s === 'tap' || s === 'drag') return s; + return isTouchDevice() ? 'drag' : 'tap'; +} +function savePlayMode(m) { localStorage.setItem('shelem_play_mode', m); } +function loadHandMode() { + const s = localStorage.getItem('shelem_hand_mode'); + return ['scroll','fan','playables'].includes(s) ? s : 'scroll'; +} +function saveHandMode(m) { localStorage.setItem('shelem_hand_mode', m); } +function loadBarBottom() { return localStorage.getItem('shelem_bar_bottom') === '1'; } +function saveBarBottom(v) { localStorage.setItem('shelem_bar_bottom', v ? '1' : '0'); } + +let playMode = loadPlayMode(); +let handMode = loadHandMode(); +let barAtBottom = loadBarBottom(); + +function applyBarBottom() { + document.getElementById('screen-game').classList.toggle('bar-bottom', barAtBottom); + const btn = $('btn-toggle-bar'); + if (btn) btn.textContent = barAtBottom ? '⬆ Bar to top' : '⬇ Bar to bottom'; +} + +const HAND_MODES = ['scroll', 'fan', 'playables']; +const HAND_LABELS = { scroll: '📜 Scroll', fan: '🃏 Fan', playables: '✅ Playables' }; + +function updatePlayModeBtn() { + const btn = $('btn-play-mode'); + if (!btn) return; + const drag = playMode === 'drag'; + btn.textContent = drag ? '☝ Drag' : '👆 Tap'; + btn.classList.toggle('drag-mode', drag); + btn.title = drag ? 'Switch to tap mode' : 'Switch to drag mode'; +} +function updateHandModeBtn() { + const btn = $('btn-hand-mode'); + if (!btn) return; + btn.textContent = HAND_LABELS[handMode] || '📜 Scroll'; + btn.classList.toggle('fan-mode', handMode === 'fan' || handMode === 'playables'); +} +function applyHandMode() { + const fanLike = handMode === 'fan' || handMode === 'playables'; + const handEl = $('my-hand'); + handEl?.classList.toggle('fan-mode', fanLike); + $('my-area')?.classList.toggle('fan-active', fanLike); + if (!fanLike && handEl) handEl.style.justifyContent = ''; +} +function updateHandSpacing() { + const handEl = $('my-hand'); + if (!handEl || handMode === 'scroll') return; + const cards = Array.from(handEl.querySelectorAll('.card')); + const n = cards.length; + if (n === 0) return; + if (n === 1) { cards[0].style.marginLeft = '0'; return; } + const cardW = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--card-w')) || 68; + const containerW = handEl.offsetWidth || (window.innerWidth - 24); + const overflow = n * cardW - containerW; + let ml; + if (overflow > 0) { + ml = -(Math.ceil(overflow / (n - 1)) + 2); + handEl.style.justifyContent = 'flex-start'; + } else { + ml = Math.min(16, Math.floor(-overflow / (n - 1))); + handEl.style.justifyContent = 'center'; + } + cards.forEach((c, i) => { c.style.marginLeft = i === 0 ? '0' : ml + 'px'; }); +} + +// Drag-to-play (touch devices) +function addDragHandlers(cardEl, code, onPlay) { + const cardCount = $('my-hand')?.querySelectorAll('.card').length ?? 0; + const useMagnifier = isTouchDevice() && playMode === 'drag' + && (handMode === 'fan' || handMode === 'playables') + && cardCount > 8; + let touchStartY = 0, touchStartX = 0, isDragging = false, ghost = null; + + cardEl.addEventListener('touchstart', e => { + touchStartY = e.touches[0].clientY; + touchStartX = e.touches[0].clientX; + isDragging = false; + if (useMagnifier) cardEl.classList.add('magnified'); + }, { passive: true }); + + cardEl.addEventListener('touchmove', e => { + const dy = touchStartY - e.touches[0].clientY; + if (useMagnifier && !isDragging && dy < 20) { + cardEl.classList.remove('magnified'); + const el2 = document.elementFromPoint(e.touches[0].clientX, e.touches[0].clientY); + const tgt = el2?.closest('.card'); + if (tgt) tgt.classList.add('magnified'); + return; + } + if (dy > 20) { + e.preventDefault(); + isDragging = true; + $('my-hand')?.querySelectorAll('.card.magnified').forEach(c => c.classList.remove('magnified')); + if (!ghost) { + ghost = document.createElement('div'); + ghost.className = 'drag-ghost'; + const gi = document.createElement('img'); + gi.src = cardSvg(code); gi.alt = code; gi.draggable = false; + ghost.appendChild(gi); + document.body.appendChild(ghost); + $('trick-area')?.classList.add('drag-active'); + } + ghost.style.left = (e.touches[0].clientX - cardEl.offsetWidth / 2) + 'px'; + ghost.style.top = (e.touches[0].clientY - cardEl.offsetHeight * 0.7) + 'px'; + } + }, { passive: false }); + + const endDrag = e => { + if (ghost) { ghost.remove(); ghost = null; } + $('trick-area')?.classList.remove('drag-active'); + $('my-hand')?.querySelectorAll('.card.magnified').forEach(c => c.classList.remove('magnified')); + if (isDragging) { + const endY = (e.changedTouches?.[0] ?? e.touches?.[0])?.clientY ?? touchStartY; + if (endY < window.innerHeight * 0.70) onPlay(code); + } + isDragging = false; + }; + cardEl.addEventListener('touchend', endDrag); + cardEl.addEventListener('touchcancel', endDrag); +} + +// ─── Helpers ────────────────────────────────────────────────── +function $(id) { return document.getElementById(id); } +function show(id) { const el = $(id); if (el) el.classList.remove('hidden'); } +function hide(id) { const el = $(id); if (el) el.classList.add('hidden'); } + +function cardSvg(code) { + if (code === 'JOKER-COLOR') return '/cards/JOKER-1.svg'; + if (code === 'JOKER-BLACK') return '/cards/JOKER-2.svg'; + const [suit, rank] = code.split('-'); + const suitMap = { C: 'CLUB', D: 'DIAMOND', H: 'HEART', S: 'SPADE' }; + const name = suitMap[suit]; + if (rank === 'A') return `/cards/${name}-1.svg`; + if (rank === 'J') return `/cards/${name}-11-JACK.svg`; + if (rank === 'Q') return `/cards/${name}-12-QUEEN.svg`; + if (rank === 'K') return `/cards/${name}-13-KING.svg`; + return `/cards/${name}-${rank}.svg`; +} + +function suitSymbol(suit) { + return { C: '♣', D: '♦', H: '♥', S: '♠' }[suit] || suit; +} + +function suitName(suit) { + return { C: 'Clubs', D: 'Diamonds', H: 'Hearts', S: 'Spades' }[suit] || suit; +} + +function teamOf(seat) { return seat % 2; } + +// Map logical seats to visual positions relative to mySeat +// Returns the seat number for a visual slot +function visualSeat(slot) { + // slot: 'bottom'=me, 'top'=partner, 'left', 'right' + if (mySeat < 0) return -1; + const offsets = { bottom: 0, left: 1, top: 2, right: 3 }; + return (mySeat + offsets[slot]) % 4; +} + +// ─── Screens ────────────────────────────────────────────────── +function showScreen(id) { + document.querySelectorAll('.screen').forEach(s => s.classList.remove('active')); + const el = $(id); + if (el) el.classList.add('active'); +} + +// ─── Card elements ───────────────────────────────────────────── +// Card element for trick-area slots (includes joker badge) +function makeTrickImg(code) { + if (code === 'JOKER-COLOR' || code === 'JOKER-BLACK') { + const wrap = document.createElement('div'); + wrap.className = 'trick-card-wrap'; + const img = document.createElement('img'); + img.src = cardSvg(code); + img.alt = code; + img.draggable = false; + wrap.appendChild(img); + const badge = document.createElement('div'); + badge.className = 'joker-badge ' + (code === 'JOKER-COLOR' ? 'joker-color-badge' : 'joker-black-badge'); + badge.textContent = code === 'JOKER-COLOR' ? '🌈 Color · 20' : '⚫ Black · 15'; + wrap.appendChild(badge); + return wrap; + } + const img = document.createElement('img'); + img.src = cardSvg(code); + img.alt = code; + img.draggable = false; + return img; +} + +function makeCardEl(code, opts = {}) { + const el = document.createElement('div'); + el.className = 'card'; + el.dataset.card = code; + const img = document.createElement('img'); + img.src = cardSvg(code); + img.alt = code; + el.appendChild(img); + // Overlay badge so players can instantly tell the two jokers apart + if (code === 'JOKER-COLOR') { + const badge = document.createElement('div'); + badge.className = 'joker-badge joker-color-badge'; + badge.textContent = '🌈 Color · 20'; + el.appendChild(badge); + } else if (code === 'JOKER-BLACK') { + const badge = document.createElement('div'); + badge.className = 'joker-badge joker-black-badge'; + badge.textContent = '⚫ Black · 15'; + el.appendChild(badge); + } + if (opts.onClick) el.addEventListener('click', () => opts.onClick(code, el)); + if (opts.illegal) el.classList.add('illegal'); + return el; +} + +function makeCardBack() { + const el = document.createElement('div'); + el.className = 'card-back'; + return el; +} + +// ─── Lobby init ─────────────────────────────────────────────── +function initLobby() { + updateAuthBar(); + + // Tabs + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); + document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); + btn.classList.add('active'); + $('tab-' + btn.dataset.tab)?.classList.add('active'); + }); + }); + + // Mode buttons + document.querySelectorAll('.mode-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + selectedJoker = btn.dataset.joker === 'true'; + }); + }); + + // Score buttons + document.querySelectorAll('.score-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.score-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + selectedScore = +btn.dataset.score; + }); + }); + + $('btn-create').addEventListener('click', createGame); + $('btn-join').addEventListener('click', joinGame); + $('btn-spectate').addEventListener('click', spectateGame); + + $('btn-show-auth').addEventListener('click', () => { + show('overlay-auth'); + $('auth-login-user').focus(); + }); + $('btn-auth-close').addEventListener('click', () => hide('overlay-auth')); + $('btn-show-leaderboard').addEventListener('click', showLeaderboard); + $('btn-show-profile').addEventListener('click', () => showProfile(authUser)); + $('btn-logout').addEventListener('click', doLogout); + + // Auth tabs + document.querySelectorAll('.auth-tab').forEach(tab => { + tab.addEventListener('click', () => { + document.querySelectorAll('.auth-tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.auth-panel').forEach(p => p.classList.remove('active')); + tab.classList.add('active'); + $('auth-panel-' + tab.dataset.authTab)?.classList.add('active'); + }); + }); + + $('btn-do-login').addEventListener('click', doLogin); + $('auth-login-pass').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); }); + $('btn-do-register').addEventListener('click', doRegister); + $('auth-reg-pass').addEventListener('keydown', e => { if (e.key === 'Enter') doRegister(); }); +} + +function createGame() { + const name = $('input-name').value.trim(); + if (!name) { $('lobby-error').textContent = 'Please enter your name.'; return; } + $('lobby-error').textContent = ''; + connectSocket(() => { + socket.emit('create', { name, jokerMode: selectedJoker, winScore: selectedScore }); + }); +} + +function joinGame() { + const name = $('input-name').value.trim(); + const code = $('input-code').value.trim().toUpperCase(); + if (!name) { $('lobby-error').textContent = 'Please enter your name.'; return; } + if (!code) { $('lobby-error').textContent = 'Please enter a room code.'; return; } + $('lobby-error').textContent = ''; + connectSocket(() => { + socket.emit('join', { name, roomId: code }); + }); +} + +function spectateGame() { + const code = $('input-spectate-code').value.trim().toUpperCase(); + if (!code) { $('lobby-error').textContent = 'Please enter a room code.'; return; } + $('lobby-error').textContent = ''; + spectating = true; + connectSocket(() => { + socket.emit('spectate', { roomId: code }); + }); +} + +// ─── Socket ─────────────────────────────────────────────────── +function connectSocket(onReady) { + if (socket?.connected) { onReady(); return; } + socket = io({ auth: { token: authToken } }); + + socket.on('connect', onReady); + + socket.on('created', ({ roomId, seat, token }) => { + myRoomId = roomId; mySeat = seat; myToken = token; + saveSession(); + showWaitingRoom(); + }); + socket.on('joined', ({ roomId, seat, token }) => { + myRoomId = roomId; mySeat = seat; myToken = token; + saveSession(); + showWaitingRoom(); + }); + socket.on('spectating', ({ roomId }) => { + myRoomId = roomId; mySeat = -1; + showScreen('screen-game'); + }); + socket.on('rejoined', ({ roomId, seat, token }) => { + myRoomId = roomId; mySeat = seat; myToken = token; + }); + + 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('roomInfo', render); + socket.on('cardPlayed',render); + socket.on('trickWon', render); + socket.on('handOver', onHandOver); + socket.on('gameOver', onGameOver); + + socket.on('disconnect', () => {}); +} + +// ─── Session persistence ────────────────────────────────────── +function saveSession() { + sessionStorage.setItem('shelem_room', myRoomId); + sessionStorage.setItem('shelem_seat', mySeat); + sessionStorage.setItem('shelem_token', myToken); +} +function clearSession() { + sessionStorage.removeItem('shelem_room'); + sessionStorage.removeItem('shelem_seat'); + sessionStorage.removeItem('shelem_token'); +} +function tryRejoin() { + const room = sessionStorage.getItem('shelem_room'); + const seat = sessionStorage.getItem('shelem_seat'); + const token = sessionStorage.getItem('shelem_token'); + if (!room || seat === null || !token) return; + myRoomId = room; mySeat = +seat; myToken = token; + connectSocket(() => { + socket.emit('rejoin', { roomId: room, seat: +seat, token }); + }); +} + +// ─── Waiting Room ────────────────────────────────────────────── +function showWaitingRoom() { + showScreen('screen-waiting'); +} + +function renderWaitingRoom(st) { + $('display-room-code').textContent = st.id; + show('waiting-options'); + $('waiting-mode-label').textContent = st.jokerMode ? '🃏 With Jokers' : '♠ No Jokers'; + $('waiting-score-label').textContent = `Win: ${st.winScore}`; + + 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' : '—'); + }); + + 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 + ? `Waiting for ${4 - filled} more player${4 - filled > 1 ? 's' : ''}…` + : 'Starting game…'; + + if (mySeat === 0) show('btn-fill-bots'); else hide('btn-fill-bots'); +} + +// ─── Main render ────────────────────────────────────────────── +function render(st) { + if (!st) return; + lastState = st; + + if (st.state === 'WAITING') { + if (document.getElementById('screen-waiting')?.classList.contains('active')) { + renderWaitingRoom(st); + } + return; + } + + // Transition to game screen if needed + if (!document.getElementById('screen-game')?.classList.contains('active')) { + showScreen('screen-game'); + } + + renderInfoBar(st); + renderTable(st); + renderOverlays(st); +} + +// ─── Info bar ────────────────────────────────────────────────── +function renderInfoBar(st) { + // Team scores + const t0names = [st.names[0], st.names[2]].filter(Boolean).join(' & '); + const t1names = [st.names[1], st.names[3]].filter(Boolean).join(' & '); + const ts0 = $('team-score-0'); + const ts1 = $('team-score-1'); + ts0.className = 'team-score team-a'; + ts1.className = 'team-score team-b'; + ts0.textContent = `${t0names || 'Team A'}: ${st.scores[0]}`; + ts1.textContent = `${t1names || 'Team B'}: ${st.scores[1]}`; + + // Trump + bid + const td = $('trump-display'); + const bd = $('bid-display'); + if (st.trump) { + td.textContent = `Trump: ${suitSymbol(st.trump)} ${suitName(st.trump)}`; + show('trump-display'); + } else { + hide('trump-display'); + } + if (st.highBid > 0 && st.state !== 'HAND_OVER' && st.state !== 'GAME_OVER') { + bd.textContent = `Bid: ${st.highBid} (${st.names[st.highBidder] || '?'})`; + show('bid-display'); + } else { + hide('bid-display'); + } +} + +// ─── Table ──────────────────────────────────────────────────── +function renderTable(st) { + const slots = { bottom: mySeat < 0 ? 0 : mySeat, + top: mySeat < 0 ? 2 : (mySeat + 2) % 4, + left: mySeat < 0 ? 1 : (mySeat + 1) % 4, + right: mySeat < 0 ? 3 : (mySeat + 3) % 4 }; + + // Player labels & card backs for opponents + renderOpponent('top', slots.top, st); + renderOpponent('left', slots.left, st); + renderOpponent('right', slots.right, st); + + // Trick area + renderTrick(st, slots); + + // My hand + renderMyHand(st); + + // My info + if (mySeat >= 0) { + $('my-name').textContent = st.names[mySeat] || 'You'; + const myTeam = teamOf(mySeat); + const partnerSeat = (mySeat + 2) % 4; + $('my-score').textContent = st.scores[myTeam]; + + // Partner label color + const pm = $('my-partner-label'); + pm.textContent = `⇔ ${st.names[partnerSeat] || ''}`; + pm.classList.remove('hidden'); + + // Turn dot + st.currentTurn === mySeat && st.state === 'PLAYING' + ? show('my-turn') : hide('my-turn'); + + // Tricks badge + const tricks = st.trickWins[mySeat]; + if (tricks > 0) { + $('my-tricks').textContent = `${tricks} trick${tricks > 1 ? 's' : ''}`; + show('my-tricks'); + } else { + hide('my-tricks'); + } + } + + // Phase message + 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; + + // 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 partnerEl = $(`${slot}-partner`); + + name.textContent = st.names[seat] || '—'; + score.textContent = st.scores[teamOf(seat)]; + + if (st.currentTurn === seat && st.state === 'PLAYING') show(`${slot}-turn`); + else hide(`${slot}-turn`); + + // 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) { + const trickMap = {}; + for (const t of st.trick) trickMap[t.player] = t.card; + + const positions = [ + { id: 'trick-bottom', seat: slots.bottom }, + { id: 'trick-top', seat: slots.top }, + { id: 'trick-left', seat: slots.left }, + { id: 'trick-right', seat: slots.right }, + ]; + for (const { id, seat } of positions) { + const el = $(id); + el.innerHTML = ''; + if (trickMap[seat]) { + el.appendChild(makeTrickImg(trickMap[seat])); + } + } + + if (st.lastTrickWinner >= 0 && st.trick.length === 0) { + $('phase-msg').textContent = `${st.names[st.lastTrickWinner]} wins the trick`; + } +} + +function renderMyHand(st) { + const handEl = $('my-hand'); + handEl.innerHTML = ''; + if (mySeat < 0) return; + + const myHand = st.hands[mySeat]; + if (!Array.isArray(myHand) || myHand.length === 0) return; + + const isMyTurn = st.state === 'PLAYING' && st.currentTurn === mySeat; + const legal = isMyTurn ? computeLegalCards(st, mySeat) : []; + + // In "playables" mode only show the legal cards when it's my turn + const display = (handMode === 'playables' && isMyTurn) ? legal : myHand; + + function doPlay(code) { + if (!isMyTurn) return; + if (!computeLegalCards(st, mySeat).includes(code)) return; + socket.emit('play', { roomId: myRoomId, seat: mySeat, token: myToken, card: code }); + } + + for (const code of display) { + const isLegal = legal.length === 0 || legal.includes(code); + const cardEl = makeCardEl(code, { + illegal: isMyTurn && !isLegal, + }); + + if (isMyTurn && isLegal) { + if (playMode === 'tap') { + cardEl.addEventListener('click', () => doPlay(code)); + } + addDragHandlers(cardEl, code, doPlay); + } + + handEl.appendChild(cardEl); + } + + // Show/hide play-mode toggle (touch-only, during play) + if (!spectating) { + if (isTouchDevice() && st.state === 'PLAYING') show('btn-play-mode'); + else hide('btn-play-mode'); + updatePlayModeBtn(); + updateHandModeBtn(); + } + + applyHandMode(); + if (handMode === 'fan' || handMode === 'playables') { + requestAnimationFrame(updateHandSpacing); + } else { + requestAnimationFrame(() => { + const overflow = handEl.scrollWidth - handEl.clientWidth; + if (overflow > 0) handEl.scrollLeft = overflow / 2; + }); + } +} + +// ─── Legal cards (client-side mirror of server logic) ───────── +function computeLegalCards(st, seat) { + const hand = st.hands[seat]; + const trick = st.trick; + const trump = st.trump; + if (!Array.isArray(hand)) return []; + + function isTrump(c) { + if (c === 'JOKER-COLOR' || c === 'JOKER-BLACK') return true; + return c.split('-')[0] === trump; + } + function suitOf(c) { + if (c === 'JOKER-COLOR' || c === 'JOKER-BLACK') return 'JOKER'; + return c.split('-')[0]; + } + + if (trick.length === 0) { + // First lead of the hand sets trump — jokers not allowed + if (!trump) return hand.filter(c => c !== 'JOKER-COLOR' && c !== 'JOKER-BLACK'); + return hand; + } + + const ledCard = trick[0].card; + const ledTrump = isTrump(ledCard); + + if (ledTrump) { + const tc = hand.filter(c => isTrump(c)); + return tc.length > 0 ? tc : hand; + } + const ls = suitOf(ledCard); + const sc = hand.filter(c => suitOf(c) === ls); + return sc.length > 0 ? sc : hand; +} + +// ─── Overlays ───────────────────────────────────────────────── +function renderOverlays(st) { + hideAllOverlays(); + + if (st.state === 'BIDDING') { + renderBiddingOverlay(st); + } else if (st.state === 'WIDOW' && mySeat === st.declarer) { + renderWidowOverlay(st); + } else if (st.state === 'WIDOW' && mySeat !== st.declarer) { + $('phase-msg').textContent = `${st.names[st.declarer]} is picking up the widow…`; + } +} + +function hideAllOverlays() { + hide('overlay-bid'); + hide('overlay-widow'); + hide('overlay-hand'); + // Restore actual hand mode if we temporarily forced fan mode during bidding + if (isTouchDevice()) applyHandMode(); +} + +// ─── Bidding overlay ─────────────────────────────────────────── +function renderBiddingOverlay(st) { + show('overlay-bid'); + // On touch devices show hand in fan mode so players can see their cards while bidding + if (isTouchDevice()) { + $('my-hand')?.classList.add('fan-mode'); + $('my-area')?.classList.add('fan-active'); + requestAnimationFrame(updateHandSpacing); + } + const history = $('bid-history'); + history.innerHTML = ''; + + for (let s = 0; s < 4; s++) { + const row = document.createElement('div'); + row.className = 'bid-row' + (st.currentBidder === s ? ' active-bidder' : ''); + + const nameSpan = document.createElement('span'); + nameSpan.className = 'bid-name'; + nameSpan.textContent = (st.names[s] || `Seat ${s+1}`) + (s === mySeat ? ' (You)' : ''); + + const valSpan = document.createElement('span'); + valSpan.className = 'bid-val'; + if (st.bids[s] === null) { + valSpan.className += ' waiting'; + valSpan.textContent = st.currentBidder === s ? '⟵ bidding' : '—'; + } else if (st.bids[s] === 'pass') { + valSpan.className += ' passed'; + valSpan.textContent = 'Pass'; + } else { + valSpan.textContent = st.bids[s]; + } + row.appendChild(nameSpan); + row.appendChild(valSpan); + history.appendChild(row); + } + + const myBid = st.bids[mySeat]; + // myBid can be a previous bid (number) — player may rebid higher; only 'pass' locks them out + const isMyTurn = st.currentBidder === mySeat && myBid !== 'pass' && !spectating; + + if (isMyTurn) { + show('bid-controls'); + hide('bid-waiting-msg'); + const floorBid = st.jokerMode ? 105 : 85; + const minBid = st.highBid > 0 ? st.highBid + 5 : floorBid; + // Reset to minimum at the start of every new hand + if (st.handNumber !== lastBidHandNumber) { + currentBidAmount = minBid; + lastBidHandNumber = st.handNumber; + } + if (currentBidAmount < minBid) currentBidAmount = minBid; + $('bid-amount-display').textContent = currentBidAmount; + } else { + hide('bid-controls'); + if (myBid === 'pass') { + $('bid-waiting-msg').textContent = 'You passed — waiting for bidding to finish…'; + } else { + const bidder = st.names[st.currentBidder] || 'Another player'; + $('bid-waiting-msg').textContent = `${bidder} is bidding…`; + } + show('bid-waiting-msg'); + } +} + +// ─── Widow overlay ──────────────────────────────────────────── +function renderWidowOverlay(st) { + widowSelected = []; + show('overlay-widow'); + + const needed = st.widowSize; + $('widow-title').textContent = `Pick up widow — discard ${needed} cards`; + $('widow-hint').textContent = `Select exactly ${needed} cards to discard. They count as your first trick.`; + + renderWidowHand(st); + updateWidowPreview(st); +} + +const WIDOW_SUIT_ORDER = ['C', 'D', 'H', 'S']; +const WIDOW_SUIT_LABEL = { C: '♣', D: '♦', H: '♥', S: '♠' }; +const WIDOW_JOKER_LABEL = { 'JOKER-COLOR': '🌈', 'JOKER-BLACK': '⚫' }; + +function renderWidowHand(st) { + const container = $('widow-hand'); + container.innerHTML = ''; + const hand = st.hands[mySeat]; + if (!Array.isArray(hand)) return; + + function onCardClick(c, el) { + const idx = widowSelected.indexOf(c); + if (idx >= 0) { + widowSelected.splice(idx, 1); + el.classList.remove('selected'); + } else { + const needed = lastState?.widowSize || 4; + if (widowSelected.length < needed) { + widowSelected.push(c); + el.classList.add('selected'); + } + } + updateWidowPreview(lastState); + } + + // Render cards grouped by suit (like Hearts pass overlay) + WIDOW_SUIT_ORDER.forEach(s => { + const suitCards = hand.filter(c => c.startsWith(s + '-')); + if (suitCards.length === 0) return; + + const row = document.createElement('div'); + row.className = 'pass-suit-row'; + + const lbl = document.createElement('span'); + lbl.className = 'pass-suit-label suit-' + s.toLowerCase(); + lbl.textContent = WIDOW_SUIT_LABEL[s]; + row.appendChild(lbl); + + const wrap = document.createElement('div'); + wrap.className = 'pass-suit-cards'; + suitCards.forEach(code => { + const card = makeCardEl(code, { onClick: onCardClick }); + if (widowSelected.includes(code)) card.classList.add('selected'); + wrap.appendChild(card); + }); + row.appendChild(wrap); + container.appendChild(row); + }); + + // Jokers as their own row at the end + const jokers = hand.filter(c => c.startsWith('JOKER-')); + if (jokers.length > 0) { + const row = document.createElement('div'); + row.className = 'pass-suit-row'; + const lbl = document.createElement('span'); + lbl.className = 'pass-suit-label'; + lbl.textContent = '🃏'; + row.appendChild(lbl); + const wrap = document.createElement('div'); + wrap.className = 'pass-suit-cards'; + jokers.forEach(code => { + const card = makeCardEl(code, { onClick: onCardClick }); + if (widowSelected.includes(code)) card.classList.add('selected'); + wrap.appendChild(card); + }); + row.appendChild(wrap); + container.appendChild(row); + } +} + +function updateWidowPreview(st) { + const needed = st?.widowSize || 4; + const preview = $('widow-selected-preview'); + preview.innerHTML = ''; + for (let i = 0; i < needed; i++) { + if (widowSelected[i]) { + preview.appendChild(makeCardEl(widowSelected[i])); + } else { + const ph = document.createElement('div'); + ph.className = 'pass-placeholder'; + preview.appendChild(ph); + } + } + $('btn-confirm-discard').disabled = widowSelected.length !== needed; +} + +// ─── Trump overlay ──────────────────────────────────────────── +function renderTrumpOverlay(st) { + show('overlay-trump'); + if (mySeat === st.declarer && !spectating) { + show('trump-declare-inner'); + hide('trump-waiting-inner'); + } else { + hide('trump-declare-inner'); + show('trump-waiting-inner'); + $('trump-waiting-msg').textContent = + `${st.names[st.declarer] || 'Declarer'} is choosing the trump suit…`; + } +} + +// ─── Hand Over ──────────────────────────────────────────────── +function onHandOver(st) { + lastState = st; + hideAllOverlays(); + renderInfoBar(st); + + const dTeam = st.declarer >= 0 ? st.declarer % 2 : 0; + const oTeam = 1 - dTeam; + const dNames = [st.names[dTeam === 0 ? 0 : 1], st.names[dTeam === 0 ? 2 : 3]].join(' & '); + const oNames = [st.names[oTeam === 0 ? 0 : 1], st.names[oTeam === 0 ? 2 : 3]].join(' & '); + const dDelta = st.handDeltas?.[dTeam] ?? 0; + const oDelta = st.handDeltas?.[oTeam] ?? 0; + + let icon = '🎴', title = 'Hand Over'; + if (st.isShelemHand) { + icon = '🎯'; + title = 'SHELEM! All tricks won!'; + } else if (dDelta < 0) { + icon = '😬'; + title = `${dNames} failed the bid`; + } else { + icon = '✅'; + title = `${dNames} made the bid`; + } + + $('hand-result-icon').textContent = icon; + $('hand-result-title').textContent = title; + $('hand-result-detail').textContent = + `Bid: ${st.highBid} | Declarer earned: ${st.teamCardPoints?.[dTeam] ?? 0} pts`; + + const scoresEl = $('hand-result-scores'); + scoresEl.innerHTML = ''; + + for (let team = 0; team < 2; team++) { + const delta = st.handDeltas?.[team] ?? 0; + const names = [st.names[team === 0 ? 0 : 1], st.names[team === 0 ? 2 : 3]] + .filter(Boolean).join(' & ') || `Team ${team + 1}`; + const row = document.createElement('div'); + row.className = 'result-score-row'; + const isWinner = mySeat >= 0 && teamOf(mySeat) === team && delta > 0; + if (isWinner) row.classList.add('winner'); + row.innerHTML = ` + ${names} + ${delta >= 0 ? '+' : ''}${delta} + → ${st.scores[team]} + `; + scoresEl.appendChild(row); + } + + show('overlay-hand'); + // overlay-hand is hidden by hideAllOverlays() when the next render() fires (BIDDING state) +} + +// ─── Game Over ──────────────────────────────────────────────── +function onGameOver(st) { + lastState = st; + hideAllOverlays(); + hide('overlay-hand'); + + const winTeams = new Set(st.gameWinner || []); + const myTeam = mySeat >= 0 ? teamOf(mySeat) : -1; + const iWon = winTeams.has(myTeam); + $('gameover-title').textContent = iWon ? '🏆 Your team wins!' : '🎴 Game Over'; + + const scoresEl = $('gameover-scores'); + scoresEl.innerHTML = ''; + for (let team = 0; team < 2; team++) { + const names = [st.names[team === 0 ? 0 : 1], st.names[team === 0 ? 2 : 3]] + .filter(Boolean).join(' & ') || `Team ${team + 1}`; + const row = document.createElement('div'); + row.className = 'gameover-row' + (winTeams.has(team) ? ' winner' : ''); + row.innerHTML = `${names}${st.scores[team]}`; + scoresEl.appendChild(row); + } + + show('overlay-gameover'); +} + +// ─── Auth ───────────────────────────────────────────────────── +function updateAuthBar() { + if (authUser) { + $('auth-status').textContent = `Logged in as ${authUser}`; + show('btn-show-profile'); show('btn-logout'); hide('btn-show-auth'); + } else { + $('auth-status').textContent = 'Playing as guest'; + hide('btn-show-profile'); hide('btn-logout'); show('btn-show-auth'); + } +} + +async function doLogin() { + const username = $('auth-login-user').value.trim(); + const password = $('auth-login-pass').value; + $('auth-login-error').textContent = ''; + try { + const r = await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + const d = await r.json(); + if (!r.ok) { $('auth-login-error').textContent = d.error || 'Login failed'; return; } + authToken = d.token; authUser = d.username; + localStorage.setItem('shelem_token', authToken); + localStorage.setItem('shelem_user', authUser); + hide('overlay-auth'); + updateAuthBar(); + if (socket) socket.auth = { token: authToken }; + } catch { $('auth-login-error').textContent = 'Network error'; } +} + +async function doRegister() { + const username = $('auth-reg-user').value.trim(); + const password = $('auth-reg-pass').value; + $('auth-reg-error').textContent = ''; + if (!username || !password) { $('auth-reg-error').textContent = 'All fields required'; return; } + try { + const r = await fetch('/api/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + const d = await r.json(); + if (!r.ok) { $('auth-reg-error').textContent = d.error || 'Registration failed'; return; } + authToken = d.token; authUser = d.username; + localStorage.setItem('shelem_token', authToken); + localStorage.setItem('shelem_user', authUser); + hide('overlay-auth'); + updateAuthBar(); + } catch { $('auth-reg-error').textContent = 'Network error'; } +} + +function doLogout() { + authToken = null; authUser = null; + localStorage.removeItem('shelem_token'); + localStorage.removeItem('shelem_user'); + updateAuthBar(); +} + +// ─── Profile ────────────────────────────────────────────────── +async function showProfile(username) { + if (!username) return; + showScreen('screen-profile'); + $('profile-username').textContent = username; + try { + const r = await fetch(`/api/profile/${encodeURIComponent(username)}`); + const d = await r.json(); + if (!r.ok) return; + $('stat-games-played').textContent = d.games_played; + $('stat-games-won').textContent = d.games_won; + $('stat-shelem').textContent = d.shelemCount || 0; + $('stat-total-score').textContent = d.total_score; + + // Change password button (only own profile) + if (username === authUser) show('btn-show-change-pass'); + else hide('btn-show-change-pass'); + + // Admin panel + if (d.isAdmin) { + show('admin-panel'); + const toggleBtn = $('btn-toggle-signups'); + toggleBtn.textContent = d.signupsOpen ? 'Open ✓' : 'Closed ✗'; + toggleBtn.classList.toggle('signup-open', d.signupsOpen); + } else { + hide('admin-panel'); + } + } catch { /* ignore */ } +} + +async function showLeaderboard() { + showScreen('screen-leaderboard'); + try { + const r = await fetch('/api/leaderboard'); + const rows = await r.json(); + const tbody = $('lb-body'); + tbody.innerHTML = ''; + rows.forEach((row, i) => { + const tr = document.createElement('tr'); + tr.innerHTML = ` + ${i + 1} + ${row.username} + ${row.score_per_game ?? '—'} + ${row.games_won} + ${row.games_played} + ${row.shelemCount || 0} + `; + tbody.appendChild(tr); + }); + } catch { /* ignore */ } +} + +// ─── Event wiring ────────────────────────────────────────────── +function wireGameEvents() { + // Waiting room + $('btn-leave-waiting').addEventListener('click', () => { + socket?.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken }); + clearSession(); + showScreen('screen-lobby'); + }); + $('btn-copy').addEventListener('click', async () => { + const code = $('display-room-code').textContent.trim(); + const btn = $('btn-copy'); + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(code); + } else { + const ta = document.createElement('textarea'); + ta.value = code; + ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0'; + document.body.appendChild(ta); + ta.focus(); ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + } + btn.textContent = '✓'; + btn.style.color = '#69f0ae'; + } catch(e) { + btn.textContent = '✗'; + } + setTimeout(() => { btn.textContent = '⧉'; btn.style.color = ''; }, 1500); + }); + $('btn-fill-bots').addEventListener('click', () => { + socket?.emit('fillBots', { roomId: myRoomId }); + }); + + // 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'); + }); + $('btn-refresh-game').addEventListener('click', () => { + if (myRoomId && mySeat >= 0 && myToken) { + socket?.emit('rejoin', { roomId: myRoomId, seat: mySeat, token: myToken }); + } + hide('game-menu-dropdown'); + }); + $('btn-toggle-bar').addEventListener('click', () => { + barAtBottom = !barAtBottom; + saveBarBottom(barAtBottom); + applyBarBottom(); + hide('game-menu-dropdown'); + }); + $('btn-exit-game').addEventListener('click', () => { + hide('game-menu-dropdown'); + show('overlay-exit-confirm'); + }); + $('btn-leave-game').addEventListener('click', () => show('overlay-exit-confirm')); + $('btn-exit-confirm-yes').addEventListener('click', () => { + socket?.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken }); + clearSession(); + hide('overlay-exit-confirm'); + hide('overlay-hand'); + hide('overlay-gameover'); + mySeat = -1; myRoomId = null; myToken = null; spectating = false; + showScreen('screen-lobby'); + }); + $('btn-exit-confirm-no').addEventListener('click', () => hide('overlay-exit-confirm')); + + // Bid controls + $('btn-bid-plus').addEventListener('click', () => { + currentBidAmount += 5; + $('bid-amount-display').textContent = currentBidAmount; + }); + $('btn-bid-minus').addEventListener('click', () => { + const floorBid = lastState?.jokerMode ? 105 : 85; + const minBid = Math.max(floorBid, (lastState?.highBid || 0) + 5); + if (currentBidAmount - 5 >= minBid) currentBidAmount -= 5; + $('bid-amount-display').textContent = currentBidAmount; + }); + $('btn-do-bid').addEventListener('click', () => { + if (!lastState) return; + const minBid = Math.max(85, (lastState.highBid || 80) + 5); + if (currentBidAmount < minBid) { currentBidAmount = minBid; } + socket?.emit('bid', { roomId: myRoomId, seat: mySeat, token: myToken, amount: currentBidAmount }); + }); + $('btn-do-pass').addEventListener('click', () => { + socket?.emit('bid', { roomId: myRoomId, seat: mySeat, token: myToken, amount: 'pass' }); + }); + + // Discard confirm + $('btn-confirm-discard').addEventListener('click', () => { + if (widowSelected.length !== (lastState?.widowSize || 4)) return; + socket?.emit('discard', { roomId: myRoomId, seat: mySeat, token: myToken, cards: widowSelected }); + }); + + // Trump declaration + document.querySelectorAll('.trump-suit-btn').forEach(btn => { + btn.addEventListener('click', () => { + socket?.emit('declareTrump', { roomId: myRoomId, seat: mySeat, token: myToken, trump: btn.dataset.suit }); + }); + }); + + // Play mode toggle (tap ↔ drag) + $('btn-play-mode')?.addEventListener('click', () => { + playMode = playMode === 'tap' ? 'drag' : 'tap'; + savePlayMode(playMode); + updatePlayModeBtn(); + if (lastState) renderMyHand(lastState); + }); + + // Hand display mode cycle: scroll → fan → playables → scroll + $('btn-hand-mode')?.addEventListener('click', () => { + const idx = HAND_MODES.indexOf(handMode); + handMode = HAND_MODES[(idx + 1) % HAND_MODES.length]; + saveHandMode(handMode); + updateHandModeBtn(); + if (lastState) renderMyHand(lastState); + }); + + // New game + $('btn-new-game').addEventListener('click', () => { + hide('overlay-gameover'); + socket?.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken }); + clearSession(); + mySeat = -1; myRoomId = null; myToken = null; + showScreen('screen-lobby'); + }); + + // Profile / leaderboard back buttons + $('btn-profile-back').addEventListener('click', () => showScreen('screen-lobby')); + $('btn-lb-back').addEventListener('click', () => showScreen('screen-lobby')); + + // Change password + $('btn-show-change-pass').addEventListener('click', () => { + $('change-pass-form').classList.toggle('hidden'); + }); + $('btn-do-change-pass').addEventListener('click', async () => { + const cur = $('change-pass-current').value; + const nw = $('change-pass-new').value; + $('change-pass-msg').textContent = ''; + try { + const r = await fetch('/api/change-password', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}`, + }, + body: JSON.stringify({ currentPassword: cur, newPassword: nw }), + }); + const d = await r.json(); + $('change-pass-msg').textContent = r.ok ? '✓ Password updated' : (d.error || 'Failed'); + $('change-pass-msg').style.color = r.ok ? '#69f0ae' : '#ff6b6b'; + } catch { $('change-pass-msg').textContent = 'Network error'; } + }); + + $('btn-toggle-signups').addEventListener('click', async () => { + try { + const r = await fetch('/api/admin/toggle-signups', { + method: 'POST', + headers: { 'Authorization': `Bearer ${authToken}` }, + }); + const d = await r.json(); + if (r.ok) { + const btn = $('btn-toggle-signups'); + btn.textContent = d.signupsOpen ? 'Open ✓' : 'Closed ✗'; + btn.classList.toggle('signup-open', d.signupsOpen); + } + } catch { /* ignore */ } + }); +} + +// ─── Boot ───────────────────────────────────────────────────── +document.addEventListener('DOMContentLoaded', () => { + initLobby(); + wireGameEvents(); + applyBarBottom(); + + // Pre-fill name from auth + if (authUser) $('input-name').value = authUser; + + // Try to rejoin previous session + tryRejoin(); +}); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..4365af7 --- /dev/null +++ b/public/index.html @@ -0,0 +1,425 @@ + + + + + + Shelem + + + + + + + + + + + + +
+
+

🃏 Shelem

+

The Persian trick-taking partnership game

+ +
+ Playing as guest +
+ + + + +
+
+ +
+ + +
+ +
+ + +
+ +
+
+ +
+ + +
+
+
+ +
+ + + +
+
+ +
+ +
+
+ + +
+ +
+
+ + +
+ +
+ +

+
+
+ + +
+
+ +

Waiting for Players

+
+ Room Code + —— + +
+

Share this code — 4 players needed

+ + +
+
1
+
2
+
3
+
4
+
+

Seats 1&2 vs Seats 3&4

+

Waiting for 3 more players…

+ + +
+
+ + +
+ + +
+ + + +
+
+
+
+ + +
+ + +
+ +
+ + +
+
+ + + + + +
+ + +
+
+ + + partner + +
+
+
+ + +
+
+ + + +
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+ + +
+
+ + + +
+
+
+ +
+ + +
+
+ You + + partner + + + + +
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ +
🃏
+

+
+
+ + Games Played +
+
+ + Games Won +
+
+ + Shelems 🃏 +
+
+ + Total Score +
+
+ +
+

Scoring at a glance

+
    +
  • Ace / 10 — 10 pts each
  • +
  • Five — 5 pts each
  • +
  • Color Joker — 20 pts (trump)
  • +
  • Black Joker — 15 pts (trump)
  • +
  • Each trick won — 5 pts
  • +
  • Shelem — win every trick for 250 pts
  • +
  • Default win score: 505 pts
  • +
+
+ +
+ + +
+ + +
+
+
+ + +
+
+
+ +
🏆
+

Leaderboard

+ + + + + + + +
#PlayerAvg ScoreWPlayed🃏
+
+
+
+ + + + + + + + + diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..c4a6c91 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "Shelem", + "short_name": "Shelem", + "description": "The Persian trick-taking partnership card game", + "start_url": "/", + "display": "standalone", + "background_color": "#133025", + "theme_color": "#1a3a1a", + "icons": [ + { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "/icons/icon.svg", "sizes": "any", "type": "image/svg+xml" } + ] +} diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..2323262 --- /dev/null +++ b/public/style.css @@ -0,0 +1,958 @@ +/* ── Reset & Base ──────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --felt: #1d6b3a; + --felt-dark: #133025; + --felt-light: #268847; + --card-w: 68px; + --card-h: 98px; + --radius: 8px; + --shadow: 0 2px 8px rgba(0,0,0,.4); + --gold: #f5c518; + --accent: #c8860a; + --red: #d32f2f; + --muted: rgba(255,255,255,.55); + --team-a: #4fc3f7; /* seats 0 & 2 */ + --team-b: #ef9a9a; /* seats 1 & 3 */ +} + +html, body { + height: 100%; + overflow: hidden; + font-family: 'Segoe UI', system-ui, sans-serif; + background: var(--felt-dark); + color: #fff; + touch-action: manipulation; +} + +/* ── Screens ──────────────────────────────────── */ +.screen { display: none; } +.screen.active { display: flex; flex-direction: column; height: 100vh; } + +/* ── Lobby ────────────────────────────────────── */ +#screen-lobby { + justify-content: center; + align-items: center; + background: radial-gradient(ellipse at center, #1d5c3a 0%, var(--felt-dark) 100%); +} + +.lobby-box { + background: rgba(0,0,0,.5); + border: 1px solid rgba(255,255,255,.12); + border-radius: 16px; + padding: 28px 34px; + width: min(430px, 94vw); + display: flex; + flex-direction: column; + gap: 14px; + max-height: 96vh; + overflow-y: auto; +} + +.logo { + font-size: 2.4rem; + text-align: center; + color: var(--gold); + text-shadow: 0 2px 12px rgba(200,130,10,.6); + letter-spacing: 2px; +} + +.tagline { + text-align: center; + color: rgba(255,255,255,.55); + font-size: .85rem; + margin-top: -8px; +} + +/* ── Auth bar ─────────────────────────────────── */ +.auth-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 10px; + background: rgba(255,255,255,.06); + border: 1px solid rgba(255,255,255,.1); + border-radius: 10px; + font-size: .8rem; +} +.auth-status { color: var(--muted); } +.auth-bar-actions { display: flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end; } +.btn-text { + background: none; border: none; color: var(--gold); + cursor: pointer; font-size: .78rem; padding: 2px 5px; + text-decoration: underline; +} +.btn-text:hover { color: #fff; } + +/* ── Tabs ─────────────────────────────────────── */ +.lobby-tabs { display: flex; gap: 6px; } +.tab-btn { + flex: 1; + padding: 7px; + background: rgba(255,255,255,.07); + border: 1px solid rgba(255,255,255,.12); + border-radius: 8px; + color: var(--muted); + cursor: pointer; + font-size: .85rem; + transition: background .15s; +} +.tab-btn.active, .tab-btn:hover { background: rgba(255,255,255,.18); color: #fff; } +.tab-panel { display: none; flex-direction: column; gap: 10px; } +.tab-panel.active { display: flex; } + +/* ── Fields & inputs ──────────────────────────── */ +.field { display: flex; flex-direction: column; gap: 5px; } +.field label { font-size: .8rem; color: var(--muted); } +.field small { color: rgba(255,255,255,.4); font-size: .72rem; } +input[type=text], input[type=password], input[type=email] { + width: 100%; + padding: 9px 12px; + background: rgba(255,255,255,.09); + border: 1px solid rgba(255,255,255,.18); + border-radius: 8px; + color: #fff; + font-size: .9rem; + outline: none; +} +input:focus { border-color: var(--gold); } + +/* ── Mode / score buttons ─────────────────────── */ +.mode-row, .score-row { display: flex; gap: 6px; } +.mode-btn, .score-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; +} +.mode-btn small { display: block; font-size: .72rem; color: rgba(255,255,255,.4); margin-top: 2px; } +.mode-btn.active, .mode-btn:hover, +.score-btn.active, .score-btn:hover { + background: rgba(245,197,24,.2); + border-color: var(--gold); + color: var(--gold); +} + +/* ── Buttons ──────────────────────────────────── */ +.btn-primary { + padding: 10px; + background: rgba(245,197,24,.25); + border: 1px solid rgba(245,197,24,.55); + border-radius: 10px; + color: var(--gold); + font-size: .95rem; + font-weight: 600; + cursor: pointer; + transition: background .15s; +} +.btn-primary:hover { background: rgba(245,197,24,.38); } +.btn-primary:disabled { opacity: .4; cursor: default; } + +.btn-secondary { + padding: 9px; + background: rgba(255,255,255,.07); + border: 1px solid rgba(255,255,255,.16); + border-radius: 10px; + color: var(--muted); + font-size: .9rem; + cursor: pointer; + transition: background .15s; +} +.btn-secondary:hover { background: rgba(255,255,255,.15); } + +.divider { border: none; border-top: 1px solid rgba(255,255,255,.1); margin: 2px 0; } +.error-msg { color: #ff6b6b; font-size: .82rem; min-height: 16px; } +.hint { color: var(--muted); font-size: .82rem; text-align: center; } + +/* ── Waiting Room ─────────────────────────────── */ +#screen-waiting { + justify-content: center; + align-items: center; + background: radial-gradient(ellipse at center, #1d5c3a 0%, var(--felt-dark) 100%); +} +.waiting-box { + background: rgba(0,0,0,.48); + border: 1px solid rgba(255,255,255,.12); + border-radius: 16px; + padding: 28px 34px; + width: min(380px, 94vw); + display: flex; + flex-direction: column; + gap: 14px; + align-items: center; + text-align: center; +} +.btn-leave-screen { + align-self: flex-start; + background: none; border: none; color: var(--muted); + cursor: pointer; font-size: .85rem; + padding: 0; text-decoration: underline; +} +.btn-leave-screen:hover { color: #fff; } + +.room-code-box { + display: flex; align-items: center; gap: 8px; + background: rgba(255,255,255,.07); + border: 1px solid rgba(255,255,255,.14); + border-radius: 10px; + padding: 8px 14px; +} +.room-code-box .label { font-size: .75rem; color: var(--muted); } +.room-code { font-size: 1.5rem; font-weight: 700; letter-spacing: 3px; color: var(--gold); } +.btn-copy { + background: none; border: none; color: var(--muted); + cursor: pointer; font-size: 1rem; padding: 2px 4px; +} + +.waiting-seats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + width: 100%; +} +.seat-slot { + display: flex; align-items: center; gap: 8px; + padding: 8px 12px; + background: rgba(255,255,255,.05); + border: 1px solid rgba(255,255,255,.12); + border-radius: 8px; + font-size: .85rem; +} +.seat-slot.partner-a { border-color: rgba(79,195,247,.3); } +.seat-slot.partner-b { border-color: rgba(239,154,154,.3); } +.seat-num { color: var(--muted); font-size: .75rem; min-width: 14px; } +.seat-name { font-weight: 600; color: #fff; } +.waiting-status { font-size: .88rem; color: var(--muted); } +.waiting-options { display: flex; gap: 10px; justify-content: center; } +.waiting-opt-label { + font-size: .78rem; color: var(--gold); + padding: 2px 8px; + background: rgba(245,197,24,.1); + border: 1px solid rgba(245,197,24,.25); + border-radius: 12px; +} +.btn-fill-bots { + padding: 8px 16px; + 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%; +} +.btn-fill-bots:hover { background: rgba(255,255,255,.16); color: #fff; } + +/* ── Game screen ──────────────────────────────── */ +#screen-game { background: var(--felt); overflow: hidden; position: relative; } + +/* Info bar */ +#info-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: rgba(0,0,0,.35); + border-bottom: 1px solid rgba(0,0,0,.3); + flex-shrink: 0; + z-index: 10; +} + +#score-block { + display: flex; + gap: 8px; + flex: 1; + justify-content: center; +} +.team-score { + font-size: .78rem; + font-weight: 700; + padding: 2px 10px; + border-radius: 20px; + white-space: nowrap; +} +.team-score.team-a { background: rgba(79,195,247,.2); border: 1px solid rgba(79,195,247,.4); color: var(--team-a); } +.team-score.team-b { background: rgba(239,154,154,.2); border: 1px solid rgba(239,154,154,.4); color: var(--team-b); } + +#game-meta { display: flex; gap: 8px; align-items: center; } +.trump-display { + font-size: .8rem; font-weight: 700; + padding: 2px 8px; + border-radius: 12px; + background: rgba(245,197,24,.2); + border: 1px solid rgba(245,197,24,.4); + color: var(--gold); +} +.bid-display { + font-size: .75rem; + color: var(--muted); + padding: 2px 8px; + background: rgba(255,255,255,.07); + border: 1px solid rgba(255,255,255,.12); + border-radius: 12px; +} + +.game-menu-wrap { position: relative; } +.game-menu-dropdown { + position: absolute; + top: calc(100% + 6px); + right: 0; + background: #1a2e1a; + border: 1px solid rgba(255,255,255,.15); + border-radius: 10px; + min-width: 140px; + z-index: 200; + overflow: hidden; +} +.game-menu-item { + display: block; width: 100%; text-align: left; + padding: 9px 14px; + background: none; border: none; + color: #eee; font-size: .85rem; cursor: pointer; +} +.game-menu-item:hover { background: rgba(255,255,255,.1); } +.game-menu-exit { color: #ff8080; } + +.spectator-banner { + background: rgba(0,0,0,.5); + text-align: center; + padding: 5px; + font-size: .8rem; + color: var(--muted); + flex-shrink: 0; +} + +/* ── Table grid ───────────────────────────────── */ +#table-grid { + flex: 1; + display: grid; + grid-template-columns: 90px 1fr 90px; + grid-template-rows: auto 1fr; + gap: 4px; + padding: 6px; + overflow: hidden; + min-height: 0; +} + +.player-area { display: flex; flex-direction: column; align-items: center; gap: 4px; } +.area-top { grid-column: 2; grid-row: 1; } +.area-left { grid-column: 1; grid-row: 1 / 3; justify-content: center; } +.area-right { grid-column: 3; grid-row: 1 / 3; justify-content: center; } + +#trick-area { + grid-column: 2; + grid-row: 2; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + position: relative; +} + +.player-label { + display: flex; + align-items: center; + gap: 4px; + font-size: .8rem; + font-weight: 600; + white-space: nowrap; + background: rgba(0,0,0,.3); + border-radius: 12px; + padding: 3px 8px; +} +.player-label.vertical { flex-direction: column; font-size: .72rem; } + +.turn-dot { color: var(--gold); font-size: .6rem; animation: pulse 1s infinite; } +@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} } + +.partner-badge { + font-size: .6rem; + padding: 1px 5px; + border-radius: 8px; + background: rgba(79,195,247,.2); + color: var(--team-a); + border: 1px solid rgba(79,195,247,.3); +} +#my-partner-label { background: rgba(79,195,247,.15); } + +.player-score-badge { + font-size: .7rem; color: var(--gold); + padding: 1px 5px; + background: rgba(245,197,24,.12); + border-radius: 8px; +} +.tricks-badge { + font-size: .7rem; color: var(--muted); + padding: 1px 5px; + background: rgba(255,255,255,.08); + border-radius: 8px; +} + +/* ── Card back / face-down ────────────────────── */ +.opp-cards { + display: flex; + flex-wrap: wrap; + gap: 3px; + justify-content: center; + max-width: 140px; +} +.opp-cards.vertical { + flex-direction: column; + max-width: 36px; + max-height: 200px; + overflow: hidden; +} + +.card-back { + width: 36px; + height: 52px; + background: linear-gradient(135deg, #1565c0 0%, #0d47a1 50%, #1565c0 100%); + border-radius: 4px; + border: 1px solid rgba(255,255,255,.25); + box-shadow: 0 1px 3px rgba(0,0,0,.4); + flex-shrink: 0; +} +.opp-cards.vertical .card-back { width: 30px; height: 42px; } + +/* ── Playing cards ────────────────────────────── */ +.card { + width: var(--card-w); + height: var(--card-h); + border-radius: var(--radius); + box-shadow: var(--shadow); + cursor: pointer; + flex-shrink: 0; + transition: transform .12s, box-shadow .12s; + position: relative; + overflow: hidden; + border: 2px solid transparent; + background: #fff; +} +.card img { width: 100%; height: 100%; object-fit: contain; display: block; border-radius: 6px; } +.card:hover { transform: translateY(-8px); box-shadow: 0 8px 20px rgba(0,0,0,.5); } +.card.selected { + transform: translateY(-12px); + border-color: var(--gold); + box-shadow: 0 0 12px rgba(245,197,24,.6); +} +.card.illegal { cursor: default; } +.card.illegal::after { + content: ''; + position: absolute; + inset: 0; + background: rgba(0,0,0,.55); + border-radius: 6px; + pointer-events: none; +} + +/* Magnifier (drag mode, dense fan) */ +.card.magnified { + transform: translateY(-34px) scale(1.5) !important; + z-index: 50 !important; + box-shadow: 0 14px 36px rgba(0,0,0,.75) !important; + border: 2px solid #fff !important; + transition: transform .1s, box-shadow .1s !important; +} + +/* Joker identification badges */ +.joker-badge { + position: absolute; + bottom: 3px; + left: 50%; + transform: translateX(-50%); + font-size: .58rem; + font-weight: 800; + padding: 2px 6px; + border-radius: 4px; + white-space: nowrap; + pointer-events: none; + line-height: 1.3; + text-shadow: 0 1px 2px rgba(0,0,0,.6); + box-shadow: 0 1px 4px rgba(0,0,0,.5); +} +.joker-color-badge { + background: linear-gradient(90deg, #e040fb, #ff6d00, #ffea00, #00e676, #2979ff); + color: #fff; + border: none; +} +.joker-black-badge { + background: linear-gradient(135deg, #263238 0%, #455a64 100%); + color: #eceff1; + border: 1px solid rgba(255,255,255,.35); +} + +/* ── Trick area ───────────────────────────────── */ +.trick-slot { + width: var(--card-w); + height: var(--card-h); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.trick-slot img { + width: var(--card-w); + height: var(--card-h); + border-radius: 6px; + box-shadow: var(--shadow); + display: block; +} +.trick-card-wrap { + position: relative; + width: var(--card-w); + height: var(--card-h); + flex-shrink: 0; +} +.trick-card-wrap img { + width: 100%; + height: 100%; + border-radius: 6px; + box-shadow: var(--shadow); + display: block; +} +.trick-middle-row { + display: flex; align-items: center; justify-content: center; gap: 4px; +} +.trick-center-info { + width: 60px; text-align: center; flex-shrink: 0; +} +.phase-msg { + font-size: .72rem; color: rgba(255,255,255,.6); + line-height: 1.4; +} + +/* Drop-zone pulse when dragging */ +#trick-area { position: relative; } +#trick-area.drag-active::before { + content: ''; + position: absolute; + inset: -10px; + border: 2px dashed rgba(255,255,255,.4); + border-radius: 18px; + pointer-events: none; + animation: pulse-border .8s ease-in-out infinite alternate; +} +@keyframes pulse-border { from { opacity: .35; } to { opacity: 1; } } + +/* Drag ghost */ +.drag-ghost { + position: fixed; + pointer-events: none; + opacity: .92; + z-index: 9999; + transform: scale(1.15) rotate(-4deg); + box-shadow: 0 12px 32px rgba(0,0,0,.7); + border-radius: 6px; + width: var(--card-w); + height: var(--card-h); + overflow: hidden; + transition: none !important; +} +.drag-ghost img { width: 100%; height: 100%; display: block; } + +/* ── My hand ──────────────────────────────────── */ +#my-area { + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px 8px calc(8px + env(safe-area-inset-bottom)); + background: rgba(0,0,0,.2); + border-top: 1px solid rgba(0,0,0,.3); +} +#my-area.fan-active { overflow: visible; } + +#my-label { + display: flex; + align-items: center; + gap: 6px; + font-size: .8rem; + font-weight: 600; +} + +/* Scroll mode (default) */ +#my-hand { + display: flex; + align-items: flex-end; + gap: 4px; + overflow-x: auto; + overflow-y: visible; + padding: 2px 0 4px; + scrollbar-width: none; + justify-content: safe center; +} +#my-hand::-webkit-scrollbar { display: none; } + +/* Fan / overlap mode */ +#my-hand.fan-mode { + gap: 0; + overflow: visible; + padding: 14px 4px 4px; + justify-content: flex-start; +} + +/* Play / hand mode toggle buttons */ +.btn-play-mode, +.btn-hand-mode { + margin-left: auto; + padding: 2px 8px; + background: rgba(255,255,255,.12); + border: 1px solid rgba(255,255,255,.25); + border-radius: 6px; + color: rgba(255,255,255,.8); + font-size: .72rem; + cursor: pointer; + white-space: nowrap; +} +.btn-play-mode:hover, .btn-hand-mode:hover { background: rgba(255,255,255,.22); } +.btn-play-mode.drag-mode { + background: rgba(100,200,100,.18); + border-color: #66bb6a; + color: #a5d6a7; +} +.btn-hand-mode.fan-mode { + background: rgba(100,180,255,.18); + border-color: rgba(100,180,255,.5); + color: rgba(180,220,255,.9); +} + +/* ── Overlays ─────────────────────────────────── */ +.overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,.72); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + padding: 12px; +} +.overlay.hidden { display: none; } + +.overlay-box { + background: #1a2e1a; + border: 1px solid rgba(255,255,255,.15); + border-radius: 16px; + padding: 26px 28px; + width: min(440px, 96vw); + display: flex; + flex-direction: column; + gap: 14px; + max-height: 92vh; + overflow-y: auto; +} +.overlay-box.small { max-width: 320px; } +.overlay-box h3 { font-size: 1.1rem; color: var(--gold); text-align: center; } + +/* ── Bidding overlay ──────────────────────────── */ +.bid-box {} + +.bid-history { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 180px; + overflow-y: auto; +} +.bid-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + background: rgba(255,255,255,.05); + border: 1px solid rgba(255,255,255,.1); + border-radius: 8px; + font-size: .85rem; +} +.bid-row.active-bidder { border-color: var(--gold); background: rgba(245,197,24,.08); } +.bid-row .bid-name { font-weight: 600; } +.bid-row .bid-val { color: var(--gold); font-weight: 700; } +.bid-row .bid-val.passed { color: var(--muted); font-weight: 400; font-style: italic; } +.bid-row .bid-val.waiting { color: var(--muted); } + +.bid-hint { font-size: .82rem; color: var(--muted); text-align: center; } +.bid-input-row { + display: flex; + align-items: center; + justify-content: center; + gap: 14px; +} +.bid-adj { + width: 40px; height: 40px; + background: rgba(255,255,255,.1); + border: 1px solid rgba(255,255,255,.2); + border-radius: 8px; + color: #fff; font-size: 1rem; cursor: pointer; +} +.bid-adj:hover { background: rgba(255,255,255,.2); } +.bid-amount-display { + font-size: 1.8rem; + font-weight: 700; + color: var(--gold); + min-width: 60px; + text-align: center; +} +.bid-action-row { display: flex; gap: 10px; } +.bid-action-row .btn-primary, .bid-action-row .btn-secondary { flex: 1; } + +/* ── Widow overlay ────────────────────────────── */ +.widow-box {} +.pass-hint { font-size: .82rem; color: var(--muted); text-align: center; } + +/* One row per suit — matches Hearts pass overlay */ +.pass-hand { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 4px; + width: 100%; + max-height: 55vh; + overflow-y: auto; +} +.pass-suit-row { + display: flex; + align-items: center; + gap: 6px; +} +.pass-suit-label { + font-size: 1.3rem; + width: 28px; + text-align: center; + flex-shrink: 0; + line-height: 1; +} +.pass-suit-label.suit-h, +.pass-suit-label.suit-d { color: #ff8080; } +.pass-suit-label.suit-c, +.pass-suit-label.suit-s { color: #ddd; } +.pass-suit-cards { + display: flex; + gap: 4px; + flex-wrap: wrap; +} +.pass-hand .card { width: 58px; height: 82px; } +.pass-hand .card img { width: 58px; height: 82px; } + +.pass-selected-row { + display: flex; align-items: center; gap: 8px; + min-height: 36px; +} +.pass-selected-label { font-size: .8rem; color: var(--muted); white-space: nowrap; } +.pass-selected-preview { display: flex; gap: 4px; flex-wrap: wrap; } +.pass-selected-preview .card { width: 48px; height: 68px; } +.pass-selected-preview .card img { width: 48px; height: 68px; } +.pass-placeholder { + width: 48px; height: 68px; + border: 2px dashed rgba(255,255,255,.2); + border-radius: 6px; +} + +/* ── Trump overlay ────────────────────────────── */ +.trump-box { align-items: center; } +.trump-suit-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + width: 100%; +} +.trump-suit-btn { + padding: 14px 10px; + background: rgba(255,255,255,.07); + border: 1px solid rgba(255,255,255,.14); + border-radius: 10px; + color: #fff; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: background .15s, border-color .15s; +} +.trump-suit-btn:hover { background: rgba(245,197,24,.2); border-color: var(--gold); color: var(--gold); } +.trump-suit-btn[data-suit="H"], .trump-suit-btn[data-suit="D"] { color: #ff8080; } +.trump-suit-btn[data-suit="H"]:hover, .trump-suit-btn[data-suit="D"]:hover { color: var(--gold); } + +/* ── Hand / game over overlays ────────────────── */ +.result-icon { font-size: 2.5rem; text-align: center; } +.result-icon.big { font-size: 3rem; } + +.result-scores { + display: flex; + flex-direction: column; + gap: 6px; +} +.result-score-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 12px; + background: rgba(255,255,255,.06); + border-radius: 8px; + font-size: .88rem; +} +.result-score-row.winner { background: rgba(245,197,24,.12); border: 1px solid rgba(245,197,24,.3); } +.result-score-row .team-label { font-weight: 600; } +.result-score-row .delta { font-weight: 700; } +.delta.pos { color: #69f0ae; } +.delta.neg { color: #ff6b6b; } + +.gameover-scores { + display: flex; + flex-direction: column; + gap: 8px; + margin: 4px 0; +} +.gameover-row { + display: flex; + justify-content: space-between; + padding: 8px 14px; + border-radius: 10px; + background: rgba(255,255,255,.06); + font-size: .9rem; +} +.gameover-row.winner { background: rgba(245,197,24,.15); border: 1px solid rgba(245,197,24,.4); } + +/* ── Auth overlay ─────────────────────────────── */ +.auth-box { max-width: 360px; } +.btn-close { + align-self: flex-end; + background: none; border: none; + color: var(--muted); font-size: 1rem; cursor: pointer; + line-height: 1; +} +.auth-tabs { display: flex; gap: 6px; } +.auth-tab { + flex: 1; padding: 7px; + background: rgba(255,255,255,.07); + border: 1px solid rgba(255,255,255,.12); + border-radius: 8px; + color: var(--muted); + cursor: pointer; font-size: .85rem; +} +.auth-tab.active { background: rgba(245,197,24,.15); border-color: rgba(245,197,24,.35); color: var(--gold); } +.auth-panel { display: none; flex-direction: column; gap: 10px; } +.auth-panel.active { display: flex; } + +/* ── Profile & leaderboard ────────────────────── */ +#screen-profile, #screen-leaderboard { + background: radial-gradient(ellipse at center, #1d5c3a 0%, var(--felt-dark) 100%); + justify-content: flex-start; + align-items: center; + overflow-y: auto; + padding: 16px; +} +.profile-wrap { width: 100%; display: flex; justify-content: center; } +.profile-box { + background: rgba(0,0,0,.48); + border: 1px solid rgba(255,255,255,.12); + border-radius: 16px; + padding: 24px 28px; + width: min(400px, 96vw); + display: flex; + flex-direction: column; + gap: 14px; +} +.btn-back { + background: none; border: none; color: var(--muted); + cursor: pointer; font-size: .85rem; align-self: flex-start; + text-decoration: underline; +} +.btn-back:hover { color: #fff; } +.profile-avatar { font-size: 2.5rem; text-align: center; } +.profile-name { font-size: 1.3rem; text-align: center; color: var(--gold); } + +.stat-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} +.stat-card { + display: flex; flex-direction: column; + align-items: center; gap: 2px; + padding: 10px; + background: rgba(255,255,255,.05); + border: 1px solid rgba(255,255,255,.1); + border-radius: 10px; +} +.stat-num { font-size: 1.4rem; font-weight: 700; color: var(--gold); } +.stat-label { font-size: .72rem; color: var(--muted); text-align: center; } + +.points-legend { + background: rgba(255,255,255,.04); + border: 1px solid rgba(255,255,255,.1); + border-radius: 10px; + padding: 12px 14px; +} +.points-legend h4 { font-size: .85rem; color: var(--gold); margin-bottom: 8px; } +.points-legend ul { list-style: none; display: flex; flex-direction: column; gap: 4px; } +.points-legend li { font-size: .8rem; color: var(--muted); } +.points-legend strong { color: #fff; } + +.profile-section { display: flex; flex-direction: column; gap: 8px; } +.admin-section { + background: rgba(255,100,0,.06); + border: 1px solid rgba(255,100,0,.2); + border-radius: 10px; + padding: 10px 14px; +} +.admin-title { font-size: .85rem; color: #ffaa55; margin-bottom: 6px; } +.admin-row { display: flex; justify-content: space-between; align-items: center; font-size: .85rem; } +.btn-admin-toggle { min-width: 80px; } +.btn-admin-toggle.signup-open { border-color: rgba(100,220,100,.6); color: #69f0ae; } + +/* ── Leaderboard ──────────────────────────────── */ +.lb-table { width: 100%; border-collapse: collapse; font-size: .82rem; } +.lb-table th, .lb-table td { padding: 7px 8px; text-align: left; } +.lb-table th { color: var(--muted); font-weight: 600; border-bottom: 1px solid rgba(255,255,255,.1); } +.lb-table td { border-bottom: 1px solid rgba(255,255,255,.05); } +.lb-table tr:hover td { background: rgba(255,255,255,.04); } + +/* ── Util ─────────────────────────────────────── */ +.hidden { display: none !important; } + +/* ── Bar-at-bottom layout ─────────────────────── */ +#screen-game.bar-bottom #info-bar { + order: 10; + border-bottom: none; + border-top: 1px solid rgba(0,0,0,.3); +} +#screen-game.bar-bottom .game-menu-dropdown { + top: auto; + bottom: calc(100% + 6px); +} + +/* ── Mobile bidding: transparent panel at top so hand stays visible ── */ +@media (pointer: coarse) { + #overlay-bid { + align-items: flex-start; + background: transparent; + pointer-events: none; + padding: 0; + } + #overlay-bid .overlay-box { + pointer-events: all; + width: 100%; + 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; + } +} + +/* ── Mobile tweaks ────────────────────────────── */ +@media (max-width: 480px) { + :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 new file mode 100644 index 0000000..4eb65f6 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,57 @@ +'use strict'; +const CACHE_NAME = 'shelem-v1'; + +const PRECACHE = [ + '/', + '/index.html', + '/style.css', + '/app.js', + '/manifest.json', + '/icons/icon-192.png', + '/icons/icon-512.png', + '/icons/icon.svg', +]; + +self.addEventListener('install', event => { + event.waitUntil( + caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE)) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', event => { + event.waitUntil( + caches.keys().then(keys => + Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', event => { + const url = event.request.url; + if (event.request.method !== 'GET') return; + if (url.includes('/socket.io/')) return; + + if (url.includes('/cards/')) { + event.respondWith( + caches.match(event.request).then(cached => { + if (cached) return cached; + return fetch(event.request).then(res => { + caches.open(CACHE_NAME).then(c => c.put(event.request, res.clone())); + return res; + }); + }) + ); + return; + } + + event.respondWith( + fetch(event.request) + .then(res => { + caches.open(CACHE_NAME).then(c => c.put(event.request, res.clone())); + return res; + }) + .catch(() => caches.match(event.request)) + ); +}); diff --git a/server.js b/server.js new file mode 100644 index 0000000..1367ea3 --- /dev/null +++ b/server.js @@ -0,0 +1,980 @@ +'use strict'; + +const express = require('express'); +const http = require('http'); +const https = require('https'); +const { Server } = require('socket.io'); +const path = require('path'); +const fs = require('fs'); +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); + +const app = express(); +const httpServer = http.createServer(app); + +let httpsServer = null; +const SSL_KEY = path.join(__dirname, 'ssl', 'key.pem'); +const SSL_CERT = path.join(__dirname, 'ssl', 'cert.pem'); +if (fs.existsSync(SSL_KEY) && fs.existsSync(SSL_CERT)) { + httpsServer = https.createServer( + { key: fs.readFileSync(SSL_KEY), cert: fs.readFileSync(SSL_CERT) }, app + ); +} + +const io = new Server(httpServer); +if (httpsServer) io.attach(httpsServer); + +const JWT_SECRET = process.env.JWT_SECRET || 'shelem-secret-change-me'; +const ADMIN_USERNAME = process.env.ADMIN_USERNAME || ''; +const SHARED_USERS_FILE = process.env.SHARED_USERS_FILE || ''; +const HTTP_PORT = parseInt(process.env.PORT || '4000'); +const HTTPS_PORT = parseInt(process.env.HTTPS_PORT || '4443'); + +// ─── JSON file database ──────────────────────────────────────── +const DATA_DIR = path.join(__dirname, 'data'); +if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); + +const USERS_FILE = path.join(DATA_DIR, 'users.json'); +const STATS_FILE = path.join(DATA_DIR, 'stats.json'); +const CONFIG_FILE = path.join(DATA_DIR, 'config.json'); + +function readJSON(file, def) { + try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return def; } +} +function writeJSON(file, data) { + const tmp = file + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(data, null, 2)); + fs.renameSync(tmp, file); +} + +let users = readJSON(USERS_FILE, []); +let stats = readJSON(STATS_FILE, []); +let config = readJSON(CONFIG_FILE, { signupsOpen: true }); + +function saveUsers() { writeJSON(USERS_FILE, users); } +function saveStats() { writeJSON(STATS_FILE, stats); } +function saveConfig() { writeJSON(CONFIG_FILE, config); } + +function isAdmin(user) { + return ADMIN_USERNAME && user && user.username === ADMIN_USERNAME; +} + +let _nextId = users.length > 0 ? Math.max(...users.map(u => u.id)) + 1 : 1; +function nextId() { return _nextId++; } + +function findUser(username) { + const local = users.find(u => u.username.toLowerCase() === username.toLowerCase()); + if (local) return local; + if (SHARED_USERS_FILE) { + try { + const shared = readJSON(SHARED_USERS_FILE, []); + const s = shared.find(u => u.username.toLowerCase() === username.toLowerCase()); + if (s) return { ...s, _fromShared: true }; + } catch { /* ignore */ } + } + return null; +} + +function getStats(userId) { + let s = stats.find(s => s.userId === userId); + if (!s) { + s = { userId, games_played: 0, games_won: 0, shelemCount: 0, total_score: 0 }; + stats.push(s); + saveStats(); + } + return s; +} + +function addStats(userId, delta) { + const s = getStats(userId); + s.games_played = (s.games_played || 0) + (delta.games_played || 0); + s.games_won = (s.games_won || 0) + (delta.games_won || 0); + s.shelemCount = (s.shelemCount || 0) + (delta.shelemCount || 0); + s.total_score = (s.total_score || 0) + (delta.total_score || 0); + saveStats(); +} + +function requireAuth(req, res, next) { + const h = req.headers.authorization; + if (!h?.startsWith('Bearer ')) return res.status(401).json({ error: 'Not authenticated' }); + try { req.user = jwt.verify(h.slice(7), JWT_SECRET); next(); } + catch { res.status(401).json({ error: 'Invalid or expired token' }); } +} + +// ─── Middleware ──────────────────────────────────────────────── +app.use(express.json()); +app.use(express.static(path.join(__dirname, 'public'), { + setHeaders: (res, filePath) => { + if (/\.svg$/.test(filePath)) { + res.setHeader('Cache-Control', 'public, max-age=2592000, immutable'); + } else if (/\.(js|css|html)$/.test(filePath)) { + res.setHeader('Cache-Control', 'no-cache'); + } + } +})); + +// ─── Auth API ───────────────────────────────────────────────── +app.get('/api/config', (_req, res) => { + res.json({ signupsOpen: config.signupsOpen }); +}); + +app.post('/api/register', (req, res) => { + if (!config.signupsOpen) + return res.status(403).json({ error: 'New registrations are currently closed.' }); + const { username, password } = req.body || {}; + if (!username || !password) + return res.status(400).json({ error: 'Username and password are required' }); + if (username.trim().length < 2 || username.trim().length > 16) + return res.status(400).json({ error: 'Username must be 2–16 characters' }); + if (password.length < 4) + return res.status(400).json({ error: 'Password must be at least 4 characters' }); + if (findUser(username)) + return res.status(409).json({ error: 'Username already taken' }); + const id = nextId(); + users.push({ id, username: username.trim(), password: bcrypt.hashSync(password, 10) }); + saveUsers(); + getStats(id); + const token = jwt.sign({ id, username: username.trim() }, JWT_SECRET, { expiresIn: '30d' }); + res.json({ token, username: username.trim() }); +}); + +app.post('/api/login', (req, res) => { + const { username, password } = req.body || {}; + if (!username || !password) + return res.status(400).json({ error: 'Username and password required' }); + const user = findUser(username); + if (!user || !bcrypt.compareSync(password, user.password)) + return res.status(401).json({ error: 'Invalid username or password' }); + const effectiveId = user._fromShared ? `hokm_${user.id}` : user.id; + const effectiveUsername = user.username; + const token = jwt.sign({ id: effectiveId, username: effectiveUsername }, JWT_SECRET, { expiresIn: '30d' }); + res.json({ token, username: effectiveUsername }); +}); + +app.post('/api/change-password', requireAuth, (req, res) => { + const { currentPassword, newPassword } = req.body || {}; + if (!currentPassword || !newPassword) + return res.status(400).json({ error: 'Both passwords required' }); + if (newPassword.length < 4) + return res.status(400).json({ error: 'New password must be at least 4 characters' }); + const user = users.find(u => u.id === req.user.id); + if (!user) return res.status(404).json({ error: 'User not found (shared accounts cannot change password here)' }); + if (!bcrypt.compareSync(currentPassword, user.password)) + return res.status(401).json({ error: 'Current password is incorrect' }); + user.password = bcrypt.hashSync(newPassword, 10); + saveUsers(); + res.json({ ok: true }); +}); + +app.get('/api/profile/:username', (req, res) => { + const user = findUser(req.params.username); + if (!user) return res.status(404).json({ error: 'User not found' }); + const effectiveId = user._fromShared ? `hokm_${user.id}` : user.id; + const s = getStats(effectiveId); + res.json({ + username: user.username, + games_played: s.games_played || 0, + games_won: s.games_won || 0, + shelemCount: s.shelemCount || 0, + total_score: s.total_score || 0, + isAdmin: isAdmin(user), + signupsOpen: config.signupsOpen, + }); +}); + +app.get('/api/leaderboard', (_req, res) => { + const allUsers = [...users]; + if (SHARED_USERS_FILE) { + try { + const shared = readJSON(SHARED_USERS_FILE, []); + for (const su of shared) { + if (!allUsers.find(u => u.username.toLowerCase() === su.username.toLowerCase())) + allUsers.push({ ...su, _fromShared: true }); + } + } catch { /* ignore */ } + } + const rows = allUsers.map(u => { + const eid = u._fromShared ? `hokm_${u.id}` : u.id; + const s = getStats(eid); + const played = s.games_played || 0; + return { + username: u.username, + games_played: played, + games_won: s.games_won || 0, + shelemCount: s.shelemCount || 0, + total_score: s.total_score || 0, + score_per_game: played > 0 ? +((s.total_score || 0) / played).toFixed(1) : null, + }; + }) + .filter(r => r.games_played > 0) + .sort((a, b) => { + if (a.score_per_game === null) return 1; + if (b.score_per_game === null) return -1; + return b.score_per_game - a.score_per_game || b.games_played - a.games_played; + }) + .slice(0, 30); + res.json(rows); +}); + +app.post('/api/admin/toggle-signups', requireAuth, (req, res) => { + if (!isAdmin(req.user)) return res.status(403).json({ error: 'Forbidden' }); + config.signupsOpen = !config.signupsOpen; + saveConfig(); + res.json({ signupsOpen: config.signupsOpen }); +}); + +// ═══════════════════════════════════════════════════════════════ +// SHELEM GAME ENGINE +// ═══════════════════════════════════════════════════════════════ + +const SUITS = ['C', 'D', 'H', 'S']; +const RANKS = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A']; +// rank index for non-joker cards: 2→0, A→12 +const RANK_IDX = Object.fromEntries(RANKS.map((r, i) => [r, i])); + +function makeDeck(jokerMode) { + const d = []; + for (const s of SUITS) for (const r of RANKS) d.push(`${s}-${r}`); + if (jokerMode) { d.push('JOKER-COLOR'); d.push('JOKER-BLACK'); } + return d; +} + +function shuffle(arr) { + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} + +function suitOf(card) { + if (card === 'JOKER-COLOR' || card === 'JOKER-BLACK') return 'JOKER'; + return card.split('-')[0]; +} +function rankOf(card) { return card.split('-')[1]; } + +function cardPoints(card) { + if (card === 'JOKER-COLOR') return 20; + if (card === 'JOKER-BLACK') return 15; + const r = rankOf(card); + if (r === 'A') return 10; + if (r === '10') return 10; + if (r === '5') return 5; + return 0; +} + +function isJoker(card) { return card === 'JOKER-COLOR' || card === 'JOKER-BLACK'; } + +function isTrump(card, trump) { + if (!trump) return false; + return isJoker(card) || suitOf(card) === trump; +} + +// Higher = stronger trump. JOKER-COLOR=100, JOKER-BLACK=99, A=12..2=0 +function trumpRank(card) { + if (card === 'JOKER-COLOR') return 100; + if (card === 'JOKER-BLACK') return 99; + return RANK_IDX[rankOf(card)]; +} + +// Suit display order: C D H S JOKER +const SUIT_ORD = { C: 0, D: 1, H: 2, S: 3, JOKER: 4 }; +function sortCards(hand) { + return [...hand].sort((a, b) => { + const sa = SUIT_ORD[suitOf(a)], sb = SUIT_ORD[suitOf(b)]; + if (sa !== sb) return sa - sb; + return RANK_IDX[rankOf(a)] - RANK_IDX[rankOf(b)]; + }); +} + +function teamOf(seat) { return seat % 2; } // 0,2 → team 0 | 1,3 → team 1 + +function widowSize(room) { return room.jokerMode ? 6 : 4; } +function discardCount(room){ return widowSize(room); } + +function trickWinner(trick, trump) { + const trumpPlayed = trick.filter(t => isTrump(t.card, trump)); + if (trumpPlayed.length > 0) { + return trumpPlayed.reduce((best, t) => + trumpRank(t.card) > trumpRank(best.card) ? t : best).player; + } + const ls = suitOf(trick[0].card); + const led = trick.filter(t => suitOf(t.card) === ls); + return led.reduce((best, t) => + RANK_IDX[rankOf(t.card)] > RANK_IDX[rankOf(best.card)] ? t : best).player; +} + +function legalCards(room, player) { + const hand = room.hands[player]; + const trick = room.trick; + const trump = room.trump; + + if (trick.length === 0) { + // First card of the hand sets trump — jokers not allowed as the opening lead + if (trump === null) return hand.filter(c => !isJoker(c)); + return hand; + } + + const ledCard = trick[0].card; + const ledTrump = isTrump(ledCard, trump); + + if (ledTrump) { + // Trump led — must follow trump + const trumpCards = hand.filter(c => isTrump(c, trump)); + return trumpCards.length > 0 ? trumpCards : hand; + } + + // Non-trump led — must follow suit (jokers are NOT the led suit) + const ls = suitOf(ledCard); + const suitCards = hand.filter(c => suitOf(c) === ls); + return suitCards.length > 0 ? suitCards : hand; +} + +function makeToken() { + return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); +} + +function newRoom(id) { + return { + id, + state: 'WAITING', + names: ['', '', '', ''], + seats: [null, null, null, null], + tokens: [null, null, null, null], + userIds: [null, null, null, null], + bots: [false, false, false, false], + hands: [[], [], [], []], + widow: [], + handNumber: 0, + dealer: 0, + // Bidding + bids: [null, null, null, null], + currentBidder: -1, + highBid: 0, + highBidder: -1, + consecutivePasses: 0, + // Widow / trump + declarer: -1, + discarded: [], + trump: null, + // Trick-play + trick: [], + trickLead: -1, + currentTurn: -1, + tricksPlayed: 0, + trickWins: [0, 0, 0, 0], + teamCardPoints: [0, 0], + lastTrick: null, + lastTrickWinner: -1, + handDeltas: null, + isShelemHand: false, + gameShelemsCount: 0, + // Accumulated game scores (per team) + scores: [0, 0], + gameWinner: null, + // Options + jokerMode: true, + winScore: 505, + spectators: new Set(), + trickTimer: null, + }; +} + +function publicInfo(room, seat) { + return { + id: room.id, + state: room.state, + names: room.names, + bots: room.bots, + hands: room.hands.map((h, i) => i === seat ? h : h.length), + // Widow: declarer sees full cards during WIDOW phase, others see count + widow: (seat === room.declarer && room.state === 'WIDOW') + ? room.widow + : (room.state === 'WIDOW' ? room.widow.length : []), + widowSize: widowSize(room), + handNumber: room.handNumber, + dealer: room.dealer, + bids: room.bids, + currentBidder: room.currentBidder, + highBid: room.highBid, + highBidder: room.highBidder, + declarer: room.declarer, + trump: room.trump, + trick: room.trick, + trickLead: room.trickLead, + currentTurn: room.currentTurn, + tricksPlayed: room.tricksPlayed, + trickWins: room.trickWins, + teamCardPoints: room.teamCardPoints, + scores: room.scores, + handDeltas: room.handDeltas, + isShelemHand: room.isShelemHand, + lastTrick: room.lastTrick, + lastTrickWinner: room.lastTrickWinner, + gameWinner: room.gameWinner, + jokerMode: room.jokerMode, + winScore: room.winScore, + spectatorCount: room.spectators.size, + }; +} + +function broadcastState(room, event = 'roomInfo') { + for (let i = 0; i < 4; i++) { + const sid = room.seats[i]; + if (sid) io.to(sid).emit(event, publicInfo(room, i)); + } + for (const sid of room.spectators) { + io.to(sid).emit(event, publicInfo(room, -1)); + } +} + +// ─── Deal ───────────────────────────────────────────────────── +function dealHand(room) { + const deck = shuffle(makeDeck(room.jokerMode)); + const wSize = widowSize(room); + const hSize = (deck.length - wSize) / 4; // always 12 + + for (let i = 0; i < 4; i++) { + room.hands[i] = sortCards(deck.slice(i * hSize, (i + 1) * hSize)); + } + room.widow = deck.slice(4 * hSize); + + room.trick = []; room.lastTrick = null; room.lastTrickWinner = -1; + room.trump = null; room.tricksPlayed = 0; + room.trickWins = [0, 0, 0, 0]; room.teamCardPoints = [0, 0]; + room.handDeltas = null; room.isShelemHand = false; + room.bids = [null, null, null, null]; + room.consecutivePasses = 0; room.highBid = 0; room.highBidder = -1; + room.declarer = -1; room.discarded = []; +} + +// ─── Bidding ────────────────────────────────────────────────── +function startBidding(room) { + room.state = 'BIDDING'; + // Bidding starts at right of dealer (counter-clockwise first seat) + room.currentBidder = (room.dealer + 3) % 4; + broadcastState(room, 'roomInfo'); + scheduleBotBid(room); +} + +function onBid(room, player, amount) { + if (room.state !== 'BIDDING') return; + if (room.currentBidder !== player) return; + if (room.bids[player] === 'pass') return; + + if (typeof amount === 'number') { + const minBid = room.jokerMode ? 105 : 85; + if (amount < minBid || amount % 5 !== 0 || amount <= room.highBid) return; + room.bids[player] = amount; + room.highBid = amount; + room.highBidder = player; + room.consecutivePasses = 0; + + // If every other player has already passed, no one can outbid — end now + const canStillBid = room.bids.filter((b, i) => i !== player && b !== 'pass').length; + if (canStillBid === 0) { + room.declarer = room.highBidder; + startWidow(room); + return; + } + } else { + // pass + room.bids[player] = 'pass'; + room.consecutivePasses++; + + // All non-high-bidder players have now passed → no one can challenge + if (room.highBid > 0) { + const othersPassed = room.bids.every((b, i) => i === room.highBidder || b === 'pass'); + if (othersPassed) { + room.declarer = room.highBidder; + startWidow(room); + return; + } + } + } + + // All four passed → redeal with next dealer (counter-clockwise) + if (room.highBid === 0 && room.consecutivePasses >= 4) { + room.handNumber++; + room.dealer = (room.dealer + 1) % 4; + dealHand(room); + startBidding(room); + return; + } + + // Three consecutive passes after a bid → bidding ends + if (room.highBid > 0 && room.consecutivePasses >= 3) { + room.declarer = room.highBidder; + startWidow(room); + return; + } + + // Advance to next eligible bidder (anti-clockwise: right-first, skip passed) + let next = (player + 3) % 4; + let safety = 0; + while (room.bids[next] === 'pass' && safety++ < 4) next = (next + 3) % 4; + room.currentBidder = next; + broadcastState(room, 'roomInfo'); + scheduleBotBid(room); +} + +// ─── Widow ──────────────────────────────────────────────────── +function startWidow(room) { + room.state = 'WIDOW'; + // Give widow cards to declarer's hand (they see them all) + room.hands[room.declarer] = sortCards([...room.hands[room.declarer], ...room.widow]); + broadcastState(room, 'roomInfo'); + if (room.bots[room.declarer]) setTimeout(() => botDiscard(room, room.declarer), 900); +} + +function onDiscard(room, player, cards) { + if (room.state !== 'WIDOW') return; + if (room.declarer !== player) return; + const needed = discardCount(room); + if (!Array.isArray(cards) || cards.length !== needed) return; + const unique = [...new Set(cards)]; + if (unique.length !== needed) return; + const hand = room.hands[player]; + if (!unique.every(c => hand.includes(c))) return; + + // Remove from hand + for (const c of unique) { + const idx = room.hands[player].indexOf(c); + room.hands[player].splice(idx, 1); + } + room.discarded = unique; + + // Widow discard counts as first trick for declarer team: 5 pts + card points + const dTeam = teamOf(player); + room.teamCardPoints[dTeam] += 5; + for (const c of unique) room.teamCardPoints[dTeam] += cardPoints(c); + + startPlaying(room); +} + +// ─── Playing ────────────────────────────────────────────────── +function startPlaying(room) { + room.state = 'PLAYING'; + room.trickLead = room.declarer; + room.currentTurn = room.declarer; + room.trick = []; + broadcastState(room, 'roomInfo'); + scheduleBotPlay(room); +} + +function onCardPlayed(room, player, card) { + // First card played by the declarer sets trump (jokers excluded by legalCards) + if (room.trump === null) room.trump = suitOf(card); + + room.hands[player] = room.hands[player].filter(c => c !== card); + room.trick.push({ card, player }); + + if (room.trick.length < 4) { + room.currentTurn = (player + 3) % 4; // anti-clockwise: next player is to the right + broadcastState(room, 'cardPlayed'); + scheduleBotPlay(room); + return; + } + + // Trick complete + const winner = trickWinner(room.trick, room.trump); + const winnerTeam = teamOf(winner); + const trickPts = room.trick.reduce((s, t) => s + cardPoints(t.card), 0); + room.teamCardPoints[winnerTeam] += trickPts + 5; // 5 per trick won + room.trickWins[winner]++; + room.tricksPlayed++; + room.lastTrick = room.trick.slice(); + room.lastTrickWinner = winner; + + if (room.tricksPlayed === 12) { + broadcastState(room, 'trickWon'); + if (room.trickTimer) clearTimeout(room.trickTimer); + room.trickTimer = setTimeout(() => finishHand(room), 1400); + } else { + broadcastState(room, 'trickWon'); + if (room.trickTimer) clearTimeout(room.trickTimer); + room.trickTimer = setTimeout(() => { + room.trickLead = winner; + room.currentTurn = winner; + room.trick = []; + broadcastState(room, 'roomInfo'); + scheduleBotPlay(room); + }, 1400); + } +} + +// ─── Hand scoring ───────────────────────────────────────────── +function finishHand(room) { + const dTeam = teamOf(room.declarer); + const oTeam = 1 - dTeam; + + const dPts = room.teamCardPoints[dTeam]; + const oPts = room.teamCardPoints[oTeam]; + + // Declarer team won all 12 tricks + the widow trick = 13 total tricks + const dTricks = room.trickWins.reduce((s, w, i) => teamOf(i) === dTeam ? s + w : s, 0) + 1; + + let dDelta = 0, oDelta = 0; + + if (dTricks === 13) { + // Shelem — win every trick + dDelta = 250; + oDelta = 0; + room.isShelemHand = true; + room.gameShelemsCount++; + } else if (dPts >= room.highBid) { + // Made the bid — score exactly what was bid, not actual points earned + dDelta = room.highBid; + oDelta = oPts; + } else { + // Failed the bid + dDelta = dPts >= oPts ? -room.highBid : -2 * room.highBid; + oDelta = oPts; + } + + room.handDeltas = [0, 0]; + room.handDeltas[dTeam] = dDelta; + room.handDeltas[oTeam] = oDelta; + room.scores[0] += room.handDeltas[0]; + room.scores[1] += room.handDeltas[1]; + + if (room.scores.some(s => s >= room.winScore)) { + finishGame(room); + return; + } + + room.state = 'HAND_OVER'; + broadcastState(room, 'handOver'); + + if (room.trickTimer) clearTimeout(room.trickTimer); + room.trickTimer = setTimeout(() => { + room.handNumber++; + room.dealer = (room.dealer + 1) % 4; // counter-clockwise rotation + dealHand(room); + startBidding(room); + }, 4500); +} + +function finishGame(room) { + const maxScore = Math.max(...room.scores); + room.gameWinner = room.scores + .map((s, i) => ({ s, i })) + .filter(x => x.s === maxScore) + .map(x => x.i); // team indices + room.state = 'GAME_OVER'; + broadcastState(room, 'gameOver'); + + if (room.bots.some(Boolean)) return; + + const winnerTeams = new Set(room.gameWinner); + for (let seat = 0; seat < 4; seat++) { + const uid = room.userIds[seat]; + if (!uid) continue; + const team = teamOf(seat); + addStats(uid, { + games_played: 1, + games_won: winnerTeams.has(team) ? 1 : 0, + shelemCount: room.gameShelemsCount, + total_score: room.scores[team], + }); + } +} + +// ─── Game init ──────────────────────────────────────────────── +function tryStartGame(room) { + const filled = room.seats.map((s, i) => !!s || room.bots[i]); + if (!filled.every(Boolean)) return; + dealHand(room); + startBidding(room); +} + +// ─── Bot: bidding ───────────────────────────────────────────── +function scheduleBotBid(room) { + if (room.state !== 'BIDDING') return; + const bot = room.currentBidder; + if (!room.bots[bot]) return; + setTimeout(() => { + if (room.state !== 'BIDDING' || room.currentBidder !== bot) return; + botBid(room, bot); + }, 600 + Math.random() * 500); +} + +function botBid(room, bot) { + const hand = room.hands[bot]; + let estimate = 0; + + for (const card of hand) estimate += cardPoints(card); + + // Estimate trick wins from strong cards + let tricks = 0; + for (const card of hand) { + if (card === 'JOKER-COLOR') tricks += 1; + else if (card === 'JOKER-BLACK') tricks += 0.95; + else if (rankOf(card) === 'A') tricks += 0.85; + else if (rankOf(card) === 'K') tricks += 0.4; + } + // Long-suit bonus (potential trump) + const sc = { C: 0, D: 0, H: 0, S: 0 }; + for (const card of hand) { const s = suitOf(card); if (sc[s] !== undefined) sc[s]++; } + const longest = Math.max(...Object.values(sc)); + if (longest >= 5) tricks += 1.5; + if (longest >= 6) tricks += 1; + + estimate += Math.round(tricks) * 5; + let bidAmount = Math.round(estimate / 5) * 5; + if (Math.random() > 0.6) bidAmount += 5; // slight aggression + bidAmount = Math.round(bidAmount / 5) * 5; + + const minBid = room.jokerMode ? 105 : 85; + if (bidAmount > room.highBid && bidAmount >= minBid) { + onBid(room, bot, bidAmount); + } else { + onBid(room, bot, 'pass'); + } +} + +// ─── Bot: discard ───────────────────────────────────────────── +function botDiscard(room, bot) { + const hand = [...room.hands[bot]]; + + // Score each card — lower = prefer to discard + const keepScore = (card) => { + if (card === 'JOKER-COLOR') return 200; + if (card === 'JOKER-BLACK') return 190; + const cp = cardPoints(card); + if (cp >= 10) return 100 + cp; // Aces and Tens: keep + if (cp === 5) return 60; // Fives: keep but lower priority + // Low cards: prefer to discard + return RANK_IDX[rankOf(card)]; + }; + + const sorted = [...hand].sort((a, b) => keepScore(a) - keepScore(b)); + const toDiscard = sorted.slice(0, discardCount(room)); + onDiscard(room, bot, toDiscard); +} + +// ─── Bot: play ──────────────────────────────────────────────── +function scheduleBotPlay(room) { + if (room.state !== 'PLAYING') return; + const bot = room.currentTurn; + if (!room.bots[bot]) return; + const delay = 500 + Math.random() * 600; + setTimeout(() => { + if (room.state !== 'PLAYING' || room.currentTurn !== bot) return; + const card = botChooseCard(room, bot); + if (card) onCardPlayed(room, bot, card); + }, delay); +} + +function botChooseCard(room, bot) { + const legal = legalCards(room, bot); + if (legal.length === 1) return legal[0]; + const trick = room.trick; + const trump = room.trump; + const botTeam = teamOf(bot); + + // Leading + if (trick.length === 0) { + const trumpCards = legal.filter(c => isTrump(c, trump)); + if (trumpCards.length >= 3) { + // Lead highest trump to draw out opponents' + return trumpCards.sort((a, b) => trumpRank(b) - trumpRank(a))[0]; + } + // Lead high card from longest suit + const nonJoker = legal.filter(c => !isJoker(c)); + if (nonJoker.length > 0) { + return nonJoker.sort((a, b) => RANK_IDX[rankOf(b)] - RANK_IDX[rankOf(a)])[0]; + } + return legal[0]; + } + + // Following + const curWinner = trickWinner(trick, trump); + const curWinnerTeam = teamOf(curWinner); + const trickPts = trick.reduce((s, t) => s + cardPoints(t.card), 0); + const partnerLeading = curWinnerTeam === botTeam; + + if (partnerLeading) { + // Let partner win — play lowest card + return legal.sort((a, b) => { + const av = cardPoints(a) * 20 + trumpRank(a); + const bv = cardPoints(b) * 20 + trumpRank(b); + return av - bv; + })[0]; + } + + // Opponent leading — try to win + const winning = legal.filter(c => { + const hyp = [...trick, { card: c, player: bot }]; + return trickWinner(hyp, trump) === bot; + }); + + if (winning.length > 0) { + // Win with cheapest winning card (preserve high trumps) + return winning.sort((a, b) => { + // Prefer non-joker wins to save jokers + const ai = isJoker(a) ? 2 : 1, bi = isJoker(b) ? 2 : 1; + if (ai !== bi) return ai - bi; + return trumpRank(a) - trumpRank(b); + })[0]; + } + + // Can't win — discard lowest value card + return legal.sort((a, b) => { + const av = cardPoints(a) * 20 + RANK_IDX[rankOf(a)]; + const bv = cardPoints(b) * 20 + RANK_IDX[rankOf(b)]; + return av - bv; + })[0]; +} + +// ═══════════════════════════════════════════════════════════════ +// SOCKET.IO +// ═══════════════════════════════════════════════════════════════ + +const rooms = new Map(); +const userSockets = new Map(); + +io.use((socket, next) => { + const token = socket.handshake.auth?.token; + if (token) { + try { socket.data.user = jwt.verify(token, JWT_SECRET); } catch { /* guest */ } + } + next(); +}); + +io.on('connection', (socket) => { + const user = socket.data.user; + if (user) userSockets.set(user.id, socket.id); + + // ── Create room ──────────────────────────────────────────── + socket.on('create', ({ name, jokerMode, winScore } = {}) => { + 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 + const ws = [205, 505, 1005]; + room.winScore = ws.includes(+winScore) ? +winScore : 505; + room.names[0] = name.trim().slice(0, 16); + room.userIds[0] = user?.id || null; + room.seats[0] = socket.id; + room.tokens[0] = makeToken(); + rooms.set(id, room); + socket.join(id); + socket.emit('created', { roomId: id, seat: 0, token: room.tokens[0] }); + socket.emit('roomInfo', publicInfo(room, 0)); + }); + + // ── Join room ────────────────────────────────────────────── + socket.on('join', ({ name, roomId } = {}) => { + if (!name?.trim()) return socket.emit('joinError', 'Name is required'); + const room = rooms.get((roomId || '').toUpperCase()); + if (!room) return socket.emit('joinError', 'Room not found'); + if (room.state !== 'WAITING') return socket.emit('joinError', 'Game already in progress'); + + let openSeat = -1; + for (let i = 0; i < 4; i++) { + if (!room.seats[i] && !room.bots[i]) { openSeat = i; break; } + } + if (openSeat === -1) return socket.emit('joinError', 'Room is full'); + + room.names[openSeat] = name.trim().slice(0, 16); + room.userIds[openSeat] = user?.id || null; + room.seats[openSeat] = socket.id; + room.tokens[openSeat] = makeToken(); + socket.join(room.id); + socket.emit('joined', { roomId: room.id, seat: openSeat, token: room.tokens[openSeat] }); + broadcastState(room, 'roomInfo'); + + if (room.seats.filter(Boolean).length + room.bots.filter(Boolean).length === 4) tryStartGame(room); + }); + + // ── Spectate ─────────────────────────────────────────────── + socket.on('spectate', ({ roomId } = {}) => { + const room = rooms.get((roomId || '').toUpperCase()); + if (!room) return socket.emit('spectateError', 'Room not found'); + room.spectators.add(socket.id); + socket.join(room.id); + socket.emit('spectating', { roomId: room.id }); + socket.emit('roomInfo', publicInfo(room, -1)); + }); + + // ── 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'); + room.seats[seat] = socket.id; + socket.join(room.id); + socket.emit('rejoined', { roomId: room.id, seat, token }); + socket.emit('roomInfo', publicInfo(room, seat)); + broadcastState(room, 'roomInfo'); + }); + + // ── Fill with bots ───────────────────────────────────────── + socket.on('fillBots', ({ roomId } = {}) => { + const room = rooms.get((roomId || '').toUpperCase()); + if (!room || room.state !== 'WAITING') return; + if (!room.seats.includes(socket.id)) 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]; + } + } + broadcastState(room, 'roomInfo'); + tryStartGame(room); + }); + + // ── Bid ──────────────────────────────────────────────────── + socket.on('bid', ({ roomId, seat, token, amount } = {}) => { + const room = rooms.get((roomId || '').toUpperCase()); + if (!room) return; + if (room.tokens[seat] !== token) return; + onBid(room, seat, amount === 'pass' ? 'pass' : +amount); + }); + + // ── Discard widow cards ──────────────────────────────────── + socket.on('discard', ({ roomId, seat, token, cards } = {}) => { + const room = rooms.get((roomId || '').toUpperCase()); + if (!room) return; + if (room.tokens[seat] !== token) return; + onDiscard(room, seat, cards); + }); + + // ── Play card ────────────────────────────────────────────── + socket.on('play', ({ roomId, seat, token, card } = {}) => { + const room = rooms.get((roomId || '').toUpperCase()); + if (!room) return; + if (room.state !== 'PLAYING') return; + if (room.tokens[seat] !== token) return; + if (room.currentTurn !== seat) return socket.emit('playError', 'Not your turn'); + if (!legalCards(room, seat).includes(card)) return socket.emit('playError', 'Illegal card'); + onCardPlayed(room, seat, card); + }); + + // ── Leave ────────────────────────────────────────────────── + socket.on('leave', ({ roomId, seat, token } = {}) => { + const room = rooms.get((roomId || '').toUpperCase()); + if (!room) return; + if (room.tokens[seat] === token) { + room.seats[seat] = null; + room.tokens[seat] = null; + } + socket.leave(room.id); + }); + + socket.on('disconnect', () => { + if (user) userSockets.delete(user.id); + }); +}); + +// ─── Start ──────────────────────────────────────────────────── +httpServer.listen(HTTP_PORT, () => + console.log(`Shelem HTTP → http://localhost:${HTTP_PORT}`) +); +if (httpsServer) { + httpsServer.listen(HTTPS_PORT, () => + console.log(`Shelem HTTPS → https://localhost:${HTTPS_PORT}`) + ); +}