diff --git a/.gitignore b/.gitignore index 3ac63ef..71ea255 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .env data/ node_modules/ +push.sh \ No newline at end of file diff --git a/docker-compose.yml b/compose.build.yml similarity index 93% rename from docker-compose.yml rename to compose.build.yml index dd8bab5..573a77e 100644 --- a/docker-compose.yml +++ b/compose.build.yml @@ -1,5 +1,6 @@ services: hearts: + container_name: hearts build: . ports: - "4000:4000" diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..a698640 --- /dev/null +++ b/compose.yml @@ -0,0 +1,16 @@ +services: + hearts: + container_name: hearts + image: git.goyban.com/goyban/hearts:latest + ports: + - "4000:4000" + - "4443:4443" + restart: unless-stopped + volumes: + - ./data:/app/data + # Hokm's data dir (read-only) so Hearts can verify Hokm accounts + - /root/hokm/data:/hokm-data:ro + # Share Hokm's card SVGs — same card assets, no duplication + - /root/hokm/public/cards:/app/public/cards:ro + env_file: + - .env diff --git a/public/app.js b/public/app.js index f79aafb..975aa6e 100644 --- a/public/app.js +++ b/public/app.js @@ -11,7 +11,17 @@ let authToken = localStorage.getItem('hearts_token') || null; let authUser = localStorage.getItem('hearts_user') || null; let lastState = null; let deferredInstallPrompt = null; -let selectedScore = 100; + +// ─── Turn escalation state ──────────────────────────────────── +let turnEscalationInterval = null; +let turnEscalationLevel = 0; +let wasMyTurn = false; + +// ─── AFK / AI-takeover state ────────────────────────────────── +let aiControlledSeats = new Set(); +let afkWarnSeat = -1; +let selectedScore = 100; +let selectedPublic = false; // ─── Play mode & hand display mode ─────────────────────────── function isTouchDevice() { @@ -160,6 +170,55 @@ const RANK_FILE = { 'J': '11-JACK', 'Q': '12-QUEEN', 'K': '13-KING', }; +// ─── Turn escalation ────────────────────────────────────────── +function handleTurnReminder(isMyTurn) { + clearInterval(turnEscalationInterval); + turnEscalationLevel = 0; + applyTurnEscalation(0); + if (!isMyTurn) return; + turnEscalationInterval = setInterval(() => { + turnEscalationLevel = Math.min(turnEscalationLevel + 1, 3); + applyTurnEscalation(turnEscalationLevel); + if (turnEscalationLevel >= 3) { + if (navigator.vibrate) navigator.vibrate([200, 100, 200]); + try { + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + const o = ctx.createOscillator(); + const g = ctx.createGain(); + o.connect(g); g.connect(ctx.destination); + o.frequency.value = 880; + g.gain.setValueAtTime(0.3, ctx.currentTime); + g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4); + o.start(); o.stop(ctx.currentTime + 0.4); + } catch(e) {} + } + }, 5000); +} + +function applyTurnEscalation(level) { + const el = $('phase-msg'); + if (!el) return; + el.classList.remove('your-turn-lvl1', 'your-turn-lvl2', 'your-turn-lvl3'); + if (level > 0) el.classList.add(`your-turn-lvl${level}`); +} + +// ─── AFK / AI-takeover UI ───────────────────────────────────── +function updateAiControlBanner() { + const banner = $('ai-control-banner'); + const msg = $('ai-control-msg'); + if (!banner || !msg) return; + if (aiControlledSeats.size === 0) { hide('ai-control-banner'); return; } + if (aiControlledSeats.has(mySeat)) { + msg.textContent = 'AI is playing for you — play a card to resume control'; + banner.className = 'ai-control-banner ai-self'; + } else { + const names = [...aiControlledSeats].map(s => lastState?.names[s] || `P${s+1}`).join(', '); + msg.textContent = `AI is playing for ${names}`; + banner.className = 'ai-control-banner ai-other'; + } + show('ai-control-banner'); +} + // ─── Helpers ────────────────────────────────────────────────── function $(id) { return document.getElementById(id); } function show(...ids) { ids.forEach(id => $(id)?.classList.remove('hidden')); } @@ -295,6 +354,14 @@ function initSocket() { socket.on('rejoinError', (msg) => { clearSession(); $('lobby-error').textContent = msg || 'Could not rejoin'; + showScreen('screen-lobby'); + }); + + socket.on('hasActiveGame', ({ roomId, seat, token }) => { + if (myRoomId) return; // already in a session + myRoomId = roomId; mySeat = seat; myToken = token; + saveSession(roomId, seat, token); + socket.emit('rejoin', { roomId, seat, token }); }); // Core game events — all handled by renderState @@ -308,11 +375,44 @@ function initSocket() { socket.on('joinError', msg => { $('lobby-error').textContent = msg; }); socket.on('passError', msg => console.warn('passError:', msg)); socket.on('playError', msg => console.warn('playError:', msg)); + + socket.on('afkWarning', ({ seat, name }) => { + afkWarnSeat = seat; + const isMe = seat === mySeat; + $('afk-banner-msg').textContent = isMe + ? "You haven't played yet!" + : `${name} hasn't played. Let AI take over?`; + if (isMe) hide('afk-vote-btn'); else show('afk-vote-btn'); + show('afk-banner'); + }); + + socket.on('afkResolved', () => { + afkWarnSeat = -1; + hide('afk-banner'); + }); + + socket.on('aiControl', ({ seat, active, name }) => { + if (active) aiControlledSeats.add(seat); else aiControlledSeats.delete(seat); + updateAiControlBanner(); + }); } // ─── State rendering ────────────────────────────────────────── +function syncAiState(state) { + aiControlledSeats = new Set(state.aiControlledSeats || []); + updateAiControlBanner(); +} + function handleState(state) { lastState = state; + syncAiState(state); + + // Restore locally-selected pass cards that the server doesn't know about yet + if (state.state === 'PASSING' && mySeat >= 0 && !state.passReady[mySeat]) { + state.passSelected = passSelectedLocal; + } else { + passSelectedLocal = []; + } // Always dismiss result overlays when fresh state arrives hide('overlay-hand', 'overlay-gameover'); @@ -335,6 +435,7 @@ function handleState(state) { function handleTrickWon(state) { lastState = state; + syncAiState(state); renderInfoBar(state); renderTable(state); // Flash the winning slot briefly @@ -346,6 +447,8 @@ function handleTrickWon(state) { function handleHandOver(state) { lastState = state; + syncAiState(state); + hide('afk-banner'); renderInfoBar(state); renderTable(state); hide('overlay-pass'); @@ -354,6 +457,8 @@ function handleHandOver(state) { function handleGameOver(state) { lastState = state; + syncAiState(state); + hide('afk-banner'); renderInfoBar(state); renderTable(state); hide('overlay-pass', 'overlay-hand'); @@ -440,6 +545,9 @@ function renderWaitingRoom(state) { } else { hide('btn-fill-bots'); } + + const badge = $('waiting-public-badge'); + if (badge) badge.textContent = state.isPublic ? '🌐 Public' : '🔒 Private'; } // ── Game table ──────────────────────────────────────────────── @@ -616,6 +724,12 @@ function renderPhaseMsg(state) { const el = $('phase-msg'); if (!el) return; + const isMyTurn = state.state === 'PLAYING' && state.currentTurn === mySeat && !spectating; + if (isMyTurn !== wasMyTurn) { + wasMyTurn = isMyTurn; + handleTurnReminder(isMyTurn); + } + if (state.state === 'WAITING') { el.textContent = 'Waiting…'; } else if (state.state === 'PASSING') { @@ -728,8 +842,9 @@ function togglePassSelect(code, state) { return; // already 3 selected } - // Optimistic update for immediate feedback + // Optimistic update for immediate feedback; persist so server pushes don't wipe it state.passSelected = selected; + passSelectedLocal = selected; renderPassOverlay(state); // Also update my-hand in game table renderMyHand(myHand, state); @@ -740,6 +855,7 @@ function confirmPass() { if (!state) return; const selected = state.passSelected || []; if (selected.length !== 3) return; + passSelectedLocal = []; socket.emit('passCards', { roomId: myRoomId, seat: mySeat, token: myToken, cards: selected }); } @@ -987,6 +1103,48 @@ function escHtml(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } +// ─── Lobby helpers ──────────────────────────────────────────── +function joinGame() { + const name = $('input-name').value.trim(); + const roomId = $('input-code').value.trim().toUpperCase(); + if (!name) { $('lobby-error').textContent = 'Enter your name'; return; } + if (!roomId) { $('lobby-error').textContent = 'Enter a room code'; return; } + $('lobby-error').textContent = ''; + myName = name; + socket.emit('join', { name, roomId }); +} + +async function loadPublicRooms() { + const listEl = $('public-rooms-list'); + if (!listEl) return; + listEl.innerHTML = '
Loading…
'; + try { + const list = await apiFetch('/api/rooms'); + if (!Array.isArray(list) || list.length === 0) { + listEl.innerHTML = 'No public rooms right now.
'; + return; + } + listEl.innerHTML = ''; + list.forEach(room => { + const row = document.createElement('div'); + row.className = 'public-room-item'; + row.innerHTML = ` +Failed to load rooms.
'; + } +} + // ─── PWA install ────────────────────────────────────────────── window.addEventListener('beforeinstallprompt', e => { e.preventDefault(); @@ -1038,28 +1196,33 @@ window.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); btn.classList.add('active'); $(`tab-${btn.dataset.tab}`)?.classList.add('active'); + if (btn.dataset.tab === 'join') loadPublicRooms(); }); }); + // Visibility toggle (public/private) + document.querySelectorAll('.visibility-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.visibility-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + selectedPublic = btn.dataset.public === 'true'; + }); + }); + + // Public rooms refresh + $('btn-refresh-rooms').addEventListener('click', loadPublicRooms); + // Create game $('btn-create').addEventListener('click', () => { const name = $('input-name').value.trim(); if (!name) { $('lobby-error').textContent = 'Enter your name'; return; } $('lobby-error').textContent = ''; myName = name; - socket.emit('create', { name, winScore: selectedScore }); + socket.emit('create', { name, winScore: selectedScore, isPublic: selectedPublic }); }); // Join game - $('btn-join').addEventListener('click', () => { - const name = $('input-name').value.trim(); - const roomId = $('input-code').value.trim().toUpperCase(); - if (!name) { $('lobby-error').textContent = 'Enter your name'; return; } - if (!roomId) { $('lobby-error').textContent = 'Enter a room code'; return; } - $('lobby-error').textContent = ''; - myName = name; - socket.emit('join', { name, roomId }); - }); + $('btn-join').addEventListener('click', joinGame); // Spectate $('btn-spectate').addEventListener('click', () => { @@ -1090,6 +1253,13 @@ window.addEventListener('DOMContentLoaded', () => { // Pass overlay confirm $('btn-confirm-pass').addEventListener('click', confirmPass); + // AFK banner + $('afk-vote-btn').addEventListener('click', () => { + socket.emit('voteAITakeover', { roomId: myRoomId }); + hide('afk-banner'); + }); + $('afk-dismiss-btn').addEventListener('click', () => hide('afk-banner')); + // Leave game $('btn-leave-game').addEventListener('click', () => { if (spectating) { @@ -1107,6 +1277,8 @@ window.addEventListener('DOMContentLoaded', () => { clearSession(); socket.emit('leave', { roomId: myRoomId, seat: mySeat, token: myToken }); myRoomId = null; mySeat = -1; myToken = null; spectating = false; + wasMyTurn = false; handleTurnReminder(false); + aiControlledSeats = new Set(); hide('afk-banner', 'ai-control-banner'); showScreen('screen-lobby'); }); $('btn-exit-confirm-no').addEventListener('click', () => hide('overlay-exit-confirm')); @@ -1116,6 +1288,8 @@ window.addEventListener('DOMContentLoaded', () => { hide('overlay-gameover'); clearSession(); myRoomId = null; mySeat = -1; myToken = null; spectating = false; + wasMyTurn = false; handleTurnReminder(false); + aiControlledSeats = new Set(); hide('afk-banner', 'ai-control-banner'); showScreen('screen-lobby'); }); diff --git a/public/index.html b/public/index.html index bc8048a..a3dfb11 100644 --- a/public/index.html +++ b/public/index.html @@ -51,15 +51,29 @@ +Switch to this tab to load rooms.