From e0b9dde93e0718998b17ac1e6ec34e26ac615f10 Mon Sep 17 00:00:00 2001 From: goyban Date: Sun, 24 May 2026 15:59:24 +0000 Subject: [PATCH] Fully containerized --- .gitignore | 1 + compose.yml => compose.build.yml | 0 docker-compose.yml | 15 ++++ public/app.js | 106 ++++++++++++++++++++++----- public/index.html | 12 ++++ public/style.css | 63 +++++++++++++++- push.sh | 28 ++++++++ server.js | 119 +++++++++++++++++++++++++++++++ 8 files changed, 326 insertions(+), 18 deletions(-) rename compose.yml => compose.build.yml (100%) create mode 100644 docker-compose.yml create mode 100755 push.sh diff --git a/.gitignore b/.gitignore index 8b13a87..7f3f178 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ data/ node_modules/ npm-debug.log* +TODO \ No newline at end of file diff --git a/compose.yml b/compose.build.yml similarity index 100% rename from compose.yml rename to compose.build.yml diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..94d57f6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + shelem: + container_name: shelem + image: git.goyban.com/goyban/shelem:latest + pull_policy: missing + ports: + - "4001:4000" + - "4444:4443" + restart: unless-stopped + volumes: + - ./data:/app/data + - /root/hokm/data:/hokm-data:ro + - /root/hearts/public/cards:/app/public/cards:ro + env_file: + - .env diff --git a/public/app.js b/public/app.js index 64b18a5..32d66d4 100644 --- a/public/app.js +++ b/public/app.js @@ -24,9 +24,16 @@ let widowSelected = []; let currentBidAmount = 85; let lastBidHandNumber = -1; -// Turn reminder state -let turnReminderTimer = null; -let turnReminderActive = false; +// Turn reminder / escalation state +let turnReminderTimer = null; +let turnReminderActive = false; +let turnEscalationLevel = 0; + +// AFK state +let afkBannerVoted = false; + +// AI control state (which seats are AI-controlled) +let aiControlledSeats = new Set(); // Unlock Web Audio on first user gesture (required by iOS) let _audioCtx = null; @@ -57,23 +64,36 @@ function playTurnChime() { } catch (e) { /* audio not available */ } } +function applyTurnEscalation(level) { + const pm = $('phase-msg'); + if (!pm) return; + pm.classList.remove('your-turn-lvl1', 'your-turn-lvl2', 'your-turn-lvl3'); + if (level > 0) pm.classList.add(`your-turn-lvl${level}`); +} + function handleTurnReminder(isMyTurn) { if (isMyTurn && !turnReminderActive) { - turnReminderActive = true; + turnReminderActive = true; + turnEscalationLevel = 0; $('my-area')?.classList.remove('turn-urgent'); - turnReminderTimer = setTimeout(() => { - $('my-area')?.classList.add('turn-urgent'); - // Vibrate on Android; chime on iOS (vibrate not supported there) - if (navigator.vibrate) { - navigator.vibrate([300, 100, 300]); - } else { - playTurnChime(); + applyTurnEscalation(0); + // Escalate every 5 seconds: level 1 → 2 → 3 (vibrate/chime at level 3) + turnReminderTimer = setInterval(() => { + turnEscalationLevel = Math.min(turnEscalationLevel + 1, 3); + applyTurnEscalation(turnEscalationLevel); + if (turnEscalationLevel >= 3) { + $('my-area')?.classList.add('turn-urgent'); + if (navigator.vibrate) navigator.vibrate([300, 100, 300]); + else playTurnChime(); + clearInterval(turnReminderTimer); + turnReminderTimer = null; } }, 5000); - } else if (!isMyTurn && (turnReminderActive || turnReminderTimer)) { - clearTimeout(turnReminderTimer); - turnReminderTimer = null; - turnReminderActive = false; + } else if (!isMyTurn && turnReminderActive) { + if (turnReminderTimer) { clearInterval(turnReminderTimer); turnReminderTimer = null; } + turnReminderActive = false; + turnEscalationLevel = 0; + applyTurnEscalation(0); $('my-area')?.classList.remove('turn-urgent'); } } @@ -90,7 +110,8 @@ function loadPlayMode() { 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'; + if (['scroll','fan','playables'].includes(s)) return s; + return isTouchDevice() ? 'fan' : 'scroll'; } function saveHandMode(m) { localStorage.setItem('shelem_hand_mode', m); } function loadBarBottom() { return localStorage.getItem('shelem_bar_bottom') === '1'; } @@ -512,6 +533,23 @@ function initSocketHandlers() { socket.on('handOver', onHandOver); socket.on('gameOver', onGameOver); + socket.on('afkWarning', ({ name } = {}) => { + afkBannerVoted = false; + $('afk-banner-msg').textContent = `${name} hasn't played for 1 minute.`; + const btn = $('afk-vote-btn'); + if (btn) { btn.disabled = false; btn.textContent = 'Let AI Play'; } + show('afk-banner'); + }); + socket.on('afkResolved', () => { + afkBannerVoted = false; + hide('afk-banner'); + }); + socket.on('aiControl', ({ seat, active, name } = {}) => { + if (active) aiControlledSeats.add(seat); + else aiControlledSeats.delete(seat); + updateAiControlBanner(); + }); + socket.on('disconnect', () => {}); } @@ -641,7 +679,7 @@ function renderInfoBar(st) { const td = $('trump-display'); const bd = $('bid-display'); if (st.trump) { - td.textContent = `Trump: ${suitSymbol(st.trump)} ${suitName(st.trump)}`; + td.textContent = `Hokm:\n${suitSymbol(st.trump)}`; show('trump-display'); } else { hide('trump-display'); @@ -1255,6 +1293,30 @@ async function showLeaderboard() { } catch { /* ignore */ } } +function updateAiControlBanner() { + if (aiControlledSeats.size === 0) { + hide('ai-control-banner'); + return; + } + const isMe = aiControlledSeats.has(mySeat); + let msg; + if (isMe) { + msg = '🤖 AI is playing for you! Play a card to take back control.'; + } else { + const names = [...aiControlledSeats].map(s => lastState?.names[s] || `Seat ${s + 1}`); + msg = `🤖 AI is playing for: ${names.join(', ')}`; + } + $('ai-control-msg').textContent = msg; + $('ai-control-banner').classList.toggle('is-me', isMe); + show('ai-control-banner'); +} + +function clearGameState() { + aiControlledSeats.clear(); + hide('ai-control-banner'); + hide('afk-banner'); +} + // ─── Event wiring ────────────────────────────────────────────── function wireGameEvents() { // Waiting room @@ -1337,6 +1399,7 @@ function wireGameEvents() { $('btn-exit-confirm-yes').addEventListener('click', () => { socket?.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken }); clearSession(); + clearGameState(); hide('overlay-exit-confirm'); hide('overlay-hand'); hide('overlay-gameover'); @@ -1432,6 +1495,15 @@ function wireGameEvents() { } catch { $('change-pass-msg').textContent = 'Network error'; } }); + $('afk-vote-btn')?.addEventListener('click', () => { + if (afkBannerVoted) return; + afkBannerVoted = true; + socket?.emit('voteAITakeover', { roomId: myRoomId, seat: mySeat, token: myToken }); + const btn = $('afk-vote-btn'); + if (btn) { btn.disabled = true; btn.textContent = 'Voted ✓'; } + }); + $('afk-dismiss-btn')?.addEventListener('click', () => hide('afk-banner')); + $('btn-toggle-signups').addEventListener('click', async () => { try { const r = await fetch('/api/admin/toggle-signups', { diff --git a/public/index.html b/public/index.html index 8ce33d5..3845e66 100644 --- a/public/index.html +++ b/public/index.html @@ -166,6 +166,18 @@ + + + + + +
diff --git a/public/style.css b/public/style.css index 5cb0f0b..8dadbcb 100644 --- a/public/style.css +++ b/public/style.css @@ -338,13 +338,17 @@ input:focus { border-color: var(--gold); } gap: 8px; flex: 1; justify-content: center; + min-width: 0; + flex-wrap: wrap; } .team-score { font-size: .78rem; font-weight: 700; padding: 2px 10px; border-radius: 20px; - white-space: nowrap; + word-break: break-word; + text-align: center; + min-width: 0; } .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); } @@ -357,6 +361,9 @@ input:focus { border-color: var(--gold); } background: rgba(245,197,24,.2); border: 1px solid rgba(245,197,24,.4); color: var(--gold); + white-space: pre; + text-align: center; + line-height: 1.2; } .bid-display { font-size: .75rem; @@ -610,6 +617,26 @@ input:focus { border-color: var(--gold); } from { opacity: .7; } to { opacity: 1; } } +/* Escalating urgency — applied every 5 s while it's your turn */ +.phase-msg.your-turn-lvl1 { + font-size: .96rem; + color: #ffc400; + animation: your-turn-flash .6s ease-in-out infinite alternate; +} +.phase-msg.your-turn-lvl2 { + font-size: 1.18rem; + color: #ffeb3b; + font-weight: 800; + text-shadow: 0 0 10px rgba(255, 220, 0, 0.6); + animation: your-turn-flash .4s ease-in-out infinite alternate; +} +.phase-msg.your-turn-lvl3 { + font-size: 1.4rem; + color: #fff; + font-weight: 800; + text-shadow: 0 0 22px rgba(255, 200, 0, 1), 0 0 6px rgba(255,255,255,.8); + animation: your-turn-flash .25s ease-in-out infinite alternate; +} /* Drop-zone pulse when dragging */ #trick-area { position: relative; } @@ -1026,6 +1053,40 @@ input:focus { border-color: var(--gold); } flex-shrink: 0; } +/* ── AFK banner ───────────────────────────────── */ +.afk-banner { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 12px; + background: rgba(200, 100, 0, 0.28); + border-bottom: 1px solid rgba(200, 120, 0, 0.45); + font-size: .82rem; + flex-shrink: 0; + flex-wrap: wrap; +} +.afk-banner.hidden { display: none !important; } + +/* ── AI control banner ────────────────────────── */ +.ai-control-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 12px; + background: rgba(80, 0, 180, 0.3); + border-bottom: 1px solid rgba(120, 60, 220, 0.5); + font-size: .8rem; + flex-shrink: 0; + color: #ce93d8; +} +.ai-control-banner.is-me { + background: rgba(180, 0, 100, 0.35); + border-bottom-color: rgba(220, 60, 140, 0.55); + color: #f48fb1; + font-weight: 700; +} +.ai-control-banner.hidden { display: none !important; } + /* ── Util ─────────────────────────────────────── */ .hidden { display: none !important; } diff --git a/push.sh b/push.sh new file mode 100755 index 0000000..61a206d --- /dev/null +++ b/push.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +REGISTRY="git.goyban.com" +USER="goyban" +IMAGE="shelem" + +VERSION="${1:-latest}" # pass version as argument, e.g. ./push.sh v1.0.1 + +BASE="${REGISTRY}/${USER}/${IMAGE}" + +echo "▶ Building ${BASE}:${VERSION} ..." +docker build -t "${BASE}:${VERSION}" . + +if [ "${VERSION}" != "latest" ]; then + echo "▶ Tagging as latest ..." + docker tag "${BASE}:${VERSION}" "${BASE}:latest" +fi + +echo "▶ Pushing ${BASE}:${VERSION} ..." +docker push "${BASE}:${VERSION}" + +if [ "${VERSION}" != "latest" ]; then + echo "▶ Pushing ${BASE}:latest ..." + docker push "${BASE}:latest" +fi + +echo "✓ Done: ${BASE}:${VERSION} + ${BASE}:latest" diff --git a/server.js b/server.js index 74e0d6e..ecf10b3 100644 --- a/server.js +++ b/server.js @@ -394,6 +394,13 @@ function newRoom(id) { winScore: 505, spectators: new Set(), trickTimer: null, + // AFK tracking + afkTimer: null, + afkSeat: -1, + afkVotes: new Set(), + // AI control (persistent per seat until player acts) + aiControlledSeats: new Set(), + createdAt: Date.now(), }; } @@ -468,6 +475,9 @@ function dealHand(room) { // ─── Bidding ────────────────────────────────────────────────── function startBidding(room) { + clearAfkTimer(room); + for (const seat of room.aiControlledSeats) broadcastAiControl(room, seat, false); + room.aiControlledSeats.clear(); room.state = 'BIDDING'; // Bidding starts at right of dealer (counter-clockwise first seat) room.currentBidder = (room.dealer + 3) % 4; @@ -577,10 +587,13 @@ function startPlaying(room) { room.currentTurn = room.declarer; room.trick = []; broadcastState(room, 'roomInfo'); + scheduleAfkTimer(room); scheduleBotPlay(room); } function onCardPlayed(room, player, card) { + clearAfkTimer(room); + // First card played by the declarer sets trump (jokers excluded by legalCards) if (room.trump === null) room.trump = suitOf(card); @@ -590,6 +603,7 @@ function onCardPlayed(room, player, card) { if (room.trick.length < 4) { room.currentTurn = (player + 3) % 4; // anti-clockwise: next player is to the right broadcastState(room, 'cardPlayed'); + scheduleAfkTimer(room); scheduleBotPlay(room); return; } @@ -616,6 +630,7 @@ function onCardPlayed(room, player, card) { room.currentTurn = winner; room.trick = []; broadcastState(room, 'roomInfo'); + scheduleAfkTimer(room); scheduleBotPlay(room); }, 1400); } @@ -623,6 +638,7 @@ function onCardPlayed(room, player, card) { // ─── Hand scoring ───────────────────────────────────────────── function finishHand(room) { + clearAfkTimer(room); const dTeam = teamOf(room.declarer); const oTeam = 1 - dTeam; @@ -698,6 +714,65 @@ function finishGame(room) { } } +// ─── AFK timer ──────────────────────────────────────────────── +function clearAfkTimer(room) { + if (room.afkTimer) { clearTimeout(room.afkTimer); room.afkTimer = null; } + if (room.afkSeat >= 0) { + for (let i = 0; i < 4; i++) { + if (room.seats[i]) io.to(room.seats[i]).emit('afkResolved', {}); + } + room.afkSeat = -1; + room.afkVotes = new Set(); + } +} + +function broadcastAiControl(room, seat, active) { + const data = { seat, active, name: room.names[seat] }; + for (let i = 0; i < 4; i++) { + if (room.seats[i]) io.to(room.seats[i]).emit('aiControl', data); + } + for (const sid of room.spectators) io.to(sid).emit('aiControl', data); +} + +function scheduleAfkTimer(room) { + clearAfkTimer(room); + if (room.state !== 'PLAYING') return; + const seat = room.currentTurn; + if (room.bots[seat] || !room.seats[seat]) return; + + // Seat is AI-controlled: play automatically after 10s + if (room.aiControlledSeats.has(seat)) { + room.afkTimer = setTimeout(() => { + if (room.state !== 'PLAYING' || room.currentTurn !== seat) return; + if (!room.aiControlledSeats.has(seat)) return; + const card = botChooseCard(room, seat); + if (card) onCardPlayed(room, seat, card); + }, 10000); + return; + } + + // Normal 60s AFK warning timer + room.afkTimer = setTimeout(() => { + if (room.state !== 'PLAYING' || room.currentTurn !== seat) return; + room.afkSeat = seat; + room.afkVotes = new Set(); + + const otherHumans = room.seats + .map((sid, i) => ({ sid, i })) + .filter(({ sid, i }) => sid && i !== seat); + + if (otherHumans.length === 0) { + room.afkSeat = -1; + const card = botChooseCard(room, seat); + if (card) onCardPlayed(room, seat, card); + return; + } + for (const { sid } of otherHumans) { + io.to(sid).emit('afkWarning', { seat, name: room.names[seat] }); + } + }, 60000); +} + // ─── Game init ──────────────────────────────────────────────── function tryStartGame(room) { const filled = room.seats.map((s, i) => !!s || room.bots[i]); @@ -1038,9 +1113,40 @@ io.on('connection', (socket) => { 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'); + // Player acted themselves — release AI control + if (room.aiControlledSeats.has(seat)) { + room.aiControlledSeats.delete(seat); + broadcastAiControl(room, seat, false); + } onCardPlayed(room, seat, card); }); + // ── Vote AI takeover (AFK) ───────────────────────────────── + socket.on('voteAITakeover', ({ roomId, seat, token } = {}) => { + const room = rooms.get((roomId || '').toUpperCase()); + if (!room || room.state !== 'PLAYING') return; + if (room.tokens[seat] !== token) return; + if (room.afkSeat < 0 || seat === room.afkSeat) return; + + room.afkVotes.add(seat); + + const otherHumanSeats = room.seats + .map((sid, i) => ({ sid, i })) + .filter(({ sid, i }) => sid && i !== room.afkSeat); + const needed = Math.ceil(otherHumanSeats.length / 2); + + if (room.afkVotes.size >= needed) { + const afkSeat = room.afkSeat; + clearAfkTimer(room); + room.aiControlledSeats.add(afkSeat); + broadcastAiControl(room, afkSeat, true); + if (room.state === 'PLAYING' && room.currentTurn === afkSeat) { + const card = botChooseCard(room, afkSeat); + if (card) onCardPlayed(room, afkSeat, card); + } + } + }); + // ── Leave ────────────────────────────────────────────────── socket.on('leave', ({ roomId, seat, token } = {}) => { const room = rooms.get((roomId || '').toUpperCase()); @@ -1058,6 +1164,19 @@ io.on('connection', (socket) => { }); }); +// ─── Stale public room cleanup ──────────────────────────────── +const SIX_HOURS_MS = 6 * 60 * 60 * 1000; +setInterval(() => { + const now = Date.now(); + for (const [id, room] of rooms) { + if (room.isPublic && room.state === 'WAITING' && now - room.createdAt > SIX_HOURS_MS) { + if (room.trickTimer) clearTimeout(room.trickTimer); + if (room.afkTimer) clearTimeout(room.afkTimer); + rooms.delete(id); + } + } +}, 30 * 60 * 1000); // check every 30 minutes + // ─── Start ──────────────────────────────────────────────────── httpServer.listen(HTTP_PORT, () => console.log(`Shelem HTTP → http://localhost:${HTTP_PORT}`)