(() => { 'use strict'; let forceLoginPage = window.location.pathname.replace(/\/+$/, '') === '/login'; const socket = io({ reconnection: true, reconnectionAttempts: Infinity, reconnectionDelay: 500, reconnectionDelayMax: 4000, randomizationFactor: 0.3, timeout: 20000, transports: ['polling', 'websocket'], tryAllTransports: true, rememberUpgrade: true }); const $ = (id) => document.getElementById(id); const state = { user: null, rooms: [], activeRooms: [], rankings: [], rankingEvent: null, guildRankingEvent: null, tournaments: [], notices: [], shop: [], guilds: [], myGuild: null, history: [], roomStates: {}, activeRoomId: null, selectedLoginAvatar: '๐ŸฆŠ', sound: localStorage.getItem('royal-sound') !== 'off', resultKeys: new Set(), timerInterval: null, shopFilter: 'all', historyFilter: 'all', emojiTargetSeatIndex: null, jackpot: null, userRedemptions: [], support: { telegramId: '232344', handle: '@232344', url: 'https://t.me/232344' }, pendingBuyIn: null, allInSelected: false, actionPending: false, activeTimerSeat: -1, sideDrawerOpen: window.matchMedia('(min-width:761px)').matches, tableCloseDeadline: null, turnAlertKeys: new Set(), turnPulseTimer: null, authMode: 'login', sessionToken: '', expandedHoleSeat: null, holeCollapseTimer: null, emoteReadyAt: 0, emoteCooldownTimer: null, mobileNavOpen: false, ttsVoices: [], winFxTimers: [], myCardPeekOpen: false, pendingExitRooms: new Set(), currentVoiceAudio: null, voicePreviewTimers: [], socketConnectedOnce: false, socketAuthInFlight: false, reconnectAuthFailures: 0, reconnectAuthTimer: null }; const loginAvatars = ['๐ŸฆŠ', '๐Ÿผ', '๐Ÿฏ', '๐Ÿฐ', '๐Ÿธ', '๐Ÿฆ', '๐Ÿง', '๐Ÿป']; const EMOTES = [ { id: 'clap', emoji: '๐Ÿ‘', name: '๋ฐ•์ˆ˜', cost: 0, effect: 'float' }, { id: 'wow', emoji: '๐Ÿ˜ฎ', name: '๋†€๋žŒ', cost: 0, effect: 'float' }, { id: 'fire', emoji: '๐Ÿ”ฅ', name: '๋ถˆ๊ฝƒ', cost: 0, effect: 'float' }, { id: 'gun', emoji: '๐Ÿ”ซ', name: '๋ฌผ์ด ์˜๊ธฐ', cost: 50, effect: 'gun' }, { id: 'egg', emoji: '๐Ÿฅš', name: '๋‹ฌ๊ฑ€ ๋˜์ง€๊ธฐ', cost: 50, effect: 'egg' }, { id: 'tomato', emoji: '๐Ÿ…', name: 'ํ† ๋งˆํ†  ๋˜์ง€๊ธฐ', cost: 50, effect: 'tomato' }, { id: 'kiss', emoji: '๐Ÿ’‹', name: 'ํ•˜ํŠธ ํ‚ค์Šค', cost: 50, effect: 'kiss' }, { id: 'lightning', emoji: 'โšก', name: '๋ฒˆ๊ฐœ', cost: 50, effect: 'lightning' } ]; function escapeHtml(value) { return String(value ?? '').replace(/[&<>'"]/g, (char) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[char]); } function fmt(value) { return Number(value || 0).toLocaleString('ko-KR'); } function phaseName(value) { return ({ idle: '๋Œ€๊ธฐ', waiting: '๋Œ€๊ธฐ', preflop: 'ํ”„๋ฆฌํ”Œ๋ž', flop: 'ํ”Œ๋ž', turn: 'ํ„ด', river: '๋ฆฌ๋ฒ„', showdown: '์‡ผ๋‹ค์šด' })[value] || value; } function currentRoom() { return state.activeRoomId ? state.roomStates[state.activeRoomId] : null; } function compactCardLabel(card) { if (!card) return '?'; const rank = ({ 11: 'J', 12: 'Q', 13: 'K', 14: 'A' })[Number(card.rank)] || String(card.rank || '?'); const suit = ({ s: 'โ™ ', h: 'โ™ฅ', d: 'โ™ฆ', c: 'โ™ฃ' })[card.suit] || ''; return `${rank}${suit}`; } function viewerSeat(payload = currentRoom()) { return payload?.seats?.find((seat) => seat?.userId === state.user?.id) || null; } function isImageAvatar(value) { return typeof value === 'string' && value.startsWith('data:image/'); } function avatarHtml(value, className = '') { if (isImageAvatar(value)) return `ํ”„๋กœํ•„`; return `${escapeHtml(value || '๐ŸฆŠ')}`; } function toast(message, type = 'info') { const node = document.createElement('div'); node.className = `toast ${type}`; node.textContent = message; $('toastLayer').appendChild(node); setTimeout(() => node.remove(), 3400); } const GAME_SOUND_SAMPLES = Object.freeze({ card: '/audio/sfx/card-place.wav?v=3022', 'board-card': '/audio/sfx/card-place.wav?v=3022', chips: '/audio/sfx/chips-place.wav?v=3022', 'chips-collect': '/audio/sfx/chips-place.wav?v=3037', 'pot-award': '/audio/sfx/pot-award.wav?v=3024' }); const gameSoundTemplates = new Map(); function playRecordedGameSound(kind) { const source = GAME_SOUND_SAMPLES[kind]; if (!source) return false; try { let template = gameSoundTemplates.get(kind); if (!template) { template = new Audio(source); template.preload = 'auto'; gameSoundTemplates.set(kind, template); } const audio = template.cloneNode(true); audio.volume = kind === 'chips' ? .82 : kind === 'pot-award' ? .76 : .64; if (kind === 'chips-collect') audio.volume = .82; audio.play()?.catch?.(() => playSyntheticSound(kind)); return true; } catch { return false; } } function playSyntheticSound(kind = 'click') { try { const AudioContext = window.AudioContext || window.webkitAudioContext; const ctx = new AudioContext(); const tones = kind === 'chips' ? [[760, .00, .035], [930, .045, .035]] : kind === 'check' ? [[320, 0, .035], [270, .075, .035]] : kind === 'call' ? [[520, 0, .055], [640, .065, .060]] : kind === 'raise' ? [[430, 0, .065], [650, .075, .075], [880, .165, .095]] : kind === 'fold' ? [[410, 0, .065], [285, .075, .11]] : kind === 'allin' ? [[310, 0, .09], [620, .10, .10], [930, .22, .14], [1240, .38, .20]] : kind === 'win' ? [[660, 0, .10], [880, .10, .12], [1120, .22, .18]] : ['card', 'board-card'].includes(kind) ? [[500, 0, .045], [610, .035, .045]] : kind === 'turn' ? [[740, 0, .08], [980, .11, .10], [740, .24, .08]] : kind === 'alert' ? [[260, 0, .10]] : [[420, 0, .04]]; tones.forEach(([frequency, startAt, duration]) => { const oscillator = ctx.createOscillator(); const gain = ctx.createGain(); oscillator.type = ['chips','check','fold'].includes(kind) ? 'triangle' : kind === 'allin' ? 'sawtooth' : 'sine'; oscillator.frequency.value = frequency; gain.gain.setValueAtTime(.055, ctx.currentTime + startAt); gain.gain.exponentialRampToValueAtTime(.001, ctx.currentTime + startAt + duration); oscillator.connect(gain).connect(ctx.destination); oscillator.start(ctx.currentTime + startAt); oscillator.stop(ctx.currentTime + startAt + duration); }); setTimeout(() => ctx.close(), 700); } catch { /* browser audio restriction */ } } function playSound(kind = 'click') { if (!state.sound) return; if (playRecordedGameSound(kind)) return; playSyntheticSound(kind); } const ACTION_VOICE_PRESETS = { female: { label: '์ Š์€ ์—ฌ์„ฑ', gender: 'female', pitch: 1.02, rate: 1.00, volume: .72, preferredNames: ['SunHi', 'Sora', 'Yuna', 'Seoyeon', '์—ฌ์„ฑ', 'Female', 'Heami', 'Yumi'] } }; const ACTION_VOICE_WORDS = { check: '์ฒดํฌ', call: '์ฝœ', raise: '๋ ˆ์ด์ฆˆ', fold: 'ํด๋“œ', allin: '์˜ฌ์ธ' }; const ACTION_VOICE_PREVIEW_TEXT = '๋ ˆ์ด์ฆˆ! ์ฝœ! ์ฒดํฌ! ํด๋“œ!'; function refreshTtsVoices() { if (!('speechSynthesis' in window)) return; state.ttsVoices = window.speechSynthesis.getVoices().filter((voice) => /^ko[-_]/i.test(voice.lang) || /korean|ํ•œ๊ตญ/i.test(voice.name)); if (!state.ttsVoices.length) state.ttsVoices = window.speechSynthesis.getVoices(); } function selectedActionVoiceStyle() { return 'female'; } function stopActionVoice({ keepPreviewTimers = false } = {}) { if (state.currentVoiceAudio) { try { state.currentVoiceAudio.pause(); state.currentVoiceAudio.currentTime = 0; } catch { /* ignore */ } state.currentVoiceAudio = null; } if ('speechSynthesis' in window) window.speechSynthesis.cancel(); if (!keepPreviewTimers) { state.voicePreviewTimers.forEach((timer) => clearTimeout(timer)); state.voicePreviewTimers = []; } } function scoreVoiceForPreset(voice, preset) { const name = `${voice?.name || ''} ${voice?.voiceURI || ''}`.toLowerCase(); const preferred = (preset.preferredNames || []).reduce((score, hint) => score + (name.includes(String(hint).toLowerCase()) ? 12 : 0), 0); const naturalHints = ['natural', 'neural', 'online', 'google', 'samsung', 'bixby', 'premium', 'enhanced']; const natural = naturalHints.reduce((score, hint) => score + (name.includes(hint) ? 5 : 0), 0); const korean = /^ko[-_]/i.test(voice?.lang || '') || /korean|ํ•œ๊ตญ|ko-kr/i.test(name) ? 20 : 0; return preferred + natural + korean + (voice?.localService ? 1 : 0); } function chooseNaturalVoice(preset) { refreshTtsVoices(); const voices = [...state.ttsVoices]; if (!voices.length) return null; voices.sort((a, b) => scoreVoiceForPreset(b, preset) - scoreVoiceForPreset(a, preset) || String(a.name).localeCompare(String(b.name))); return voices[0] || null; } function styledActionText(actionType) { const word = ACTION_VOICE_WORDS[actionType] || ''; return `${word}!`; } function playPackagedActionVoice(actionType, { keepPreviewTimers = false } = {}) { if (!ACTION_VOICE_WORDS[actionType]) return; const style = selectedActionVoiceStyle(); stopActionVoice({ keepPreviewTimers }); try { const audio = new Audio(`/audio/actions/female/${actionType}.wav?v=3023`); audio.preload = 'auto'; audio.volume = .68; state.currentVoiceAudio = audio; audio.addEventListener('ended', () => { if (state.currentVoiceAudio === audio) state.currentVoiceAudio = null; }, { once: true }); audio.play()?.catch?.(() => playSound(actionType || 'click')); } catch { playSound(actionType || 'click'); } } function speakNaturalAction(actionType, { keepPreviewTimers = false, retry = 0 } = {}) { const text = ACTION_VOICE_WORDS[actionType]; if (!text || !('speechSynthesis' in window) || typeof SpeechSynthesisUtterance === 'undefined') { playPackagedActionVoice(actionType, { keepPreviewTimers }); return; } const style = selectedActionVoiceStyle(); const preset = ACTION_VOICE_PRESETS.female; refreshTtsVoices(); const voice = chooseNaturalVoice(preset); if (!voice && retry < 2) { const timer = setTimeout(() => speakNaturalAction(actionType, { keepPreviewTimers: true, retry: retry + 1 }), 140); state.voicePreviewTimers.push(timer); return; } stopActionVoice({ keepPreviewTimers }); try { const utterance = new SpeechSynthesisUtterance(styledActionText(actionType)); utterance.lang = 'ko-KR'; utterance.pitch = preset.pitch; utterance.rate = preset.rate; utterance.volume = preset.volume; if (voice) utterance.voice = voice; utterance.onerror = () => playPackagedActionVoice(actionType, { keepPreviewTimers: true }); window.speechSynthesis.resume?.(); window.speechSynthesis.speak(utterance); } catch { playPackagedActionVoice(actionType, { keepPreviewTimers }); } } function speakAction(actionType, preview = false) { if (!state.sound) return; if (!preview) { speakNaturalAction(actionType); return; } stopActionVoice(); const previewActions = ['raise', 'call', 'check', 'fold', 'allin']; previewActions.forEach((type, index) => { const timer = setTimeout(() => speakNaturalAction(type, { keepPreviewTimers: true }), index * 1800); state.voicePreviewTimers.push(timer); }); } if ('speechSynthesis' in window) { refreshTtsVoices(); window.speechSynthesis.addEventListener?.('voiceschanged', refreshTtsVoices); } function resizeProfileImage(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = () => reject(new Error('ํŒŒ์ผ์„ ์ฝ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.')); reader.onload = () => { const image = new Image(); image.onerror = () => reject(new Error('์ด๋ฏธ์ง€๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.')); image.onload = () => { const size = 256; const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); const crop = Math.min(image.width, image.height); const sx = (image.width - crop) / 2; const sy = (image.height - crop) / 2; ctx.drawImage(image, sx, sy, crop, crop, 0, 0, size, size); resolve(canvas.toDataURL('image/jpeg', .82)); }; image.src = reader.result; }; reader.readAsDataURL(file); }); } function renderLoginAvatars() { $('loginAvatars').innerHTML = loginAvatars.map((avatar) => ``).join(''); } function scheduleReconnectAuthentication() { clearTimeout(state.reconnectAuthTimer); if (!socket.connected) return; const delay = Math.min(8000, 750 * (2 ** Math.min(state.reconnectAuthFailures, 4))); state.reconnectAuthTimer = setTimeout(() => { const token = localStorage.getItem('royal-token'); authenticate({ token, reconnect: true, silent: true }); }, delay); } function restoreSpectatorRoom(roomId) { if (!roomId || !socket.connected) return; socket.timeout(8000).emit('room:spectate', { roomId }, (error, result) => { if (error || !result?.ok) return; state.activeRoomId = roomId; renderMyRoomTabs(); if (!$('gameView').classList.contains('hidden')) showGameView(); }); } function authenticate({ nickname, avatar, token, reconnect = false, silent = false } = {}) { if (state.socketAuthInFlight || !socket.connected) return; state.socketAuthInFlight = true; const previousRoomId = state.activeRoomId; const wasSpectating = Boolean(previousRoomId && state.roomStates[previousRoomId]?.viewer?.isSpectator); socket.timeout(12000).emit('auth', { nickname, avatar, token, sessionToken: state.sessionToken, reconnect }, (timeoutError, result) => { state.socketAuthInFlight = false; if (timeoutError) { state.reconnectAuthFailures += 1; $('connectionBadge').textContent = '์žฌ์—ฐ๊ฒฐ ์ค‘'; $('connectionBadge').className = 'connection offline'; scheduleReconnectAuthentication(); return; } if (!result?.ok) { if (result?.code === 'AUTH_REQUIRED' || result?.code === 'BANNED') { clearTimeout(state.reconnectAuthTimer); localStorage.removeItem('royal-token'); state.sessionToken = ''; state.user = null; unlockAuthPageScroll(); $('loginScreen').classList.remove('hidden'); if (!silent) toast(result?.message || '๋กœ๊ทธ์ธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', 'error'); return; } state.reconnectAuthFailures += 1; $('connectionBadge').textContent = '์žฌ์—ฐ๊ฒฐ ์ค‘'; $('connectionBadge').className = 'connection offline'; scheduleReconnectAuthentication(); return; } clearTimeout(state.reconnectAuthTimer); state.reconnectAuthFailures = 0; $('connectionBadge').textContent = '์˜จ๋ผ์ธ'; $('connectionBadge').className = 'connection online'; if (result.token) localStorage.setItem('royal-token', result.token); if (result.sessionToken) state.sessionToken = result.sessionToken; state.user = result.user; if (forceLoginPage) { forceLoginPage = false; window.history.replaceState({}, document.title, '/'); } $('loginScreen').classList.add('hidden'); $('app').classList.remove('hidden'); updateTopUser(); const reconnectedRoomIds = Array.isArray(result.reconnectedRoomIds) ? result.reconnectedRoomIds : []; if (reconnectedRoomIds.length) { state.activeRoomId = reconnectedRoomIds.includes(previousRoomId) ? previousRoomId : reconnectedRoomIds[0]; if (!$('gameView').classList.contains('hidden')) showGameView(); } else if (reconnect && wasSpectating) { restoreSpectatorRoom(previousRoomId); } }); } function updateTopUser() { if (!state.user) return; const displayAvatar = state.user.profileImage || state.user.avatar; $('topAvatar').innerHTML = avatarHtml(displayAvatar); $('topNickname').textContent = state.user.nickname; $('topChips').textContent = fmt(state.user.chips); $('profileAvatar').innerHTML = avatarHtml(displayAvatar, 'profile-photo'); $('profileName').textContent = state.user.nickname; } function applyBootstrap(data) { if (!data) return; ['user', 'rooms', 'activeRooms', 'rankings', 'rankingEvent', 'guildRankingEvent', 'tournaments', 'notices', 'shop', 'guilds', 'myGuild', 'history', 'jackpot', 'userRedemptions', 'support'].forEach((key) => { if (data[key] !== undefined) state[key] = data[key]; }); updateTopUser(); renderAll(); } function renderAll() { renderMyRoomTabs(); renderRooms(); renderTournaments(); renderNotices(); renderRankings(); renderRewards(); renderMissions(); renderShop(); renderGuilds(); renderProfile(); renderEmojiBar(); renderSupportContact(); } function renderSupportContact() { const telegramId = String(state.support?.telegramId || '232344').replace(/^@+/, ''); const handle = `@${telegramId}`; const url = state.support?.url || `https://t.me/${telegramId}`; if ($('telegramContactId')) $('telegramContactId').textContent = handle; if ($('telegramContactLink')) $('telegramContactLink').href = url; } async function loadSupportContact() { try { const response = await fetch('/api/support-contact', { credentials: 'same-origin' }); const data = await response.json(); if (response.ok && data?.support) { state.support = data.support; renderSupportContact(); } } catch {} } function switchTab(tab) { document.querySelectorAll('.tab-panel').forEach((panel) => panel.classList.toggle('active', panel.id === `tab-${tab}`)); document.querySelectorAll('.nav-btn[data-tab]').forEach((button) => button.classList.toggle('active', button.dataset.tab === tab)); socket.emit('bootstrap:refresh', () => {}); } function roomTypeLabel(type) { return ({ stake20000: '20,000P', stake50000: '50,000P', stake100000: '100,000P', stake300000: '300,000P', bot: '์—ฐ์Šต๋ฐฉ', friend: '์นœ๊ตฌ๋ฐฉ', rush: '๋Ÿฌ์‹œ', tournament: 'ํ† ๋„ˆ๋จผํŠธ' })[type] || type; } function renderMyRoomTabs() { const rows = state.activeRooms || []; if (!rows.length) { $('myRoomTabs').innerHTML = '์ฐธ๊ฐ€ ์ค‘์ธ ๋ฐฉ ์—†์Œ'; return; } $('myRoomTabs').innerHTML = rows.map((room) => { const payload = state.roomStates[room.id]; const me = viewerSeat(payload); const isTurn = payload?.game?.active && payload.game.actionIndex === me?.seatIndex; const holeText = (me?.cards || []).filter(Boolean).map(compactCardLabel).join(' '); return ``; }).join(''); } function renderRooms() { const rooms = state.rooms || []; const joined = new Set((state.activeRooms || []).map((room) => room.id)); const jackpot = Number(state.jackpot?.balance || 10000000); if ($('lobbyJackpot')) $('lobbyJackpot').innerHTML = `
ROYAL JACKPOT${fmt(jackpot)}P
์›ํ•ธ๋“œยทํˆฌํ•ธ๋“œ๋งŒ ยท ๋ณด๋“œ ๋‹จ๋… ์ œ์™ธ`; const time = (value) => new Date(value).toLocaleTimeString('ko-KR',{hour:'2-digit',minute:'2-digit'}); $('roomGrid').innerHTML = rooms.map((room) => { const left = Math.max(0, Number(room.remainingOpenMs || 0)); const hh = String(Math.floor(left/3600000)).padStart(2,'0'); const mm = String(Math.floor(left%3600000/60000)).padStart(2,'0'); const openTimeHtml = `OPEN ${time(room.createdAt)}์ž…์žฅ ๊ฐ€๋Šฅ ${hh}:${mm} ๋‚จ์Œ${time(room.createdAt)} ~ ${time(room.closesAt)}`; const full = Number(room.total || 0) >= Number(room.maxPlayers || 8); const joinedRoom = joined.has(room.id); const mainAction = joinedRoom ? `` : full ? `` : ``; return `
${room.locked ? '๐Ÿ”’ ' : ''}${roomTypeLabel(room.type)} ยท ${phaseName(room.status)}

${escapeHtml(room.name)}

๋ฐ”์ด์ธ ${fmt(room.buyIn)}P ยท ๋ธ”๋ผ์ธ๋“œ ${fmt(room.smallBlind)} / ${fmt(room.bigBlind)}
${openTimeHtml}
${room.total || 0}/${room.maxPlayers || 8}๋ช…${room.pending ? `์˜ˆ์•ฝ ${room.pending}๋ช…` : ''}๐Ÿ‘ ${room.spectators}
${(room.pendingNames || []).length ? `
${room.pendingNames.map((name) => `${escapeHtml(name)} ๋Œ€๊ธฐ ์ค‘`).join(' ยท ')}
` : ''}
${mainAction}
`; }).join('') || '
ํ˜„์žฌ ์ž…์žฅ ๊ฐ€๋Šฅํ•œ ๋ฐฉ์ด ์—†์Šต๋‹ˆ๋‹ค.
'; } function renderTournaments() { if (!$('tournamentGrid')) return; const statusLabel = { scheduled: '์ฐธ๊ฐ€ ๋ชจ์ง‘', running: '์ง„ํ–‰ ์ค‘', completed: '์ข…๋ฃŒ', cancelled: '์ทจ์†Œ' }; $('tournamentGrid').innerHTML = (state.tournaments || []).map((item) => { const joined = item.participants?.includes(state.user?.id); const canJoin = item.status === 'scheduled' && !joined && (item.participants?.length || 0) < item.maxPlayers && new Date(item.startsAt).getTime() > Date.now(); const canEnter = item.status === 'running' && joined && item.roomId; const prizes = (item.prizes || []).map((row) => `${row.rank}์œ„ ${fmt(row.amount)}P`).join(' ยท '); const winners = (item.winners || []).map((row) => `${row.rank}์œ„ ${escapeHtml(row.nickname)} +${fmt(row.amount)}P`).join('
'); const action = canEnter ? `` : canJoin ? `` : ``; return `
${escapeHtml(statusLabel[item.status] || item.status)}

${escapeHtml(item.name)}

์‹œ์ž‘ ${new Date(item.startsAt).toLocaleString('ko-KR')}
์ฐธ๊ฐ€๋น„ ${fmt(item.entryFee)}P ยท ์‹œ์ž‘ ์Šคํƒ ${fmt(item.startingStack)}P
์ƒ๊ธˆ ${escapeHtml(prizes || '๋ฏธ์ •')}
${item.participants?.length || 0} / ${item.maxPlayers}๋ช…

${winners ? `

${winners}

` : ''}${action}
`; }).join('') || '
์˜ˆ์ •๋œ ํ† ๋„ˆ๋จผํŠธ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.
'; } function renderNotices() { const notices = (state.notices || []).filter((notice) => notice?.active !== false); if (!$('noticeGrid')) return; $('noticeGrid').innerHTML = notices.map((notice) => { const images = (notice.images || []).slice(0, 4).map((src, index) => `
${escapeHtml(notice.title)} ์ฒจ๋ถ€์‚ฌ์ง„ ${index + 1}
`).join(''); const updatedAt = notice.updatedAt || notice.createdAt; return ``; }).join('') || '
๋“ฑ๋ก๋œ ๊ณต์ง€์‚ฌํ•ญ์ด ์—†์Šต๋‹ˆ๋‹ค.
'; } function openNoticeDetail(noticeId) { const notice = (state.notices || []).find((row) => row.id === noticeId); if (!notice || !$('noticeDetailDialog') || !$('noticeDetailContent')) return; const images = (notice.images || []).map((src, index) => `
${escapeHtml(notice.title)} ์ฒจ๋ถ€์‚ฌ์ง„ ${index + 1}
์ฒจ๋ถ€์‚ฌ์ง„ ${index + 1}
`).join(''); $('noticeDetailContent').innerHTML = `
${notice.pinned ? 'IMPORTANT NOTICE' : 'NOTICE'}

${escapeHtml(notice.title)}

${escapeHtml(notice.content).replace(/\n/g, '
')}
${images ? `
${images}
` : ''}
`; $('noticeDetailDialog').showModal(); } function renderRankings() { const event = state.rankingEvent; if (event) { $('rankingEnds').textContent = `${new Date(event.endsAt).toLocaleString('ko-KR')} ๋งˆ๊ฐ ยท ํ™˜์ „ ๋ถˆ๊ฐ€ ์ด๋ฒคํŠธ ํฌ์ธํŠธ`; $('rankingPayouts').innerHTML = event.payouts.map((row) => `
${row.rank}์œ„${fmt(row.prize)}P
`).join(''); } $('rankingBody').innerHTML = (state.rankings || []).slice(0, 10).map((row) => `${row.rank}${avatarHtml(row.profileImage || row.avatar, 'rank-avatar')}${escapeHtml(row.nickname)}${fmt(row.weeklyRankPoints)}${fmt(row.rankPoints)}${row.wins}์Šน / ${row.hands}ํ•ธ๋“œ`).join(''); const jackpot = state.jackpot || { balance: 10000000, recentHits: [] }; const hits = [...(jackpot.recentHits || [])] .filter((hit) => hit && Number(hit.amount || 0) > 0) .sort((left, right) => new Date(right.createdAt || 0) - new Date(left.createdAt || 0)) .slice(0, 30); if ($('jackpotStatusBalance')) $('jackpotStatusBalance').textContent = `ํ˜„์žฌ ์žญํŒŸ ${fmt(jackpot.balance)}P`; if ($('jackpotStatusCount')) $('jackpotStatusCount').textContent = `์ตœ๊ทผ ๋‹น์ฒจ ${hits.length}๊ฑด`; if ($('jackpotStatusFeed')) { $('jackpotStatusFeed').innerHTML = hits.map((hit, index) => { const status = hit.status === 'claimable' ? '์ˆ˜๋ น ๋Œ€๊ธฐ' : '์ง€๊ธ‰ ์™„๋ฃŒ'; const occurredAt = hit.createdAt ? new Date(hit.createdAt).toLocaleString('ko-KR') : '-'; return `
${index + 1}
${escapeHtml(hit.nickname || 'ํ”Œ๋ ˆ์ด์–ด')}${escapeHtml(hit.roomName || '๊ฒŒ์ž„๋ฐฉ')} ยท Hand #${Number(hit.handNo || 0)}
${escapeHtml(hit.handName || '์žญํŒŸ')}${occurredAt}
+${fmt(hit.amount)}P${status}
`; }).join('') || '
์•„์ง ์žญํŒŸ ๋‹น์ฒจ์ž๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.ํ™€์นด๋“œ 1์žฅ ๋˜๋Š” 2์žฅ์ด ์‹ค์ œ ์กฑ๋ณด ๊ตฌ์„ฑ์— ๋“ค์–ด๊ฐ„ ์žญํŒŸ๋งŒ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.
'; } } function rewardCard(item, kind) { const percent = Math.min(100, (item.progress / item.target) * 100); const ready = item.progress >= item.target; const attr = kind === 'signup' ? `data-signup-reward="${item.id}"` : `data-weekly-reward="${item.id}"`; return `
${kind === 'signup' ? 'NEW' : 'WEEKLY'}

${escapeHtml(item.title)}

+${fmt(item.reward)}P

${escapeHtml(item.description || '')}${item.description ? '
' : ''}${item.progress} / ${item.target}

`; } function renderRewards() { const signup = state.user?.signupRewards || []; const weekly = state.user?.weeklyRewards || []; $('signupRewardGrid').innerHTML = signup.map((item) => rewardCard(item, 'signup')).join(''); $('weeklyRewardGrid').innerHTML = weekly.map((item) => rewardCard(item, 'weekly')).join(''); if (state.rankingEvent) $('weeklyResetText').textContent = `${new Date(state.rankingEvent.endsAt).toLocaleDateString('ko-KR')} ์ดˆ๊ธฐํ™”`; } function renderMissions() { const missions = state.user?.missions?.items || []; $('missionGrid').innerHTML = missions.map((mission) => { const percent = Math.min(100, (mission.progress / mission.target) * 100); const ready = mission.progress >= mission.target; return `
DAILY

${escapeHtml(mission.title)}

+${fmt(mission.reward)}P

${mission.progress} / ${mission.target}

`; }).join(''); } const SKIN_THEMES = { classic: { felt:'#12603d', felt2:'#062b1c', rail:'#111918', rail2:'#89d95a', accent:'#a6ee72', glow:'rgba(104,255,150,.38)', sigil:'โ™ ', motif:'classic' }, 'azure-dragon': { felt:'#075e74', felt2:'#031c2d', rail:'#07141b', rail2:'#52d9e9', accent:'#7af3ff', glow:'rgba(66,229,255,.48)', sigil:'้พ', motif:'dragon' }, 'white-tiger': { felt:'#59616a', felt2:'#161b20', rail:'#0b0d0f', rail2:'#e6d8b1', accent:'#fff1c6', glow:'rgba(255,240,198,.42)', sigil:'่™Ž', motif:'tiger' }, 'black-tortoise': { felt:'#17493d', felt2:'#041813', rail:'#050b0a', rail2:'#6fb59e', accent:'#8bd7bd', glow:'rgba(97,215,177,.42)', sigil:'็Ž„', motif:'tortoise' }, 'vermilion-bird': { felt:'#8d2d20', felt2:'#2a0605', rail:'#180807', rail2:'#ff9d48', accent:'#ffbe68', glow:'rgba(255,92,43,.52)', sigil:'ๆœฑ', motif:'phoenix' }, 'golden-dragon': { felt:'#564210', felt2:'#171003', rail:'#100d05', rail2:'#f1d36d', accent:'#ffe992', glow:'rgba(255,213,77,.54)', sigil:'็š‡', motif:'gold-dragon' } }; function valueHue(value) { let hash = 0; for (const char of String(value || 'royal')) hash = ((hash << 5) - hash + char.charCodeAt(0)) | 0; return Math.abs(hash) % 360; } function skinTheme(value) { if (SKIN_THEMES[value]) return SKIN_THEMES[value]; const hue = valueHue(value); return { felt:`hsl(${hue} 52% 28%)`, felt2:`hsl(${hue} 58% 10%)`, rail:`hsl(${(hue + 25) % 360} 35% 8%)`, rail2:`hsl(${(hue + 55) % 360} 74% 62%)`, accent:`hsl(${(hue + 70) % 360} 88% 72%)`, glow:`hsla(${hue} 88% 58% / .44)`, sigil:'โ—†', motif:'classic' }; } function skinInlineStyle(value) { const t = skinTheme(value); return `--felt:${t.felt};--felt2:${t.felt2};--rail:${t.rail};--rail2:${t.rail2};--accent:${t.accent};--table-glow:${t.glow};--sigil:"${t.sigil}"`; } const BUILTIN_CARD_SKINS = new Set(['classic-card','midnight-blue','emerald-grid','royal-gold','crimson-dragon','violet-neon']); function cardSkinInlineStyle(value) { if (BUILTIN_CARD_SKINS.has(value)) return ''; const hue = valueHue(value); return `background:repeating-linear-gradient(45deg,hsl(${hue} 62% 28%) 0 7px,hsl(${(hue + 42) % 360} 72% 42%) 7px 14px);`; } function itemIcon(item) { if (item.type === 'avatar') return item.value; if (item.type === 'skin') return ({ classic: 'โ™ ', 'azure-dragon':'้พ', 'white-tiger':'่™Ž', 'black-tortoise':'็Ž„', 'vermilion-bird':'ๆœฑ', 'golden-dragon':'็š‡' })[item.value] || '๐ŸŽจ'; if (item.type === 'cardSkin') return '๐Ÿ‚ '; return Array.isArray(item.value) ? item.value.join(' ') : item.value; } function ownsItem(item) { const inventory = state.user?.inventory || {}; const bucket = item.type === 'avatar' ? inventory.avatars : item.type === 'skin' ? inventory.skins : item.type === 'cardSkin' ? inventory.cardSkins : inventory.emojis; const values = Array.isArray(item.value) ? item.value : [item.value]; return values.every((value) => bucket?.includes(value)); } function renderShop() { const rows = (state.shop || []).filter((item) => state.shopFilter === 'all' || item.category === state.shopFilter); $('shopGrid').innerHTML = rows.map((item) => `
${item.type === 'skin' ? `
${itemIcon(item)}${escapeHtml(item.name)}ROYAL TABLE
` : item.type === 'cardSkin' ? `
โ™ 
${escapeHtml(item.name)}
` : `
${itemIcon(item)}
`} ${escapeHtml(item.category)}

${escapeHtml(item.name)}

${fmt(item.price)}P

${item.type === 'skin' ? `` : item.type === 'cardSkin' ? `` : ''}
`).join(''); document.querySelectorAll('[data-shop-filter]').forEach((button) => button.classList.toggle('active', button.dataset.shopFilter === state.shopFilter)); } function renderGuilds() { const mine = state.myGuild; const event = state.guildRankingEvent || { totalPrizePoints: 100000, payouts: [{ rank: 1, prize: 50000 }, { rank: 2, prize: 30000 }, { rank: 3, prize: 20000 }], maxMembers: 20, creationCost: 100000 }; const payoutText = (event.payouts || []).map((row) => `${row.rank}์œ„ ${fmt(row.prize)}P`).join(' ยท '); $('guildRankingPrize').innerHTML = `
WEEKLY GUILD PRIZE

์ฃผ๊ฐ„ ๊ธธ๋“œ ์ƒ๊ธˆ ์ด ${fmt(event.totalPrizePoints || 100000)}P

${payoutText}

RP ๊ธฐ์ค€๋งค์ฃผ ์›”์š”์ผ ๊ธธ๋“œ์›์—๊ฒŒ ๊ท ๋“ฑ ๋ถ„๋ฐฐ
`; const guildRejoinAt = Date.parse(state.user?.guildRejoinAvailableAt || ''); const guildCooldownActive = Number.isFinite(guildRejoinAt) && guildRejoinAt > Date.now(); const guildCooldownHours = guildCooldownActive ? Math.max(1, Math.ceil((guildRejoinAt - Date.now()) / 3600000)) : 0; $('openGuildCreate').disabled = Boolean(mine) || guildCooldownActive; if (!mine) { $('myGuild').innerHTML = guildCooldownActive ? `

๊ธธ๋“œ ์žฌ๊ฐ€์ž… ๋Œ€๊ธฐ ์ค‘

๊ธธ๋“œ ํƒˆํ‡ด ํ›„ 24์‹œ๊ฐ„ ๋™์•ˆ ์žฌ๊ฐ€์ž…ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์•ฝ ${guildCooldownHours}์‹œ๊ฐ„ ํ›„ ๊ฐ€์ž…ยท์ฐฝ์„คํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

` : '

๊ฐ€์ž…ํ•œ ๊ธธ๋“œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.

๊ธธ๋“œ๋ฅผ ๋งŒ๋“ค๊ฑฐ๋‚˜ ์•„๋ž˜ ๋ชฉ๋ก์—์„œ ๊ฐ€์ž…ํ•˜์„ธ์š”. ๊ธธ๋“œ ์ถœ์„ยทํ›„์›ยท๊ฒŒ์ž„ ๊ฒฐ๊ณผ๋กœ ์ฃผ๊ฐ„ RP๋ฅผ ์Œ“์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

'; } else { const memberRows = mine.members.map((member, index) => `${index + 1}${member.avatar} ${escapeHtml(member.nickname)}${member.role === 'master' ? ' ๐Ÿ‘‘' : ''}${fmt(member.contribution)}P${member.wins}์Šน`).join(''); const guildTopTen = Array.from({ length: 10 }, (_, index) => { const guild = (state.guilds || [])[index]; return guild ? `
${index + 1}์œ„${escapeHtml(guild.name)}${fmt(guild.weeklyRp ?? guild.weeklyPoints)} RP
` : `
${index + 1}์œ„-0 RP
`; }).join(''); $('myGuild').innerHTML = `
MY GUILD ยท LV.${mine.level}

๐Ÿ›ก๏ธ ${escapeHtml(mine.name)}

${escapeHtml(mine.description)}

${fmt(mine.weeklyPoints)}์ฃผ๊ฐ„ ๊ธธ๋“œ RP
๐Ÿ“ข ๊ณต์ง€${escapeHtml(mine.notice)}
${mine.isOwner ? `
` : ''}

๊ธธ๋“œ์› ยท ${mine.memberCount}/${mine.maxMembers || 20}๋ช…

${memberRows}
#๊ธธ๋“œ์›์ฃผ๊ฐ„ RP์Šน๋ฆฌ

์ฃผ๊ฐ„ ๊ธธ๋“œ TOP 10

${guildTopTen}
`; } $('guildGrid').innerHTML = (state.guilds || []).map((guild, index) => `
RANK ${index + 1} ยท LV.${guild.level}

๐Ÿ›ก๏ธ ${escapeHtml(guild.name)}

${escapeHtml(guild.description)}

${guild.memberCount}/${guild.maxMembers || 20}๋ช…์ฃผ๊ฐ„ ${fmt(guild.weeklyRp ?? guild.weeklyPoints)} RP๋ˆ„์  ${fmt(guild.points)} RP
`).join('') || '
์•„์ง ์ƒ์„ฑ๋œ ๊ธธ๋“œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.
'; } function historyBoardHtml(board = [], compact = false) { const cards = Array.isArray(board) ? board : []; const street = (label, items) => items.length ? `
${label}
${items.map((card) => cardHtml(card, true)).join('')}
` : ''; return `
${street('ํ”Œ๋ž', cards.slice(0, 3))}${street('ํ„ด', cards.slice(3, 4))}${street('๋ฆฌ๋ฒ„', cards.slice(4, 5))}
`; } function replayPlayerCards(row, player) { const revealed = player.cardsRevealed !== undefined ? Boolean(player.cardsRevealed) : Boolean(!player.folded && row?.result?.revealAll !== false); if (!revealed) return '์นด๋“œ ๋น„๊ณต๊ฐœ'; return `${(player.cards || []).map((card) => cardHtml(card, true)).join('')}`; } function historyRow(row) { const me = row.players?.find((player) => player.userId === state.user?.id); const net = Number(me?.net ?? 0); const won = net > 0; const label = `${net > 0 ? '+' : ''}${fmt(net)}P`; return { me, net, won, html: `
${escapeHtml(row.roomName)}

${new Date(row.createdAt).toLocaleString('ko-KR')} ยท Hand #${row.handNo} ยท ${escapeHtml(row.blinds)}

${label}
` }; } function renderProfile() { if (!state.user) return; const winRate = state.user.hands ? Math.round((state.user.wins / state.user.hands) * 1000) / 10 : 0; $('profileStats').innerHTML = [ ['๋ณด์œ  ํฌ์ธํŠธ', `${fmt(state.user.chips)}P`], ['๋ˆ„์  ์ „์ ', `${state.user.wins}์Šน ${state.user.losses}ํŒจ`], ['์Šน๋ฅ ', `${winRate}%`], ['๋ˆ„์  RP', fmt(state.user.rankPoints)], ['์ฃผ๊ฐ„ RP', fmt(state.user.weeklyRankPoints)], ['ํ”Œ๋ ˆ์ด ํ•ธ๋“œ', fmt(state.user.hands)] ].map(([label, value]) => `
${label}${value}
`).join(''); const avatars = state.user.inventory?.avatars || []; $('avatarInventory').innerHTML = avatars.map((avatar) => ``).join(''); const skins = state.user.inventory?.skins || ['classic']; const skinNames = { classic: 'ํด๋ž˜์‹', 'azure-dragon': '์ฒญ๋ฃก', 'white-tiger': '๋ฐฑํ˜ธ', 'black-tortoise': 'ํ˜„๋ฌด', 'vermilion-bird': '์ฃผ์ž‘', 'golden-dragon': 'ํ™ฉ๋ฃก' }; $('skinInventory').innerHTML = skins.map((skin) => ``).join(''); const cardSkins = state.user.inventory?.cardSkins || ['classic-card']; const cardSkinNames = { 'classic-card':'ํด๋ž˜์‹', 'midnight-blue':'๋ฏธ๋“œ๋‚˜์ดํŠธ', 'emerald-grid':'์—๋ฉ”๋ž„๋“œ', 'royal-gold':'๋กœ์—ด ๊ณจ๋“œ', 'crimson-dragon':'ํฌ๋ฆผ์Šจ', 'violet-neon':'๋ฐ”์ด์˜ฌ๋ ›' }; $('cardSkinInventory').innerHTML = cardSkins.map((skin) => `
`).join(''); $('emojiInventory').innerHTML = (state.user.inventory?.emojis || []).map((emoji) => `${emoji}`).join(''); if ($('voucherWallet')) $('voucherWallet').textContent = `${fmt(state.user.exchangeablePoints ?? state.user.chips)}P`; if ($('voucherRestriction')) { const remaining = Number(state.user.rewardUnlockRemainingHands || 0); const locked = Number(state.user.restrictedExchangePoints || 0); $('voucherRestriction').classList.toggle('hidden', remaining <= 0 || locked <= 0); $('voucherRestriction').innerHTML = remaining > 0 && locked > 0 ? `๋ณด์ƒ ํฌ์ธํŠธ ๊ตํ™˜ ์ž ๊ธˆ๊ฐ€์ž…ยท์‹ ๊ทœยท์ฃผ๊ฐ„ ๋ณด์ƒ ${fmt(locked)}P๋Š” ${fmt(remaining)}ํ•ธ๋“œ๋ฅผ ๋” ์™„๋ฃŒํ•˜๋ฉด ๊ตํ™˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ฒŒ์ž„ ํ”Œ๋ ˆ์ด์—๋Š” ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.` : ''; } const voucherRows = state.userRedemptions || []; if ($('voucherHistory')) { const labels = { pending: '์ฒ˜๋ฆฌ ๋Œ€๊ธฐ', approved: '์Šน์ธ ์™„๋ฃŒ', rejected: '๋ฐ˜๋ คยทํ™˜๋ถˆ' }; $('voucherHistory').innerHTML = voucherRows.map((row) => `
${escapeHtml(row.reward)}${new Date(row.createdAt).toLocaleString('ko-KR')} ยท ${escapeHtml(row.contact || '')}
${fmt(row.points)}P${labels[row.status] || escapeHtml(row.status)}
${row.adminNote ? `

๊ด€๋ฆฌ์ž ๋ฉ”๋ชจ: ${escapeHtml(row.adminNote)}

` : ''}
`).join('') || '
์•„์ง ์‹ ์ฒญํ•œ ๊ตํ™˜๊ถŒ์ด ์—†์Šต๋‹ˆ๋‹ค.
'; } const jackpot = state.jackpot || { balance: 10000000, claims: [] }; $('jackpotBalanceLabel').textContent = `ํ˜„์žฌ ์žญํŒŸ ${fmt(jackpot.balance)}P`; const jackpotClaims = jackpot.claims || []; $('jackpotPanel').innerHTML = `
ํ˜„์žฌ ๋ˆ„์ ${fmt(jackpot.balance)}P
๋‚ด ์ˆ˜๋ น ๋ˆ„๊ณ„${fmt(state.user.jackpotWins || 0)}P
์žญํŒŸ ๋Œ€์ƒ: ํฌ์นด๋“œ ยท ์Šคํ‹ฐํ”Œ ยท ๋กœํ‹ฐํ”Œ์˜ ์›ํ•ธ๋“œ/ํˆฌํ•ธ๋“œ
๋ณด๋“œ 5์žฅ๋งŒ์œผ๋กœ ์™„์„ฑ๋œ ์กฑ๋ณด์™€ ๋‹จ์ˆœ ํ‚ค์ปค๋Š” ์ œ์™ธ๋ฉ๋‹ˆ๋‹ค.
${jackpotClaims.map((claim) => `
${escapeHtml(claim.handName)}${escapeHtml(claim.roomName)} ยท Hand #${claim.handNo} ยท ${claim.percent}%
${fmt(claim.amount)}P${claim.status === 'claimable' ? `` : '์ˆ˜๋ น ์™„๋ฃŒ'}
`).join('') || '

์•„์ง ๋‹ฌ์„ฑํ•œ ์žญํŒŸ ๋ณด์ƒ์ด ์—†์Šต๋‹ˆ๋‹ค.

'}
`; const rows = (state.history || []).map(historyRow).filter((row) => state.historyFilter === 'all' || (state.historyFilter === 'win' ? row.won : !row.won)); $('profileHistory').innerHTML = rows.map((row) => row.html).join('') || '
ํ•ด๋‹นํ•˜๋Š” ๊ฒŒ์ž„ ๊ธฐ๋ก์ด ์—†์Šต๋‹ˆ๋‹ค.
'; document.querySelectorAll('[data-history-filter]').forEach((button) => button.classList.toggle('active', button.dataset.historyFilter === state.historyFilter)); } function showLobby() { clearWinFx(); state.myCardPeekOpen = false; collapseHoleCards(); $('pokerTable')?.classList.remove('my-turn-pulse'); $('gameView').classList.add('hidden'); $('lobbyView').classList.remove('hidden'); document.body.classList.remove('game-active'); setMobileNav(false); socket.emit('bootstrap:refresh', () => {}); } function syncGameSideDrawerForViewport() { if (window.matchMedia('(min-width:761px)').matches) state.sideDrawerOpen = true; $('gameSideDrawer').classList.toggle('collapsed', !state.sideDrawerOpen); $('sideDrawerToggle').setAttribute('aria-expanded', state.sideDrawerOpen ? 'true' : 'false'); $('sideDrawerToggle').querySelector('span').textContent = state.sideDrawerOpen ? 'โ–ถ' : 'โ—€'; } function showGameView() { setMobileNav(false); document.body.classList.add('game-active'); $('lobbyView').classList.add('hidden'); $('gameView').classList.remove('hidden'); window.scrollTo({ top: 0, behavior: 'auto' }); syncGameSideDrawerForViewport(); setSidePanel('chat'); const payload = currentRoom(); if (payload) renderGame(payload); } function openRoom(roomId, options = {}) { if (!roomId) return toast('๊ฒŒ์ž„๋ฐฉ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', 'error'); state.activeRoomId = roomId; state.emojiTargetSeatIndex = null; state.allInSelected = false; renderMyRoomTabs(); showGameView(); if (!state.roomStates[roomId] && options.skipSpectate !== true) { socket.emit('room:spectate', { roomId }, (result) => { if (!result?.ok) { showLobby(); toast(result?.message || '๋ชจ๋ฐ”์ผ์—์„œ ๊ฒŒ์ž„๋ฐฉ ์ •๋ณด๋ฅผ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.', 'error'); } }); } } function joinRoom(roomId) { const room = state.rooms.find((entry) => entry.id === roomId); let password = ''; if (room?.locked) password = prompt('๋ฐฉ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”.') || ''; socket.emit('room:spectate', { roomId, password }, (result) => { if (!result?.ok) return toast(result?.message || '๋ฐฉ์— ์ž…์žฅํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', 'error'); openRoom(result.roomId, { skipSpectate: true }); playSound('click'); toast('๊ฒŒ์ž„๋ฐฉ์— ์ž…์žฅํ–ˆ์Šต๋‹ˆ๋‹ค. ์›ํ•˜๋Š” ์ขŒ์„์˜ ์ฐฉ์„ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅด์„ธ์š”.', 'info'); }); } function watchRoom(roomId) { socket.emit('room:spectate', { roomId }, (result) => { if (!result?.ok) return toast(result?.message || '๊ด€์ „ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', 'error'); openRoom(roomId, { skipSpectate: true }); }); } function cardHtml(card, small = false, extraClass = '', delayMs = 0) { const style = `--deal-delay:${Number(delayMs || 0)}ms`; if (!card) { const backSkin = state.user?.selectedCardSkin || 'classic-card'; return `
โ™ 
`; } const rank = ({ 11: 'J', 12: 'Q', 13: 'K', 14: 'A' })[card.rank] || card.rank; const suit = ({ s: 'โ™ ', h: 'โ™ฅ', d: 'โ™ฆ', c: 'โ™ฃ' })[card.suit]; const red = card.suit === 'h' || card.suit === 'd'; return `
${rank}${suit}
${suit}
${rank}${suit}
`; } function evaluateFiveClient(cards) { const ranks = cards.map((card) => Number(card.rank)).sort((a, b) => b - a); const counts = {}; ranks.forEach((rank) => { counts[rank] = (counts[rank] || 0) + 1; }); const groups = Object.entries(counts) .map(([rank, count]) => ({ rank: Number(rank), count })) .sort((a, b) => b.count - a.count || b.rank - a.rank); const flush = cards.every((card) => card.suit === cards[0].suit); const unique = [...new Set(ranks)].sort((a, b) => b - a); if (unique.includes(14)) unique.push(1); let straightHigh = 0; for (let index = 0; index <= unique.length - 5; index += 1) { let sequential = true; for (let step = 1; step < 5; step += 1) if (unique[index + step] !== unique[index] - step) sequential = false; if (sequential) { straightHigh = unique[index]; break; } } if (flush && straightHigh) return { category: 8, tiebreak: [straightHigh], name: straightHigh === 14 ? '๋กœ์—ด ์ŠคํŠธ๋ ˆ์ดํŠธ ํ”Œ๋Ÿฌ์‹œ' : '์ŠคํŠธ๋ ˆ์ดํŠธ ํ”Œ๋Ÿฌ์‹œ' }; if (groups[0]?.count === 4) return { category: 7, tiebreak: [groups[0].rank, groups[1]?.rank || 0], name: 'ํฌ์นด๋“œ' }; if (groups[0]?.count === 3 && groups[1]?.count === 2) return { category: 6, tiebreak: [groups[0].rank, groups[1].rank], name: 'ํ’€ํ•˜์šฐ์Šค' }; if (flush) return { category: 5, tiebreak: ranks, name: 'ํ”Œ๋Ÿฌ์‹œ' }; if (straightHigh) return { category: 4, tiebreak: [straightHigh], name: '์ŠคํŠธ๋ ˆ์ดํŠธ' }; if (groups[0]?.count === 3) return { category: 3, tiebreak: [groups[0].rank, ...groups.slice(1).map((group) => group.rank).sort((a, b) => b - a)], name: 'ํŠธ๋ฆฌํ”Œ' }; if (groups[0]?.count === 2 && groups[1]?.count === 2) { const pairs = [groups[0].rank, groups[1].rank].sort((a, b) => b - a); const kicker = groups.find((group) => group.count === 1)?.rank || 0; return { category: 2, tiebreak: [...pairs, kicker], name: 'ํˆฌํŽ˜์–ด' }; } if (groups[0]?.count === 2) { const kickers = groups.filter((group) => group.count === 1).map((group) => group.rank).sort((a, b) => b - a); return { category: 1, tiebreak: [groups[0].rank, ...kickers], name: '์›ํŽ˜์–ด' }; } return { category: 0, tiebreak: ranks, name: 'ํ•˜์ด์นด๋“œ' }; } function compareClientHands(left, right) { if (left.category !== right.category) return left.category - right.category; const length = Math.max(left.tiebreak.length, right.tiebreak.length); for (let index = 0; index < length; index += 1) { const difference = (left.tiebreak[index] || 0) - (right.tiebreak[index] || 0); if (difference) return difference; } return 0; } function evaluateBestClient(cards) { if (!Array.isArray(cards) || cards.length < 5) return null; let best = null; for (let a = 0; a < cards.length - 4; a += 1) for (let b = a + 1; b < cards.length - 3; b += 1) for (let c = b + 1; c < cards.length - 2; c += 1) for (let d = c + 1; d < cards.length - 1; d += 1) for (let e = d + 1; e < cards.length; e += 1) { const evaluated = evaluateFiveClient([cards[a], cards[b], cards[c], cards[d], cards[e]]); if (!best || compareClientHands(evaluated, best) > 0) best = evaluated; } return best; } function renderMyHandAssist(payload = currentRoom()) { const guide = $('myHandGuide'); const guideName = $('myHandGuideName'); const dock = $('myCardPeekDock'); const button = $('myCardPeekBtn'); const panel = $('myCardPeekPanel'); const cardsNode = $('myCardPeekCards'); if (!guide || !dock || !button || !panel || !cardsNode) return; const me = viewerSeat(payload); const hole = Array.isArray(me?.cards) ? me.cards.filter(Boolean) : []; const board = Array.isArray(payload?.game?.board) ? payload.game.board.filter(Boolean) : []; const canEvaluate = Boolean(me && !me.folded && hole.length === 2 && board.length >= 3); if (canEvaluate) { const evaluated = evaluateBestClient([...hole, ...board]); guideName.textContent = evaluated?.name || 'ํ™•์ธ ์ค‘'; guide.classList.remove('hidden'); guide.dataset.category = String(evaluated?.category ?? 0); } else { guide.classList.add('hidden'); guideName.textContent = '-'; guide.removeAttribute('data-category'); } const isMyTurn = Boolean(me && !me.folded && hole.length === 2 && payload?.game?.active && Number(payload.game.actionIndex) === Number(me.seatIndex)); dock.classList.toggle('hidden', !isMyTurn); if (!isMyTurn) state.myCardPeekOpen = false; button.setAttribute('aria-expanded', state.myCardPeekOpen ? 'true' : 'false'); button.textContent = state.myCardPeekOpen ? '๋‚ด ์นด๋“œ ๋‹ซ๊ธฐ' : '๋‚ด ์นด๋“œ ๋ณด๊ธฐ'; panel.classList.toggle('hidden', !state.myCardPeekOpen || !isMyTurn); cardsNode.innerHTML = isMyTurn ? hole.map((card) => cardHtml(card, false, 'my-card-peek-face')).join('') : ''; } function actionBadge(action = '') { const text = String(action || '').trim(); if (!text) return { text: '', kind: 'none' }; if (text.startsWith('๋ ˆ์ด์ฆˆ')) return { text, kind: 'raise' }; if (text.startsWith('์ฝœ')) return { text, kind: 'call' }; if (text.startsWith('์ฒดํฌ')) return { text, kind: 'check' }; if (text.startsWith('ํด๋“œ')) return { text, kind: 'fold' }; if (text.startsWith('์˜ฌ์ธ')) return { text, kind: 'allin' }; if (text.startsWith('SB') || text.startsWith('BB')) return { text, kind: 'blind' }; return { text, kind: 'status' }; } function renderGame(payload, options = {}) { if (!payload || payload.room.id !== state.activeRoomId) return; if (payload.game.active && !payload.game.lastResult) clearWinFx(); const { room, game, seats, viewer, chat, meta } = payload; const mySeat = viewerSeat(payload); const myActualIndex = mySeat?.seatIndex ?? -1; const visualIndex = (actual) => myActualIndex >= 0 ? (actual - myActualIndex + 8) % 8 : actual; $('gameRoomName').textContent = room.name; $('gameBlinds').textContent = `์ตœ์†Œ ${fmt(room.buyIn)}P ยท ${fmt(room.smallBlind)} / ${fmt(room.bigBlind)}`; $('gameOccupancy').textContent = `${room.total || seats.filter(Boolean).length}/${room.maxPlayers || 8}๋ช…`; $('myBuyInBadge').classList.toggle('hidden', !mySeat); $('myBuyInBadge').textContent = mySeat ? `ํ…Œ์ด๋ธ” ๋ณด์œ  ${fmt(mySeat.stack || 0)}P` : 'ํ…Œ์ด๋ธ” ๋ณด์œ  0P'; state.tableCloseDeadline = Number(room.closesAt || 0) || null; $('spectatorBadge').classList.toggle('hidden', !viewer.isSpectator && !mySeat?.rebuyDeadline); $('spectatorBadge').textContent = viewer.pending ? `์˜ˆ์•ฝ ๋Œ€๊ธฐ ${viewer.reservationPosition || 1}๋ฒˆ` : mySeat?.rebuyDeadline ? '๋ฆฌ๋ฐ”์ด ๋Œ€๊ธฐ' : (viewer.isSpectator ? '๊ด€์ „ ์ค‘' : 'ํ”Œ๋ ˆ์ด ์ค‘'); $('quickSitBtn').classList.toggle('hidden', !viewer.isSpectator || viewer.pending); if (viewer.isSpectator && !viewer.pending) { $('quickSitBtn').textContent = seats.some((seat) => !seat) ? '๋น ๋ฅธ ์ฐฉ์„' : '์˜ˆ์•ฝ'; } $('rebuyBtn').classList.toggle('hidden', !mySeat?.rebuyDeadline); const exitIsReserved = Boolean(mySeat?.exitReserved || state.pendingExitRooms.has(room.id)); if (mySeat && !mySeat.exitReserved) state.pendingExitRooms.delete(room.id); $('exitRoomBtn').classList.remove('hidden'); $('exitRoomBtn').disabled = false; $('exitRoomBtn').textContent = exitIsReserved ? '๋‚˜๊ฐ€๊ธฐ ์ทจ์†Œ' : '๋ฐฉ ๋‚˜๊ฐ€๊ธฐ'; $('exitRoomBtn').classList.toggle('exit-cancel-mode', exitIsReserved); const exitQuote = mySeat?.earlyExitQuote; if ($('earlyExitNotice')) { const showPolicy = Boolean(mySeat && Number(exitQuote?.sessionHands || 0) < Number(exitQuote?.requiredHands || 50)); $('earlyExitNotice').classList.toggle('hidden', !showPolicy); $('earlyExitNotice').innerHTML = showPolicy ? `${Number(exitQuote.sessionHands || 0)}/50ํ•ธ๋“œ50ํ•ธ๋“œ ์ „์— ๋‚˜๊ฐ€๋ฉด ์ˆœ์ด์ต์˜ 20% ๊ณต์ œ${Number(exitQuote.fee || 0) > 0 ? ` ยท ํ˜„์žฌ ์˜ˆ์ƒ ${fmt(exitQuote.fee)}P` : ''}` : ''; } $('gamePhase').textContent = `HAND #${game.handNo} ยท ${phaseName(game.phase).toUpperCase()}`; $('gamePot').textContent = `POT ${fmt(game.pot)}P`; if ($('tableJackpot')) $('tableJackpot').innerHTML = `JACKPOT${fmt(state.jackpot?.balance || 10000000)}P`; const animateBoard = !options.suppressFx && ['board', 'board-card'].includes(meta?.animation); const animatedBoardIndex = Number.isInteger(Number(meta?.boardCardIndex)) ? Number(meta.boardCardIndex) : game.board.length - 1; $('boardCards').innerHTML = game.board.map((card, index) => cardHtml(card, false, animateBoard && index === animatedBoardIndex ? 'board-deal-card' : '', 0)).join(''); renderPotStack(game.pot, room.bigBlind, { stable: Boolean(game.dealing || meta?.animation === 'deal-card') }); $('gameMessage').textContent = game.dealing ? `์นด๋“œ๋ฅผ ํ•œ ์žฅ์”ฉ ๋‚˜๋ˆ„๋Š” ์ค‘โ€ฆ ${Math.min(game.dealPosition || 0, game.dealTotal || 0)}/${game.dealTotal || 0}` : game.revealAll && game.active ? 'ALL-IN SHOW ยท ๋ชจ๋“  ์ƒ์กด ํŒจ๋ฅผ ๊ณต๊ฐœํ•˜๊ณ  ๋ณด๋“œ๋ฅผ ์ง„ํ–‰ํ•ฉ๋‹ˆ๋‹ค.' : !game.active ? (viewer.isSpectator ? '๊ด€์ „ ์ค‘ ยท ์ขŒ์„์„ ์„ ํƒํ•ด ์ฐฉ์„ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.' : mySeat?.rebuyDeadline ? '2๋ถ„ ์•ˆ์— ๋ฆฌ๋ฐ”์ดํ•˜์ง€ ์•Š์œผ๋ฉด ์ž๋™ ํ‡ด์žฅํ•ฉ๋‹ˆ๋‹ค.' : '๋‹ค์Œ ํ•ธ๋“œ๋ฅผ ์ค€๋น„ํ•˜๋Š” ์ค‘') : game.actionIndex === mySeat?.seatIndex ? '๋‹น์‹ ์˜ ์ฐจ๋ก€์ž…๋‹ˆ๋‹ค. 20์ดˆ ์•ˆ์— ์„ ํƒํ•˜์„ธ์š”.' : `${seats[game.actionIndex]?.name || 'ํ”Œ๋ ˆ์ด์–ด'} ์ƒ๊ฐ ์ค‘โ€ฆ`; const generalWaitingPlayers = (payload.pending || []).filter((entry) => !Number.isInteger(entry.requestedSeat)).map((entry, index) => `${escapeHtml(entry.name || 'ํ”Œ๋ ˆ์ด์–ด')} ๋Œ€๊ธฐ ์ค‘${index ? ` ยท ${entry.position || index + 1}๋ฒˆ` : ''}`); $('waitingQueueNotice').classList.toggle('hidden', generalWaitingPlayers.length === 0); $('waitingQueueNotice').innerHTML = generalWaitingPlayers.length ? `๋Œ€๊ธฐ์„${generalWaitingPlayers.join(' ยท ')}` : ''; const pendingBySeat = new Map((payload.pending || []).filter((entry) => Number.isInteger(entry.requestedSeat)).map((entry) => [Number(entry.requestedSeat), entry])); const dealOrder = Array.from({ length: 8 }, (_, step) => (game.dealer + 1 + step + 8) % 8).filter((index) => seats[index]?.cardCount > 0); const dealPosition = new Map(dealOrder.map((seatIndex, position) => [seatIndex, position])); $('seatLayer').innerHTML = seats.map((seat, index) => { const slot = visualIndex(index); if (!seat) { const waiting = pendingBySeat.get(index); if (waiting) return `
${avatarHtml(waiting.avatar, 'pending-seat-image')}
${escapeHtml(waiting.name || 'ํ”Œ๋ ˆ์ด์–ด')}๋Œ€๊ธฐ ์ค‘
`; const canSit = viewer.isSpectator && !viewer.pending; return `
${index + 1}
${canSit ? `` : '๋นˆ ์ขŒ์„'}
`; } if (seat.waitingForNextHand) { return `
${avatarHtml(seat.avatar, 'pending-seat-image')}
${escapeHtml(seat.name || 'ํ”Œ๋ ˆ์ด์–ด')}๋Œ€๊ธฐ ์ค‘
`; } const isMe = seat.userId && seat.userId === state.user?.id; const cards = seat.cards?.length ? seat.cards.map((card, cardIndex) => { const isNewDealCard = !options.suppressFx && meta?.animation === 'deal-card' && Number(meta.dealSeat) === index && cardIndex === seat.cards.length - 1; const holeClass = `${isMe ? 'my-hole-card' : ''}${isNewDealCard ? ' deal-hole-card sequential-deal-card' : ''}`.trim(); return cardHtml(card, true, holeClass, 0); }).join('') : ''; const classes = ['seat', `seat${slot}`]; if (game.actionIndex === index && game.active) classes.push('active'); if (seat.folded) classes.push('folded'); if (!seat.connected && !seat.isBot) classes.push('disconnected'); if (game.lastResult?.winners?.some((winner) => winner.seatIndex === index)) classes.push('winner-glow'); if (state.emojiTargetSeatIndex === index) classes.push('emoji-selected'); if (index === myActualIndex) classes.push('my-seat'); const lastAction = actionBadge(seat.lastAction || seat.action || (seat.folded ? 'ํด๋“œ' : seat.rebuyDeadline ? '๋ฆฌ๋ฐ”์ด ๋Œ€๊ธฐ' : '')); const winnerRows = (game.lastResult?.winners || []).filter((winner) => Number(winner.seatIndex) === index); const winnerAmount = winnerRows.reduce((sum, winner) => sum + Number(winner.amount || 0), 0); const finalHand = game.lastResult?.hands?.[index]?.name || ''; const winnerHand = finalHand || winnerRows.map((winner) => winner.hand).filter(Boolean).at(-1) || ''; const inlineWin = !game.active && winnerAmount > 0 ? `
+${fmt(winnerAmount)}P${escapeHtml(winnerHand || '์Šน๋ฆฌ')}
` : !game.active && finalHand ? `
์ตœ์ข… ํŒจ${escapeHtml(finalHand)}
` : ''; const positionBadges = [ game.dealer === index ? 'D' : '', Number(game.smallBlindIndex) === index ? 'SB' : '', Number(game.bigBlindIndex) === index ? 'BB' : '' ].join(''); return `
${inlineWin}${!inlineWin && lastAction.text ? `
${escapeHtml(lastAction.text)}
` : ''}
${avatarHtml(seat.avatar, 'seat-avatar-image')}${seat.allIn ? 'ALL IN' : ''}
${escapeHtml(seat.name)}${seat.isProxy && !seat.exitReserved ? '์ž๋™' : ''}${seat.exitReserved ? '๋‚˜๊ฐ€๊ธฐ ์˜ˆ์•ฝ' : ''}${positionBadges}
ํ…Œ์ด๋ธ” ${fmt(seat.stack)}P
${Number(seat.bet || 0) > 0 ? `
${fmt(seat.bet)}P
` : ''} ${cards ? `` : ''} ${seat.rebuyDeadline ? `
02:00
` : ''}
`; }).join(''); const selectedTableSkin = state.user?.selectedSkin || 'classic'; $('pokerTable').className = `poker-table skin-${selectedTableSkin}`; $('pokerTable').setAttribute('style', skinInlineStyle(selectedTableSkin)); renderActionControls(); renderMyHandAssist(payload); renderChat(chat || []); renderSessionHistory(viewer.sessionHistory || []); renderEmojiBar(); startTurnTimer(game.actionDeadline, game.actionIndex); renderMyRoomTabs(); const gameVisible = !$('gameView').classList.contains('hidden') && room.id === state.activeRoomId; if (!options.suppressFx && gameVisible && meta?.animation === 'deal-card') playSound('card'); else if (!options.suppressFx && gameVisible && meta?.animation === 'board-card') playSound('board-card'); else if (!options.suppressFx && gameVisible && meta?.animation === 'board') playSound('card'); if (!options.suppressFx && gameVisible && meta?.animation === 'chips') { animateChips(meta.actorSeat, meta.amount); playSound('chips'); } if (!options.suppressFx && gameVisible && meta?.animation === 'collect-bets') { animateRoundBetsToPot(meta.roundBets || []); playSound('chips'); } if (!options.suppressFx && gameVisible && meta?.animation === 'bets-collected') { playSound('chips-collect'); } if (!options.suppressFx && gameVisible && ['check','call','raise','fold','allin'].includes(meta?.actionType)) speakAction(meta.actionType); if (game.lastResult) showResultIfNew(payload); } function renderPotStack(pot, bigBlind = 10, options = {}) { const stack = $('potChipStack'); if (!stack) return; const amount = Math.max(0, Number(pot || 0)); const normalized = amount / Math.max(1, Number(bigBlind || 10)); const chipCount = amount <= 0 ? 0 : Math.max(5, Math.min(42, 5 + Math.floor(Math.log2(normalized + 1) * 4.2))); const colors = ['red','blue','gold','black','green']; const towerCount = chipCount ? Math.min(6, Math.max(2, Math.ceil(chipCount / 7))) : 0; const signature = `${amount}:${Math.max(1, Number(bigBlind || 10))}:${chipCount}:${towerCount}`; stack.classList.toggle('stable-pot', Boolean(options.stable)); if (stack.dataset.signature === signature) return; stack.dataset.signature = signature; const towers = Array.from({length:towerCount},(_,tower)=>{ const count = Math.max(1, Math.ceil((chipCount-tower)/towerCount)); const chips = Array.from({length:count},(_,level)=>``).join(''); const offset = Math.abs(tower - (towerCount - 1) / 2) * 2; return `
${chips}
`; }).join(''); stack.innerHTML = `
${towers}
`; } function renderActionControls() { const payload = currentRoom(); if (!payload) return; const me = viewerSeat(payload); const isTurn = Boolean(me && payload.game.active && payload.game.actionIndex === me.seatIndex && !payload.viewer.isSpectator && !me.folded && !me.allIn && !me.exitReserved); const callAmount = me ? Math.max(0, payload.game.currentBet - me.bet) : 0; $('actionPanel').classList.toggle('hidden', !isTurn); if (!isTurn) { state.allInSelected = false; document.querySelectorAll('[data-pot]').forEach((button) => button.classList.remove('selected')); return; } const actionPending = Boolean(state.actionPending); $('foldBtn').disabled = actionPending; $('callBtn').disabled = actionPending; $('raiseBtn').disabled = actionPending || !me || me.stack <= callAmount; const minTo = Math.min(me.bet + me.stack, Math.max(payload.game.currentBet + payload.game.minRaise, payload.room.bigBlind)); const maxTo = me.bet + me.stack; $('raiseSlider').min = minTo; $('raiseSlider').max = Math.max(minTo, maxTo); $('raiseSlider').step = payload.room.bigBlind; if (Number($('raiseSlider').value) < minTo || Number($('raiseSlider').value) > maxTo) $('raiseSlider').value = minTo; const selectedRaiseTotal = state.allInSelected ? maxTo : Number($('raiseSlider').value); $('raiseValue').textContent = fmt(selectedRaiseTotal); const raiseLabel = state.allInSelected || selectedRaiseTotal >= maxTo ? 'All-in' : 'Raise'; const raiseText = $('raiseBtn').querySelector('span:last-child'); if (raiseText) raiseText.textContent = raiseLabel; const callText = $('callBtn').querySelector('span:last-child'); if (callText) callText.textContent = callAmount === 0 ? 'Check' : `Call ${fmt(Math.min(callAmount, me.stack))}`; $('actionWaitText').textContent = actionPending ? 'ํ–‰๋™ ์ฒ˜๋ฆฌ ์ค‘...' : state.allInSelected ? 'ALL-IN ๊ธˆ์•ก์ด ์„ ํƒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. Raise ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅด๋ฉด ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค.' : '๋ฒ ํŒ… ํฌ๊ธฐ๋ฅผ ์„ ํƒํ•œ ๋’ค ํ–‰๋™ํ•˜์„ธ์š”.'; document.querySelector('[data-pot="allin"]')?.classList.toggle('selected', state.allInSelected); document.querySelectorAll('[data-pot]').forEach((button) => { button.disabled = actionPending; }); } function startTurnTimer(deadline, actionIndex = -1) { clearInterval(state.timerInterval); state.activeTimerSeat = actionIndex; const render = () => { const seconds = deadline ? Math.max(0, Math.ceil((deadline - Date.now()) / 1000)) : 0; const centralTimer = $('turnTimer'); centralTimer.textContent = deadline ? `${seconds}s` : '--'; centralTimer.classList.toggle('active', Boolean(deadline)); centralTimer.classList.toggle('urgent', Boolean(deadline) && seconds <= 5); centralTimer.setAttribute('aria-hidden', deadline ? 'false' : 'true'); if ($('tableOpenCountdown')) { const leftMs = Math.max(0, Number(state.tableCloseDeadline || 0) - Date.now()); const total = Math.ceil(leftMs / 1000); const hh = String(Math.floor(total / 3600)).padStart(2, '0'); const mm = String(Math.floor((total % 3600) / 60)).padStart(2, '0'); const ss = String(total % 60).padStart(2, '0'); $('tableOpenCountdown').textContent = `${hh}:${mm}:${ss}`; $('tableOpenCountdown').classList.toggle('urgent', total <= 600); } document.querySelectorAll('[data-rebuy-deadline]').forEach((node) => { const left = Math.max(0, Math.ceil((Number(node.dataset.rebuyDeadline) - Date.now()) / 1000)); node.textContent = `${String(Math.floor(left / 60)).padStart(2, '0')}:${String(left % 60).padStart(2, '0')}`; if (node.closest('.my-seat')) $('rebuyCountdown').textContent = node.textContent; }); if (deadline && seconds <= 0) clearInterval(state.timerInterval); }; render(); state.timerInterval = setInterval(render, 250); } function renderChat(rows) { $('chatList').innerHTML = rows.map((row) => row.type === 'emoji' ? `
${avatarHtml(row.avatar, 'chat-avatar')} ${escapeHtml(row.name)}${row.text}โ†’ ${escapeHtml(row.targetName || 'ํ…Œ์ด๋ธ”')}${row.cost ? ` ยท ${row.cost}P` : ''}
` : `
${avatarHtml(row.avatar, 'chat-avatar')} ${escapeHtml(row.name)}
${escapeHtml(row.text)}
`).join(''); $('chatList').scrollTop = $('chatList').scrollHeight; } function renderSessionHistory(rows) { $('sessionHistory').innerHTML = rows.map((row) => `
Hand #${row.handNo} ยท ${escapeHtml(row.result)}${escapeHtml(row.relation || '์ƒ๋Œ€ ๊ฒฐ๊ณผ ๊ธฐ๋ก ์—†์Œ')}${new Date(row.at).toLocaleTimeString('ko-KR')} ยท ${escapeHtml(row.hand)}
${Number(row.net) >= 0 ? '+' : ''}${fmt(row.net)}P
๋‚ด ์นด๋“œ
${(row.cards || []).map((card) => cardHtml(card, true)).join('')}
${historyBoardHtml(row.board || [], true)}${(row.revealedOpponents || []).length ? `
์˜คํ”ˆ๋œ ์ƒ๋Œ€ ์นด๋“œ${row.revealedOpponents.map((opponent) => `
${escapeHtml(opponent.name)} ยท ${escapeHtml(opponent.hand || '')}
${(opponent.cards || []).map((card) => cardHtml(card, true)).join('')}
`).join('')}
` : ''}
`).join('') || '
์ด ๋ฐฉ์—์„œ ์™„๋ฃŒํ•œ ๊ฒŒ์ž„์ด ์•„์ง ์—†์Šต๋‹ˆ๋‹ค.
'; } function chipLabelsForAmount(amount = 0) { return amount >= 10000 ? ['1K','5K','1K'] : amount >= 1000 ? ['500','1K','500'] : ['10','50','100']; } function animateChips(actorSeat, amount = 0) { const layer = $('chipAnimationLayer'); const seat = document.querySelector(`[data-seat-index="${actorSeat}"]`); if (!layer || !seat) return; const tableRect = $('pokerTable').getBoundingClientRect(); const seatRect = seat.getBoundingClientRect(); const targetNode = seat.querySelector('.seat-chip-image') || seat.querySelector('[data-round-bet-seat]') || seat.querySelector('.seat-bet-info:not(.seat-bet-empty)'); const targetRect = targetNode?.getBoundingClientRect?.(); const sx = seatRect.left - tableRect.left + seatRect.width / 2; const sy = seatRect.top - tableRect.top + seatRect.height * .48; const tx = targetRect ? targetRect.left - tableRect.left + targetRect.width / 2 : sx; const ty = targetRect ? targetRect.top - tableRect.top + targetRect.height / 2 : seatRect.top - tableRect.top - 16; const labels = chipLabelsForAmount(amount); for (let i = 0; i < 7; i += 1) { const chip = document.createElement('div'); chip.className = `flying-chip seat-bet-flying-chip chip-${i % 3}`; chip.textContent = labels[i % labels.length]; chip.style.setProperty('--sx', `${sx + (i - 3) * 5}px`); chip.style.setProperty('--sy', `${sy + (i % 2) * 4}px`); chip.style.setProperty('--tx', `${tx + (i - 3) * 3}px`); chip.style.setProperty('--ty', `${ty + (i % 2) * 3}px`); chip.style.animationDelay = `${i * 32}ms`; layer.appendChild(chip); setTimeout(() => chip.remove(), 950); } } function animateRoundBetsToPot(roundBets = []) { const layer = $('chipAnimationLayer'); const table = $('pokerTable'); if (!layer || !table) return; const tableRect = table.getBoundingClientRect(); const tx = tableRect.width / 2; const ty = tableRect.height * .47; roundBets.forEach((amount, seatIndex) => { if (Number(amount || 0) <= 0) return; const seat = document.querySelector(`[data-seat-index="${seatIndex}"]`); const marker = seat?.querySelector('.seat-chip-image') || document.querySelector(`[data-round-bet-seat="${seatIndex}"]`) || seat?.querySelector('.seat-bet-info:not(.seat-bet-empty)'); const node = marker || seat; if (!node) return; const rect = node.getBoundingClientRect(); const sx = rect.left - tableRect.left + rect.width / 2; const sy = rect.top - tableRect.top + rect.height / 2; const labels = chipLabelsForAmount(amount); for (let index = 0; index < 8; index += 1) { const chip = document.createElement('div'); chip.className = `flying-chip round-to-pot-chip chip-${index % 3}`; chip.textContent = labels[index % labels.length]; chip.style.setProperty('--sx', `${sx + ((index % 4) - 1.5) * 5}px`); chip.style.setProperty('--sy', `${sy + (index % 2) * 4}px`); chip.style.setProperty('--tx', `${tx + ((index % 5) - 2) * 5}px`); chip.style.setProperty('--ty', `${ty + (index % 3) * 4}px`); chip.style.animationDelay = `${seatIndex * 24 + index * 28}ms`; layer.appendChild(chip); setTimeout(() => chip.remove(), 1050); } marker?.classList.add('collecting'); }); } function playEmoteSound(effect, stage = 'launch') { if (!state.sound || !effect || effect === 'float') return; try { const AudioContext = window.AudioContext || window.webkitAudioContext; const ctx = new AudioContext(); const now = ctx.currentTime; const master = ctx.createGain(); master.gain.value = .72; master.connect(ctx.destination); const tone = (frequency, start, duration, type = 'sine', gainValue = .12, endFrequency = null) => { const oscillator = ctx.createOscillator(); const gain = ctx.createGain(); oscillator.type = type; oscillator.frequency.setValueAtTime(frequency, now + start); if (endFrequency) oscillator.frequency.exponentialRampToValueAtTime(Math.max(25, endFrequency), now + start + duration); gain.gain.setValueAtTime(gainValue, now + start); gain.gain.exponentialRampToValueAtTime(.001, now + start + duration); oscillator.connect(gain).connect(master); oscillator.start(now + start); oscillator.stop(now + start + duration); }; const noise = (start, duration, gainValue, frequency, type = 'bandpass') => { const length = Math.max(1, Math.floor(ctx.sampleRate * duration)); const buffer = ctx.createBuffer(1, length, ctx.sampleRate); const samples = buffer.getChannelData(0); for (let index = 0; index < length; index += 1) { const envelope = Math.pow(1 - index / length, 2.4); samples[index] = (Math.random() * 2 - 1) * envelope; } const source = ctx.createBufferSource(); const filter = ctx.createBiquadFilter(); const gain = ctx.createGain(); filter.type = type; filter.frequency.value = frequency; filter.Q.value = .8; gain.gain.setValueAtTime(gainValue, now + start); gain.gain.exponentialRampToValueAtTime(.001, now + start + duration); source.buffer = buffer; source.connect(filter).connect(gain).connect(master); source.start(now + start); }; if (effect === 'gun') { if (stage === 'launch') { noise(0, .075, .30, 2100, 'highpass'); tone(125, 0, .12, 'square', .15, 58); tone(950, .012, .055, 'sawtooth', .08, 340); } else { tone(155, 0, .11, 'sine', .09, 70); noise(.01, .09, .12, 720); } } else if (effect === 'egg') { if (stage === 'launch') { noise(0, .24, .08, 900, 'highpass'); tone(520, 0, .22, 'sine', .04, 170); } else { noise(0, .16, .24, 430, 'lowpass'); tone(145, .01, .18, 'triangle', .12, 70); tone(690, .02, .07, 'sine', .04, 280); } } else if (effect === 'tomato') { if (stage === 'launch') { noise(0, .20, .07, 780, 'bandpass'); tone(380, 0, .18, 'sine', .04, 135); } else { noise(0, .22, .28, 300, 'lowpass'); tone(105, 0, .20, 'triangle', .15, 48); tone(240, .035, .12, 'sine', .06, 90); } } else if (effect === 'kiss') { if (stage === 'launch') { tone(660, 0, .07, 'sine', .10, 910); tone(980, .07, .10, 'sine', .09, 1320); } else { tone(880, 0, .12, 'sine', .08, 1180); tone(1320, .10, .18, 'sine', .07, 1760); } } else if (effect === 'lightning') { if (stage === 'launch') { noise(0, .16, .24, 3500, 'highpass'); tone(1280, 0, .16, 'sawtooth', .11, 180); tone(2200, .035, .08, 'square', .06, 420); } else { noise(0, .21, .25, 2400, 'bandpass'); tone(520, 0, .22, 'sawtooth', .13, 65); } } setTimeout(() => ctx.close(), 750); } catch { /* autoplay or browser audio restriction */ } } function showFloatingEmoji(data) { if (data.roomId !== state.activeRoomId) return; const layer = $('emojiLayer'); const table = $('pokerTable'); const tableRect = table.getBoundingClientRect(); const source = data.sourceSeatIndex >= 0 ? document.querySelector(`[data-seat-index="${data.sourceSeatIndex}"]`) : null; const target = data.targetSeatIndex >= 0 ? document.querySelector(`[data-seat-index="${data.targetSeatIndex}"]`) : null; const point = (node, fallbackX, fallbackY) => { if (!node) return { x: fallbackX, y: fallbackY }; const rect = node.getBoundingClientRect(); return { x: rect.left - tableRect.left + rect.width / 2, y: rect.top - tableRect.top + rect.height / 2 }; }; const from = point(source, tableRect.width / 2, tableRect.height * .82); const to = point(target, tableRect.width / 2, tableRect.height * .45); if (data.effect === 'float') { const node = document.createElement('div'); node.className = 'floating-emoji free-reaction'; node.textContent = data.emoji; node.style.left = `${to.x}px`; node.style.top = `${to.y}px`; layer.appendChild(node); setTimeout(() => node.remove(), 2300); return; } playEmoteSound(data.effect, 'launch'); const projectile = document.createElement('div'); projectile.className = `emote-projectile effect-${data.effect}`; projectile.textContent = data.emoji; projectile.style.setProperty('--sx', `${from.x}px`); projectile.style.setProperty('--sy', `${from.y}px`); projectile.style.setProperty('--tx', `${to.x}px`); projectile.style.setProperty('--ty', `${to.y}px`); projectile.style.setProperty('--mx', `${(from.x + to.x) / 2}px`); projectile.style.setProperty('--my', `${(from.y + to.y) / 2 - 110}px`); layer.appendChild(projectile); if (data.effect === 'gun') { const bullet = document.createElement('div'); bullet.className = 'emote-projectile bullet'; bullet.textContent = '๐Ÿ’ฆ'; bullet.style.cssText = projectile.style.cssText; layer.appendChild(bullet); setTimeout(() => bullet.remove(), 900); } setTimeout(() => { playEmoteSound(data.effect, 'impact'); const hit = document.createElement('div'); hit.className = `emote-hit hit-${data.effect}`; hit.textContent = ({ gun: '๐Ÿ’ฅ', egg: '๐Ÿณ', tomato: '๐Ÿ’ข', kiss: '๐Ÿ’•', lightning: 'โšก' })[data.effect] || 'โœจ'; hit.style.left = `${to.x}px`; hit.style.top = `${to.y}px`; layer.appendChild(hit); setTimeout(() => hit.remove(), 1500); }, 650); setTimeout(() => projectile.remove(), 1100); } function clearWinFx() { (state.winFxTimers || []).forEach((timer) => clearTimeout(timer)); state.winFxTimers = []; document.querySelectorAll('.pot-to-winner-chip,.winner-money-fx,.winner-hand-fx').forEach((node) => node.remove()); document.querySelectorAll('.seat.win-award-pulse').forEach((node) => node.classList.remove('win-award-pulse')); $('potChipStack')?.classList.remove('pot-awarding'); } function animatePotToWinner(winner, order = 0) { const layer = $('chipAnimationLayer'); const table = $('pokerTable'); const seat = document.querySelector(`[data-seat-index="${winner.seatIndex}"]`); if (!layer || !table || !seat) return; const tableRect = table.getBoundingClientRect(); const seatRect = seat.getBoundingClientRect(); const sx = tableRect.width / 2; const sy = tableRect.height * .47; const tx = seatRect.left - tableRect.left + seatRect.width / 2; const ty = seatRect.top - tableRect.top + seatRect.height / 2; const delayBase = order * 260; $('potChipStack')?.classList.add('pot-awarding'); seat.classList.add('win-award-pulse'); for (let index = 0; index < 12; index += 1) { const chip = document.createElement('i'); chip.className = `pot-to-winner-chip chip-${index % 3}`; chip.style.setProperty('--sx', `${sx + ((index % 5) - 2) * 8}px`); chip.style.setProperty('--sy', `${sy + (index % 3) * 5}px`); chip.style.setProperty('--tx', `${tx + ((index % 4) - 1.5) * 7}px`); chip.style.setProperty('--ty', `${ty + (index % 3) * 5}px`); chip.style.animationDelay = `${delayBase + index * 32}ms`; layer.appendChild(chip); state.winFxTimers.push(setTimeout(() => chip.remove(), delayBase + 1450)); } const money = document.createElement('div'); money.className = 'winner-money-fx'; money.textContent = `ํ…Œ์ด๋ธ” ๋จธ๋‹ˆ +${fmt(winner.amount)}P`; money.style.left = `${tx}px`; money.style.top = `${ty}px`; money.style.animationDelay = `${delayBase + 330}ms`; layer.appendChild(money); const hand = document.createElement('div'); hand.className = 'winner-hand-fx'; hand.textContent = `${winner.hand || '์Šน๋ฆฌ'} ์Šน๋ฆฌ`; hand.style.left = `${tx}px`; hand.style.top = `${ty}px`; hand.style.animationDelay = `${delayBase + 400}ms`; layer.appendChild(hand); state.winFxTimers.push(setTimeout(() => playSound('pot-award'), delayBase + 560)); state.winFxTimers.push(setTimeout(() => money.remove(), delayBase + 3100)); state.winFxTimers.push(setTimeout(() => hand.remove(), delayBase + 3300)); state.winFxTimers.push(setTimeout(() => seat.classList.remove('win-award-pulse'), delayBase + 3400)); state.winFxTimers.push(setTimeout(() => $('potChipStack')?.classList.remove('pot-awarding'), delayBase + 1450)); } function showResultIfNew(payload) { const { room, game } = payload; const result = game.lastResult; const key = `${room.id}-${game.handNo}`; if (!result || state.resultKeys.has(key)) return; state.resultKeys.add(key); const gameVisible = !$('gameView').classList.contains('hidden') && room.id === state.activeRoomId; if (!gameVisible) return; clearWinFx(); const grouped = new Map(); (result.winners || []).forEach((winner) => { const seatIndex = Number(winner.seatIndex); const current = grouped.get(seatIndex) || { seatIndex, name: winner.name, amount: 0, hand: winner.hand || '์Šน๋ฆฌ' }; current.amount += Number(winner.amount || 0); if (winner.hand) current.hand = winner.hand; grouped.set(seatIndex, current); }); [...grouped.values()].forEach((winner, index) => animatePotToWinner(winner, index)); if (result.jackpotClaims?.length) { const names = result.jackpotClaims.map((claim) => `${claim.name} ${claim.handName} +${fmt(claim.amount)}P`).join(' ยท '); state.winFxTimers.push(setTimeout(() => toast(`๐ŸŽฐ ์žญํŒŸ ${names}`, 'success'), 900)); } } function notifyTurnIfNeeded(payload) { const me = viewerSeat(payload); if (!me || !payload.game?.active || payload.game.actionIndex !== me.seatIndex || me.folded || me.allIn) return; const key = `${payload.room.id}-${payload.game.handNo}-${payload.game.actionIndex}`; if (state.turnAlertKeys.has(key)) return; state.turnAlertKeys.add(key); playSound('turn'); renderMyRoomTabs(); const holeText = (me.cards || []).filter(Boolean).map(compactCardLabel).join(' '); const gameVisible = payload.room.id === state.activeRoomId && !$('gameView').classList.contains('hidden'); if (!gameVisible) toast(`${payload.room.name} ยท ๋‚ด ์ฐจ๋ก€ ยท ๋‚ด ์นด๋“œ ${holeText || '๋ฐฐ๋ถ„ ์ค‘'}`, 'success'); if (gameVisible) { clearTimeout(state.turnPulseTimer); $('pokerTable').classList.add('my-turn-pulse'); state.turnPulseTimer = setTimeout(() => $('pokerTable').classList.remove('my-turn-pulse'), 2600); } } function emoteRemainingMs() { return Math.max(0, Number(state.emoteReadyAt || 0) - Date.now()); } function beginEmoteCooldown(ms = 1000) { state.emoteReadyAt = Date.now() + Math.max(0, Number(ms || 1000)); clearTimeout(state.emoteCooldownTimer); renderEmojiBar(); state.emoteCooldownTimer = setTimeout(() => { state.emoteReadyAt = 0; renderEmojiBar(); if ($('playerDialog')?.open && Number.isInteger(state.emojiTargetSeatIndex)) openPlayerDialog(state.emojiTargetSeatIndex); }, Math.max(0, Number(ms || 1000)) + 30); } function sendEmote(emoteId, targetSeatIndex) { const emote = EMOTES.find((item) => item.id === emoteId); if (emoteRemainingMs() > 0) return toast('์ด๋ชจํ‹ฐ์ฝ˜์€ 1์ดˆ ํ›„ ๋‹ค์‹œ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.', 'info'); socket.emit('emoji:send', { roomId: state.activeRoomId, emoteId, targetSeatIndex }, (result) => { if (result?.ok) beginEmoteCooldown(1000); if (!result?.ok && result?.cooldownMs) beginEmoteCooldown(result.cooldownMs); toast(result?.message || (result?.ok ? '์ด๋ชจํ‹ฐ์ฝ˜์„ ๋ณด๋ƒˆ์Šต๋‹ˆ๋‹ค.' : '์ด๋ชจํ‹ฐ์ฝ˜ ์ „์†ก ์‹คํŒจ'), result?.ok ? 'success' : 'error'); }); } function renderEmojiBar() { const payload = currentRoom(); const target = Number.isInteger(state.emojiTargetSeatIndex) ? payload?.seats?.[state.emojiTargetSeatIndex] : null; $('emojiTargetLabel').innerHTML = target ? `${avatarHtml(target.avatar, 'target-avatar')} ${escapeHtml(target.name)}` : 'ํ…Œ์ด๋ธ” ์ „์ฒด'; const cooling = emoteRemainingMs() > 0; $('emojiBar').innerHTML = EMOTES.map((emote) => ``).join(''); } function showReplay(id) { const row = state.history.find((entry) => entry.id === id); if (!row) return; $('replayContent').innerHTML = `

${escapeHtml(row.roomName)} ยท Hand #${row.handNo}

${new Date(row.createdAt).toLocaleString('ko-KR')} ยท ๋ธ”๋ผ์ธ๋“œ ${escapeHtml(row.blinds)}

ํ…Œ์ด๋ธ” ์˜คํ”ˆ ์นด๋“œ

${historyBoardHtml(row.board || [])}

ํ”Œ๋ ˆ์ด์–ด

${(row.players || []).map((player) => `
${avatarHtml(player.avatar, 'replay-avatar')} ${escapeHtml(player.name)}${player.userId === state.user?.id ? ' ๋‚˜' : ''}
${replayPlayerCards(row, player)}์‹œ์ž‘ ${fmt(player.startStack)}P โ†’ ์ข…๋ฃŒ ${fmt(player.finalStack)}P ยท ${Number(player.net || 0) >= 0 ? '+' : ''}${fmt(player.net || 0)}P${player.folded ? ' ยท ํด๋“œ' : ''}
`).join('')}

์•ก์…˜ ๋กœ๊ทธ

${(row.actions || []).map((action) => `
${escapeHtml(action.text)}
`).join('')}
`; $('replayDialog').showModal(); } function openBuyInDialog({ roomId, seatIndex = null, mode = 'sit' }) { const livePayload = state.roomStates[roomId]; const room = livePayload?.room || state.rooms.find((entry) => entry.id === roomId); if (!room || !state.user) return toast('๋ฐฉ ์ •๋ณด๋ฅผ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.', 'error'); const minimum = Number(room.buyIn || 500); const maximum = Math.max(minimum, Math.min(Number(state.user.chips || 0), minimum * 20)); if (Number(state.user.chips || 0) < minimum) return toast(`์ตœ์†Œ ${fmt(minimum)}P๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.`, 'error'); state.pendingBuyIn = { roomId, seatIndex, mode, minimum, maximum }; $('buyInTitle').textContent = mode === 'rebuy' ? '๋ฆฌ๋ฐ”์ด ํฌ์ธํŠธ ์„ ํƒ' : mode === 'reserve' ? '์˜ˆ์•ฝ ๋ฐ”์ด์ธ ์„ ํƒ' : '์ฐฉ์„ ๋ฐ”์ด์ธ ์„ ํƒ'; $('buyInWallet').textContent = `${fmt(state.user.chips)}P`; $('buyInMinimum').textContent = `${fmt(minimum)}P`; $('buyInAmount').min = minimum; $('buyInAmount').max = maximum; $('buyInAmount').step = room.bigBlind; $('buyInRange').min = minimum; $('buyInRange').max = maximum; $('buyInRange').step = room.bigBlind; const initial = Math.min(maximum, mode === 'rebuy' ? Number(viewerSeat(livePayload)?.buyIn || minimum) : minimum); $('buyInAmount').value = initial; $('buyInRange').value = initial; $('buyInDialog').showModal(); } function openPlayerDialog(seatIndex) { const payload = currentRoom(); const seat = payload?.seats?.[seatIndex]; if (!seat) return; state.emojiTargetSeatIndex = seatIndex; renderEmojiBar(); const stats = seat.stats || {}; const cooling = emoteRemainingMs() > 0; $('playerDialogContent').innerHTML = `
${avatarHtml(seat.avatar, 'player-popup-avatar')}

${escapeHtml(seat.name)}

${fmt(stats.hands)}ํ•ธ๋“œ ยท ${fmt(stats.wins)}์Šน ยท ์Šน๋ฅ  ${stats.winRate || 0}%

๋ˆ„์  ์†์ต ${Number(stats.net || 0) >= 0 ? '+' : ''}${fmt(stats.net || 0)}P

์ด๋ชจํ‹ฐ์ฝ˜ ๋ณด๋‚ด๊ธฐ

${EMOTES.map((emote) => ``).join('')}
`; if (!$('playerDialog').open) $('playerDialog').showModal(); } function emitWithToast(event, payload, successRefresh = true) { socket.emit(event, payload, (result) => { toast(result?.message || (result?.ok ? '์ฒ˜๋ฆฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.' : '์ฒ˜๋ฆฌํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.'), result?.ok ? 'success' : 'error'); if (result?.ok && successRefresh) socket.emit('bootstrap:refresh', () => {}); }); } renderLoginAvatars(); $('soundBtn').textContent = state.sound ? '๐Ÿ”Š' : '๐Ÿ”‡'; function collapseHoleCards() { clearTimeout(state.holeCollapseTimer); state.holeCollapseTimer = null; if (state.expandedHoleSeat === null) return; state.expandedHoleSeat = null; const payload = currentRoom(); if (payload) renderGame(payload, { suppressFx: true }); } function toggleHoleCards(seatIndex) { clearTimeout(state.holeCollapseTimer); const wasExpanded = state.expandedHoleSeat === seatIndex; state.expandedHoleSeat = wasExpanded ? null : seatIndex; const payload = currentRoom(); if (payload) renderGame(payload, { suppressFx: true }); if (!wasExpanded) { state.holeCollapseTimer = setTimeout(() => { if (state.expandedHoleSeat === seatIndex) collapseHoleCards(); }, 2800); } } function setSidePanel(name) { document.querySelectorAll('[data-side]').forEach((button) => button.classList.toggle('active', button.dataset.side === name)); document.querySelectorAll('.side-panel').forEach((panel) => panel.classList.toggle('active', panel.id === `side-${name}`)); } function openMobileChat() { state.sideDrawerOpen = true; $('gameSideDrawer').classList.remove('collapsed'); $('sideDrawerToggle').setAttribute('aria-expanded', 'true'); $('sideDrawerToggle').querySelector('span').textContent = 'โ–ถ'; setSidePanel('chat'); } function syncMobileKeyboardOffset() { const viewport = window.visualViewport; if (!viewport) return; const offset = Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop); document.documentElement.style.setProperty('--keyboard-offset', `${Math.round(offset)}px`); } function setMobileNav(open) { state.mobileNavOpen = Boolean(open); $('lobbySidebar')?.classList.toggle('mobile-open', state.mobileNavOpen); const backdrop = $('mobileNavBackdrop'); if (backdrop) backdrop.hidden = !state.mobileNavOpen; $('mobileNavToggle')?.setAttribute('aria-expanded', state.mobileNavOpen ? 'true' : 'false'); document.body.classList.toggle('mobile-nav-open', state.mobileNavOpen); } document.addEventListener('click', (event) => { const loginAvatar = event.target.closest('[data-login-avatar]'); if (loginAvatar) { state.selectedLoginAvatar = loginAvatar.dataset.loginAvatar; renderLoginAvatars(); return; } const tab = event.target.closest('[data-tab]'); if (tab && !tab.classList.contains('brand')) { switchTab(tab.dataset.tab); setMobileNav(false); return; } const brand = event.target.closest('.brand[data-tab]'); if (brand) { showLobby(); switchTab('rooms'); setMobileNav(false); return; } const myRoom = event.target.closest('[data-my-room]'); if (myRoom) { openRoom(myRoom.dataset.myRoom); return; } const quick = event.target.closest('[data-quick]'); if (quick) { socket.emit('room:quickJoin', { type: quick.dataset.quick }, (result) => result?.ok ? openRoom(result.roomId) : toast(result?.message || '๋น ๋ฅธ ์ž…์žฅ ์‹คํŒจ', 'error')); return; } const openJoined = event.target.closest('[data-open-joined]'); if (openJoined) { openRoom(openJoined.dataset.openJoined); return; } const join = event.target.closest('[data-join-room]'); if (join) { joinRoom(join.dataset.joinRoom); return; } const reserve = event.target.closest('[data-reserve-room]'); if (reserve) { openBuyInDialog({ roomId: reserve.dataset.reserveRoom, mode: 'reserve' }); return; } const watch = event.target.closest('[data-watch-room]'); if (watch) { watchRoom(watch.dataset.watchRoom); return; } const noticeOpen = event.target.closest('[data-notice-open]'); if (noticeOpen) { openNoticeDetail(noticeOpen.dataset.noticeOpen); return; } const tournamentRoomEnter = event.target.closest('[data-tournament-room-enter]'); if (tournamentRoomEnter) { joinRoom(tournamentRoomEnter.dataset.tournamentRoomEnter); return; } const tournament = event.target.closest('[data-tournament]'); if (tournament) { emitWithToast('tournament:join', { tournamentId: tournament.dataset.tournament }); return; } const mission = event.target.closest('[data-mission]'); if (mission) { emitWithToast('mission:claim', { missionId: mission.dataset.mission }); return; } const signup = event.target.closest('[data-signup-reward]'); if (signup) { emitWithToast('signup:claim', { rewardId: signup.dataset.signupReward }); return; } const weekly = event.target.closest('[data-weekly-reward]'); if (weekly) { emitWithToast('weekly:claim', { rewardId: weekly.dataset.weeklyReward }); return; } const shop = event.target.closest('[data-shop-item]'); if (shop) { emitWithToast('shop:buy', { itemId: shop.dataset.shopItem }); return; } const shopFilter = event.target.closest('[data-shop-filter]'); if (shopFilter) { state.shopFilter = shopFilter.dataset.shopFilter; renderShop(); return; } const historyFilter = event.target.closest('[data-history-filter]'); if (historyFilter) { state.historyFilter = historyFilter.dataset.historyFilter; renderProfile(); return; } const guild = event.target.closest('[data-guild-join]'); if (guild) { emitWithToast('guild:join', { guildId: guild.dataset.guildJoin }); return; } if (event.target.closest('[data-guild-checkin]')) { emitWithToast('guild:checkin', {}); return; } const donate = event.target.closest('[data-guild-donate]'); if (donate) { emitWithToast('guild:donate', { amount: Number(donate.dataset.guildDonate) }); return; } if (event.target.closest('[data-guild-save]')) { emitWithToast('guild:update', { description: $('guildDescriptionInput')?.value || '', notice: $('guildNoticeInput')?.value || '' }); return; } if (event.target.closest('[data-guild-leave]')) { if (confirm('๊ธธ๋“œ์—์„œ ํƒˆํ‡ดํ• ๊นŒ์š”? ํƒˆํ‡ด ํ›„ 24์‹œ๊ฐ„ ๋™์•ˆ ๋‹ค๋ฅธ ๊ธธ๋“œ์— ์žฌ๊ฐ€์ž…ํ•˜๊ฑฐ๋‚˜ ์ƒˆ ๊ธธ๋“œ๋ฅผ ๋งŒ๋“ค ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.')) emitWithToast('guild:leave', {}); return; } const avatar = event.target.closest('[data-select-avatar]'); if (avatar) { emitWithToast('cosmetic:select', { type: 'avatar', value: avatar.dataset.selectAvatar }); return; } const skin = event.target.closest('[data-select-skin]'); if (skin) { emitWithToast('cosmetic:select', { type: 'skin', value: skin.dataset.selectSkin }); return; } const cardSkin = event.target.closest('[data-select-card-skin]'); if (cardSkin) { emitWithToast('cosmetic:select', { type: 'cardSkin', value: cardSkin.dataset.selectCardSkin }); return; } const replay = event.target.closest('[data-replay-id]'); if (replay) { showReplay(replay.dataset.replayId); return; } const sitSeat = event.target.closest('[data-sit-seat]'); if (sitSeat) { openBuyInDialog({ roomId: state.activeRoomId, seatIndex: Number(sitSeat.dataset.sitSeat), mode: 'sit' }); return; } const holeCards = event.target.closest('[data-hole-seat]'); if (holeCards) { event.preventDefault(); event.stopPropagation(); toggleHoleCards(Number(holeCards.dataset.holeSeat)); return; } const playerSeat = event.target.closest('[data-player-seat]'); if (playerSeat) { const index = Number(playerSeat.dataset.playerSeat); const seat = currentRoom()?.seats?.[index]; if (seat?.userId !== state.user?.id) openPlayerDialog(index); return; } const popupEmote = event.target.closest('[data-popup-emote]'); if (popupEmote) { sendEmote(popupEmote.dataset.popupEmote, state.emojiTargetSeatIndex); return; } const skinPreview = event.target.closest('[data-skin-preview]'); if (skinPreview) { const skin = skinPreview.dataset.skinPreview; const name = skinPreview.dataset.skinName || 'ํ…Œ์ด๋ธ” ์Šคํ‚จ'; const theme = skinTheme(skin); $('skinPreviewContent').innerHTML = `
LIVE PREVIEW

${escapeHtml(name)}

${theme.sigil}
POT 88,888P
Aโ™  Kโ™ฅ Qโ™ฆ Jโ™ฃ 10โ™ 
${escapeHtml(name)} ์‹ค์ œ ์ƒ‰์ƒยท๋ ˆ์ผยท๋ฌธ์–‘ ๋ฏธ๋ฆฌ๋ณด๊ธฐ
`; $('skinPreviewDialog').showModal(); return; } const cardSkinPreview = event.target.closest('[data-card-skin-preview]'); if (cardSkinPreview) { const skin = cardSkinPreview.dataset.cardSkinPreview || 'classic-card'; const name = cardSkinPreview.dataset.cardSkinName || '์นด๋“œ ์Šคํ‚จ'; $('skinPreviewContent').innerHTML = `
CARD BACK PREVIEW

${escapeHtml(name)}

โ™ 
โ™ 
${escapeHtml(name)}๊ฒŒ์ž„ ์ค‘ ์ƒ๋Œ€ ์นด๋“œ ๋’ท๋ฉด๊ณผ ๋”œ๋ง ์• ๋‹ˆ๋ฉ”์ด์…˜์— ์ ์šฉ๋ฉ๋‹ˆ๋‹ค.
`; $('skinPreviewDialog').showModal(); return; } const jackpotClaim = event.target.closest('[data-jackpot-claim]'); if (jackpotClaim) { emitWithToast('jackpot:claim', { claimId: jackpotClaim.dataset.jackpotClaim }); return; } const targetSeat = event.target.closest('[data-emote-target-seat]'); if (targetSeat) { state.emojiTargetSeatIndex = Number(targetSeat.dataset.emoteTargetSeat); renderGame(currentRoom()); toast('์ด๋ชจํ‹ฐ์ฝ˜ ๋ฐ›์„ ์ƒ๋Œ€๋ฅผ ์„ ํƒํ–ˆ์Šต๋‹ˆ๋‹ค.', 'info'); return; } const emoji = event.target.closest('[data-emote-id]'); if (emoji) { sendEmote(emoji.dataset.emoteId, state.emojiTargetSeatIndex); return; } const side = event.target.closest('[data-side]'); if (side) { setSidePanel(side.dataset.side); } }); function setAuthStatus(message = '', type = '') { const node = $('authStatus'); if (!node) return; node.textContent = message; node.className = `auth-status${type ? ` ${type}` : ''}`; } function normalizePhoneInput(value) { let digits = String(value || '').replace(/\D/g, ''); if (digits.startsWith('82')) digits = `0${digits.slice(2)}`; return digits.slice(0, 11); } function unlockAuthPageScroll() { document.body.classList.remove('mobile-nav-open', 'game-active', 'chat-keyboard-open'); document.documentElement.style.removeProperty('overflow'); document.body.style.removeProperty('overflow'); document.documentElement.style.removeProperty('overflow-y'); document.body.style.removeProperty('overflow-y'); } function setAuthMode(mode) { unlockAuthPageScroll(); state.authMode = mode; const register = mode === 'register'; $('showLoginMode').classList.toggle('active', !register); $('showRegisterMode').classList.toggle('active', register); $('realNameLabel').classList.toggle('hidden', !register); $('realNameInput').classList.toggle('hidden', !register); $('realNameInput').disabled = !register; $('realNameInput').required = register; $('nicknameLabel').classList.toggle('hidden', !register); $('nicknameInput').classList.toggle('hidden', !register); $('nicknameInput').disabled = !register; $('nicknameInput').required = register; $('phoneEntryArea').classList.toggle('hidden', !register); $('phoneInput').disabled = !register; $('phoneInput').required = register; $('registerAvatarArea').classList.toggle('hidden', !register); $('referralLabel').classList.toggle('hidden', !register); $('referralInput').classList.toggle('hidden', !register); $('referralInput').disabled = !register; $('passwordInput').autocomplete = register ? 'new-password' : 'current-password'; $('passwordInput').enterKeyHint = register ? 'next' : 'done'; $('loginBtn').textContent = register ? '๊ฐ€์ž… ์‹ ์ฒญ' : '๋กœ๊ทธ์ธ'; $('loginBtn').disabled = false; setAuthStatus(register ? '์ •๋ณด๋ฅผ ์ž…๋ ฅํ•˜๋ฉด ๊ด€๋ฆฌ์ž ์Šน์ธ ๋Œ€๊ธฐ๋กœ ์ ‘์ˆ˜๋ฉ๋‹ˆ๋‹ค.' : ''); if (register && window.matchMedia('(max-width:520px)').matches) { requestAnimationFrame(() => $('loginScreen')?.scrollTo({ top: 0, behavior: 'smooth' })); } } async function accountRequest(path, body) { const response = await fetch(path, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await response.json().catch(() => ({ ok: false, message: '์„œ๋ฒ„ ์‘๋‹ต์„ ์ฝ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.' })); if (!response.ok || !data.ok) throw new Error(data.message || '์š”์ฒญ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); return data; } $('showLoginMode').addEventListener('click', () => setAuthMode('login')); $('showRegisterMode').addEventListener('click', () => setAuthMode('register')); $('accountAuthForm').addEventListener('submit', async (event) => { event.preventDefault(); const register = state.authMode === 'register'; const email = $('emailInput').value.trim(); const password = $('passwordInput').value; const realName = $('realNameInput').value.trim(); const nickname = $('nicknameInput').value.trim(); const phone = normalizePhoneInput($('phoneInput')?.value); const referralCode = $('referralInput').value.trim(); if (!email || !$('emailInput').checkValidity()) { setAuthStatus('์˜ฌ๋ฐ”๋ฅธ ์ด๋ฉ”์ผ ์ฃผ์†Œ๋ฅผ ์ž…๋ ฅํ•ด ์ฃผ์„ธ์š”.', 'error'); $('emailInput').focus(); return; } if (password.length < 4) { setAuthStatus('๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 4๊ธ€์ž ์ด์ƒ ์ž…๋ ฅํ•ด ์ฃผ์„ธ์š”.', 'error'); $('passwordInput').focus(); return; } if (register && realName.length < 2) { setAuthStatus('์ด๋ฆ„์„ 2์ž ์ด์ƒ ์ž…๋ ฅํ•ด ์ฃผ์„ธ์š”.', 'error'); $('realNameInput').focus(); return; } if (register && !nickname) { setAuthStatus('๋‹‰๋„ค์ž„์„ ์ž…๋ ฅํ•ด ์ฃผ์„ธ์š”.', 'error'); $('nicknameInput').focus(); return; } if (register && !/^010\d{8}$/.test(phone)) { setAuthStatus('010์œผ๋กœ ์‹œ์ž‘ํ•˜๋Š” 11์ž๋ฆฌ ํœด๋Œ€ํฐ ๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•ด ์ฃผ์„ธ์š”.', 'error'); $('phoneInput').focus(); return; } const button = $('loginBtn'); button.disabled = true; button.textContent = register ? '์‹ ์ฒญ ์ค‘โ€ฆ' : '๋กœ๊ทธ์ธ ์ค‘โ€ฆ'; setAuthStatus(register ? '๊ฐ€์ž… ์‹ ์ฒญ์„ ์ „์†กํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹คโ€ฆ' : '๋กœ๊ทธ์ธํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹คโ€ฆ'); try { const endpoint = register ? '/api/auth/register' : '/api/auth/login'; const result = await accountRequest(endpoint, { email, password, realName, nickname, phone, avatar: state.selectedLoginAvatar, referralCode }); if (register && result.pending) { const message = result.message || '๊ฐ€์ž… ์‹ ์ฒญ์ด ์ ‘์ˆ˜๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'; toast(message, 'success'); setAuthMode('login'); setAuthStatus(message, 'success'); $('passwordInput').value = ''; $('realNameInput').value = ''; $('nicknameInput').value = ''; $('phoneInput').value = ''; $('referralInput').value = ''; return; } state.sessionToken = result.sessionToken || ''; if (result.token) localStorage.setItem('royal-token', result.token); authenticate({ token: result.token || undefined }); } catch (error) { setAuthStatus(error.message, 'error'); toast(error.message, 'error'); } finally { button.disabled = false; button.textContent = state.authMode === 'register' ? '๊ฐ€์ž… ์‹ ์ฒญ' : '๋กœ๊ทธ์ธ'; } }); $('forgotPasswordBtn').addEventListener('click', async () => { const email = prompt('๊ฐ€์ž…ํ•œ ์ด๋ฉ”์ผ์„ ์ž…๋ ฅํ•˜์„ธ์š”.'); if (!email) return; try { const result = await accountRequest('/api/auth/password-reset/request', { email }); alert(result.developmentToken ? `${result.message}\n\n๊ฐœ๋ฐœ์šฉ ์žฌ์„ค์ • ํ† ํฐ: ${result.developmentToken}` : result.message); } catch (error) { toast(error.message, 'error'); } }); $('mobileNavToggle')?.addEventListener('click', () => setMobileNav(!state.mobileNavOpen)); $('mobileNavClose')?.addEventListener('click', () => setMobileNav(false)); $('mobileNavBackdrop')?.addEventListener('click', () => setMobileNav(false)); $('soundBtn').addEventListener('click', () => { state.sound = !state.sound; localStorage.setItem('royal-sound', state.sound ? 'on' : 'off'); $('soundBtn').textContent = state.sound ? '๐Ÿ”Š' : '๐Ÿ”‡'; playSound(); }); $('openCreateRoom')?.addEventListener('click', () => {}); $('openGuildCreate').addEventListener('click', () => $('guildDialog').showModal()); $('reportBtn').addEventListener('click', () => $('reportDialog').showModal()); $('closeSkinPreviewBtn').addEventListener('click', () => $('skinPreviewDialog').close()); $('closePlayerDialogBtn').addEventListener('click', () => $('playerDialog').close()); $('closeReplayDialogBtn').addEventListener('click', () => $('replayDialog').close()); $('closeNoticeDetailBtn')?.addEventListener('click', () => $('noticeDetailDialog')?.close()); ['skinPreviewDialog', 'playerDialog', 'replayDialog'].forEach((id) => { const dialog = $(id); dialog.addEventListener('click', (event) => { if (event.target === dialog) dialog.close(); }); }); $('clearEmojiTarget').addEventListener('click', () => { state.emojiTargetSeatIndex = null; renderEmojiBar(); if (currentRoom()) renderGame(currentRoom()); }); $('quickSitBtn').addEventListener('click', () => { const payload = currentRoom(); if (!payload) return; const index = payload.seats.findIndex((seat) => !seat); if (index >= 0) { openBuyInDialog({ roomId: state.activeRoomId, seatIndex: index, mode: 'sit' }); return; } openBuyInDialog({ roomId: state.activeRoomId, mode: 'reserve' }); }); $('rebuyBtn').addEventListener('click', () => openBuyInDialog({ roomId: state.activeRoomId, mode: 'rebuy' })); $('buyInAmount').addEventListener('input', () => { $('buyInRange').value = $('buyInAmount').value; }); $('buyInRange').addEventListener('input', () => { $('buyInAmount').value = $('buyInRange').value; }); document.querySelectorAll('[data-buyin-mult]').forEach((button) => button.addEventListener('click', () => { const x = state.pendingBuyIn; if (!x) return; const v = Math.min(x.maximum, x.minimum * Number(button.dataset.buyinMult)); $('buyInAmount').value = v; $('buyInRange').value = v; })); document.querySelector('[data-buyin-all]').addEventListener('click', () => { const x = state.pendingBuyIn; if (!x) return; $('buyInAmount').value = x.maximum; $('buyInRange').value = x.maximum; }); $('cancelBuyInBtn').addEventListener('click', () => { state.pendingBuyIn = null; $('buyInDialog').close(); }); $('buyInDialog').addEventListener('cancel', () => { state.pendingBuyIn = null; }); $('buyInForm').addEventListener('submit', (event) => { event.preventDefault(); const pending = state.pendingBuyIn; if (!pending) return; const buyInAmount = Number($('buyInAmount').value); const eventName = pending.mode === 'rebuy' ? 'room:rebuy' : pending.mode === 'reserve' ? 'room:reserve' : 'room:sit'; socket.emit(eventName, { roomId: pending.roomId, seatIndex: pending.seatIndex, buyInAmount }, (result) => { toast(result?.message || (result?.ok ? '์ฒ˜๋ฆฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.' : '์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'), result?.ok ? 'success' : 'error'); if (result?.ok) { $('buyInDialog').close(); state.pendingBuyIn = null; if (pending.mode === 'reserve') setTimeout(() => openRoom(pending.roomId), 120); } }); }); $('logoutBtn').addEventListener('click', () => { if (!confirm('๋กœ๊ทธ์•„์›ƒํ• ๊นŒ์š”? ์ง„ํ–‰ ์ค‘์ธ ํ•ธ๋“œ๋Š” ์ž๋™ ํ”Œ๋ ˆ์ด ํ›„ ํ‡ด์„ํ•ฉ๋‹ˆ๋‹ค.')) return; fetch('/api/auth/logout', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' } }).catch(() => {}); socket.emit('auth:logout', {}, () => { localStorage.removeItem('royal-token'); state.sessionToken = ''; state.user = null; socket.disconnect(); location.reload(); }); }); $('profileImageInput').addEventListener('change', async (event) => { const file = event.target.files?.[0]; if (!file) return; if (!file.type.startsWith('image/')) return toast('์ด๋ฏธ์ง€ ํŒŒ์ผ๋งŒ ์„ ํƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.', 'error'); try { const imageData = await resizeProfileImage(file); socket.emit('profile:image', { imageData }, (result) => toast(result?.message || 'ํ”„๋กœํ•„ ์‚ฌ์ง„ ์ฒ˜๋ฆฌ ์™„๋ฃŒ', result?.ok ? 'success' : 'error')); } catch (error) { toast(error.message || '์‚ฌ์ง„์„ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', 'error'); } event.target.value = ''; }); $('removeProfileImage').addEventListener('click', () => socket.emit('profile:image', { imageData: null }, (result) => toast(result?.message || '์‚ฌ์ง„์„ ์‚ญ์ œํ–ˆ์Šต๋‹ˆ๋‹ค.', result?.ok ? 'success' : 'error'))); function adjustVoucherPoints(delta) { const input = $('voucherPoints'); const next = Math.max(10000, Math.min(1000000, Math.round((Number(input.value || 10000) + delta) / 10000) * 10000)); input.value = next; } $('voucherMinus').addEventListener('click', () => adjustVoucherPoints(-10000)); $('voucherPlus').addEventListener('click', () => adjustVoucherPoints(10000)); $('voucherPoints').addEventListener('change', () => adjustVoucherPoints(0)); $('voucherForm')?.addEventListener('submit', (event) => { event.preventDefault(); const payload = { rewardType: $('voucherProduct').value, points: Number($('voucherPoints').value), contact: $('voucherContact').value.trim(), note: $('voucherNote').value.trim() }; if (!payload.contact) return toast('๊ตํ™˜๊ถŒ์„ ๋ฐ›์„ ์—ฐ๋ฝ์ฒ˜๋ฅผ ์ž…๋ ฅํ•ด ์ฃผ์„ธ์š”.', 'error'); if (!Number.isFinite(payload.points) || payload.points < 10000 || payload.points % 10000 !== 0) return toast('์‹ ์ฒญ ๊ธˆ์•ก์€ 10,000P ๋‹จ์œ„๋กœ ์ž…๋ ฅํ•ด ์ฃผ์„ธ์š”.', 'error'); if (!confirm(`${fmt(payload.points)}P ๊ตํ™˜๊ถŒ์„ ์‹ ์ฒญํ• ๊นŒ์š”? ์‹ ์ฒญ ํฌ์ธํŠธ๋Š” ์ฆ‰์‹œ ์ฐจ๊ฐ๋ฉ๋‹ˆ๋‹ค.`)) return; socket.emit('redemption:create', payload, (result) => { toast(result?.message || '๊ตํ™˜ ์‹ ์ฒญ์„ ์ฒ˜๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค.', result?.ok ? 'success' : 'error'); if (result?.ok) { $('voucherContact').value = ''; $('voucherNote').value = ''; socket.emit('bootstrap:refresh', () => {}); } }); }); const setContactOpen = (open) => { $('contactPanel').hidden = !open; $('contactToggle').setAttribute('aria-expanded', open ? 'true' : 'false'); }; $('contactToggle').addEventListener('click', () => setContactOpen($('contactPanel').hidden)); $('contactClose').addEventListener('click', () => setContactOpen(false)); $('copyTelegram').addEventListener('click', async () => { const handle = state.support?.handle || `@${String(state.support?.telegramId || '232344').replace(/^@+/, '')}`; try { await navigator.clipboard.writeText(handle); toast(`ํ…”๋ ˆ๊ทธ๋žจ ์•„์ด๋”” ${handle}๋ฅผ ๋ณต์‚ฌํ–ˆ์Šต๋‹ˆ๋‹ค.`, 'success'); } catch { window.prompt('์•„๋ž˜ ์•„์ด๋””๋ฅผ ๋ณต์‚ฌํ•˜์„ธ์š”.', handle); } }); $('createRoomForm').addEventListener('submit', (event) => { event.preventDefault(); const data = Object.fromEntries(new FormData(event.currentTarget)); socket.emit('room:create', data, (result) => { if (!result?.ok) return toast(result?.message || '๋ฐฉ ์ƒ์„ฑ ์‹คํŒจ', 'error'); $('createRoomDialog').close(); openRoom(result.roomId); toast('์นœ๊ตฌ๋ฐฉ์„ ๋งŒ๋“ค์—ˆ์Šต๋‹ˆ๋‹ค.', 'success'); }); }); $('guildForm').addEventListener('submit', (event) => { event.preventDefault(); const data = Object.fromEntries(new FormData(event.currentTarget)); $('guildDialog').close(); emitWithToast('guild:create', data); }); $('reportForm').addEventListener('submit', (event) => { event.preventDefault(); const data = Object.fromEntries(new FormData(event.currentTarget)); $('reportDialog').close(); emitWithToast('report:create', data, false); }); $('backToLobbyBtn').addEventListener('click', showLobby); $('exitRoomBtn').addEventListener('click', () => { if (!state.activeRoomId) return; const livePayload = currentRoom(); const liveSeat = viewerSeat(livePayload); const exitIsReserved = Boolean(liveSeat?.exitReserved || state.pendingExitRooms.has(state.activeRoomId)); if (exitIsReserved) { if (!confirm('๋‚˜๊ฐ€๊ธฐ ์˜ˆ์•ฝ์„ ์ทจ์†Œํ• ๊นŒ์š”? ์ด๋ฏธ ์ž๋™ ํด๋“œ๊ฐ€ ์ฒ˜๋ฆฌ๋œ ๋’ค์—๋Š” ์ทจ์†Œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.')) return; const roomId = state.activeRoomId; socket.emit('room:leave:cancel', { roomId }, (result) => { if (!result?.ok) return toast(result?.message || '๋‚˜๊ฐ€๊ธฐ ์˜ˆ์•ฝ ์ทจ์†Œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', 'error'); state.pendingExitRooms.delete(roomId); const payload = state.roomStates[roomId]; const me = viewerSeat(payload); if (me) me.exitReserved = false; if (payload) renderGame(payload, { suppressFx: true }); toast(result.message || '๋‚˜๊ฐ€๊ธฐ ์˜ˆ์•ฝ์„ ์ทจ์†Œํ–ˆ์Šต๋‹ˆ๋‹ค.', 'success'); }); return; } const quote = liveSeat?.earlyExitQuote; const policyText = quote && Number(quote.sessionHands || 0) < Number(quote.requiredHands || 50) ? `\n\nํ˜„์žฌ ${Number(quote.sessionHands || 0)}/50ํ•ธ๋“œ์ž…๋‹ˆ๋‹ค. 50ํ•ธ๋“œ ์ „์— ๋‚˜๊ฐ€๋ฉด ์ˆœ์ด์ต์˜ 20%๊ฐ€ ๊ณต์ œ๋ฉ๋‹ˆ๋‹ค.${Number(quote.fee || 0) > 0 ? `\nํ˜„์žฌ ๊ธฐ์ค€ ์˜ˆ์ƒ ๊ณต์ œ: ${fmt(quote.fee)}P\n์˜ˆ์ƒ ์ง€๊ธ‰: ${fmt(quote.cashout)}P` : `\nํ˜„์žฌ ๊ธฐ์ค€ ๊ณต์ œํ•  ์ˆœ์ด์ต์€ ์—†์Šต๋‹ˆ๋‹ค.`}` : ''; if (!confirm(`๋‚˜๊ฐ€๊ธฐ๋ฅผ ์˜ˆ์•ฝํ• ๊นŒ์š”? ํ˜„์žฌ ํ•ธ๋“œ์—์„œ ์ฐจ๋ก€๊ฐ€ ์˜ค๋ฉด ์ฒดํฌ ๊ฐ€๋Šฅ ์‹œ ์ž๋™ ์ฒดํฌํ•˜๊ณ , ๋ฐ›์•„์•ผ ํ•  ๊ธˆ์•ก์ด ์žˆ์œผ๋ฉด ์ž๋™ ํด๋“œํ•ฉ๋‹ˆ๋‹ค. ํ•ธ๋“œ๊ฐ€ ๋๋‚˜๋ฉด ๋กœ๋น„๋กœ ์ด๋™ํ•ฉ๋‹ˆ๋‹ค.${policyText}`)) return; const leavingId = state.activeRoomId; socket.emit('room:leave', { roomId: leavingId }, (result) => { if (!result?.ok) return toast(result?.message || '๋ฐฉ ๋‚˜๊ฐ€๊ธฐ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', 'error'); toast(result.message || '๋ฐฉ์—์„œ ๋‚˜๊ฐ”์Šต๋‹ˆ๋‹ค.', 'success'); if (result.waitForResult) { state.pendingExitRooms.add(leavingId); const payload = state.roomStates[leavingId]; if (payload) renderGame(payload, { suppressFx: true }); return; } delete state.roomStates[leavingId]; state.activeRooms = state.activeRooms.filter((room) => room.id !== leavingId); state.activeRoomId = state.activeRooms[0]?.id || null; renderMyRoomTabs(); showLobby(); }); }); $('myCardPeekBtn').addEventListener('click', () => { state.myCardPeekOpen = !state.myCardPeekOpen; renderMyHandAssist(); }); $('myCardPeekClose').addEventListener('click', () => { state.myCardPeekOpen = false; renderMyHandAssist(); }); function sendGameAction(type, amount = null, onSuccess = null) { if (state.actionPending) return; state.actionPending = true; renderActionControls(); const payload = { roomId: state.activeRoomId, type }; if (amount !== null && amount !== undefined) payload.amount = amount; socket.timeout(4000).emit('game:action', payload, (error, result) => { state.actionPending = false; renderActionControls(); if (error) return toast('์„œ๋ฒ„ ์‘๋‹ต์ด ๋Šฆ์Šต๋‹ˆ๋‹ค. ์—ฐ๊ฒฐ ์ƒํƒœ๋ฅผ ํ™•์ธํ•ด ์ฃผ์„ธ์š”.', 'error'); if (!result?.ok) return toast(result?.message || 'ํ–‰๋™ ์ฒ˜๋ฆฌ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', 'error'); if (typeof onSuccess === 'function') onSuccess(result); }); } $('foldBtn').addEventListener('click', () => sendGameAction('fold')); $('callBtn').addEventListener('click', () => sendGameAction('checkcall')); $('raiseBtn').addEventListener('click', () => { const payload = currentRoom(); const me = viewerSeat(payload); if (!me) return; const maxTo = me.bet + me.stack; const isAll = state.allInSelected || Number($('raiseSlider').value) >= maxTo; sendGameAction(isAll ? 'allin' : 'raise', isAll ? maxTo : Number($('raiseSlider').value), () => { state.allInSelected = false; document.querySelectorAll('[data-pot]').forEach((button) => button.classList.remove('selected')); }); }); $('raiseSlider').addEventListener('input', () => { const me = viewerSeat(); state.allInSelected = Boolean(me && Number($('raiseSlider').value) >= me.bet + me.stack); renderActionControls(); }); document.querySelectorAll('[data-pot]').forEach((button) => button.addEventListener('click', () => { const payload = currentRoom(); const me = viewerSeat(payload); if (!me || !payload) return; const value = button.dataset.pot; document.querySelectorAll('[data-pot]').forEach((item) => item.classList.remove('selected')); if (value === 'allin') { state.allInSelected = true; $('raiseSlider').value = Number($('raiseSlider').max); button.classList.add('selected'); $('raiseValue').textContent = fmt(me.bet + me.stack); renderActionControls(); return; } state.allInSelected = false; button.classList.add('selected'); const target = payload.game.currentBet + Math.round((payload.game.pot * Number(value)) / payload.room.bigBlind) * payload.room.bigBlind; $('raiseSlider').value = Math.max(Number($('raiseSlider').min), Math.min(Number($('raiseSlider').max), target)); $('raiseValue').textContent = fmt($('raiseSlider').value); renderActionControls(); })); window.addEventListener('resize', () => { if (!$('gameView').classList.contains('hidden')) syncGameSideDrawerForViewport(); }); $('sideDrawerToggle').addEventListener('click', () => { state.sideDrawerOpen = !state.sideDrawerOpen; $('gameSideDrawer').classList.toggle('collapsed', !state.sideDrawerOpen); $('sideDrawerToggle').setAttribute('aria-expanded', state.sideDrawerOpen ? 'true' : 'false'); $('sideDrawerToggle').querySelector('span').textContent = state.sideDrawerOpen ? 'โ–ถ' : 'โ—€'; }); $('chatForm').addEventListener('submit', (event) => { event.preventDefault(); const message = $('chatInput').value.trim(); if (!message) return; socket.emit('chat:send', { roomId: state.activeRoomId, message }, (result) => { if (!result?.ok) toast(result?.message, 'error'); else $('chatInput').value = ''; }); }); $('chatInput').addEventListener('focus', () => { openMobileChat(); document.body.classList.add('chat-keyboard-open'); syncMobileKeyboardOffset(); setTimeout(() => $('chatInput').scrollIntoView({ block: 'nearest', behavior: 'smooth' }), 120); }); $('chatInput').addEventListener('blur', () => { document.body.classList.remove('chat-keyboard-open'); setTimeout(syncMobileKeyboardOffset, 80); }); if (window.visualViewport) { window.visualViewport.addEventListener('resize', syncMobileKeyboardOffset); window.visualViewport.addEventListener('scroll', syncMobileKeyboardOffset); syncMobileKeyboardOffset(); } socket.on('connect', async () => { const reconnecting = state.socketConnectedOnce || Boolean(state.user); state.socketConnectedOnce = true; $('connectionBadge').textContent = reconnecting ? '๋ณต๊ตฌ ์ค‘' : '์˜จ๋ผ์ธ'; $('connectionBadge').className = reconnecting ? 'connection offline' : 'connection online'; if (forceLoginPage && !state.user) { unlockAuthPageScroll(); $('app').classList.add('hidden'); $('loginScreen').classList.remove('hidden'); return; } const token = localStorage.getItem('royal-token'); if (state.user || state.sessionToken || token) { authenticate({ token, reconnect: reconnecting, silent: reconnecting }); return; } try { const response = await fetch('/api/auth/me', { credentials: 'include' }); if (response.ok) return authenticate({ reconnect: reconnecting, silent: reconnecting }); } catch {} unlockAuthPageScroll(); $('loginScreen').classList.remove('hidden'); }); socket.on('disconnect', () => { clearTimeout(state.reconnectAuthTimer); state.socketAuthInFlight = false; state.actionPending = false; $('connectionBadge').textContent = '์žฌ์—ฐ๊ฒฐ ์ค‘'; $('connectionBadge').className = 'connection offline'; }); socket.on('connect_error', () => { $('connectionBadge').textContent = '์—ฐ๊ฒฐ ์žฌ์‹œ๋„ ์ค‘'; $('connectionBadge').className = 'connection offline'; }); socket.io.on('reconnect_attempt', () => { $('connectionBadge').textContent = '์žฌ์—ฐ๊ฒฐ ์ค‘'; $('connectionBadge').className = 'connection offline'; }); window.addEventListener('online', () => { if (!socket.connected) socket.connect(); else if (state.user) authenticate({ token: localStorage.getItem('royal-token'), reconnect: true, silent: true }); }); document.addEventListener('visibilitychange', () => { if (document.visibilityState !== 'visible') return; if (!socket.connected) socket.connect(); else if (state.user && !state.socketAuthInFlight) authenticate({ token: localStorage.getItem('royal-token'), reconnect: true, silent: true }); }); socket.on('bootstrap', applyBootstrap); socket.on('wallet:update', (user) => { state.user = user; updateTopUser(); renderRewards(); renderMissions(); renderProfile(); renderShop(); renderEmojiBar(); }); socket.on('user:rooms', (rooms) => { state.activeRooms = rooms || []; if (state.activeRoomId && !state.activeRooms.some((room) => room.id === state.activeRoomId) && !state.roomStates[state.activeRoomId]?.viewer?.isSpectator) { state.activeRoomId = state.activeRooms[0]?.id || null; } renderMyRoomTabs(); renderRooms(); }); socket.on('lobby:rooms', (rooms) => { state.rooms = rooms; renderRooms(); }); socket.on('notices:updated', (notices) => { state.notices = Array.isArray(notices) ? notices : []; renderNotices(); }); socket.on('tournaments:updated', (tournaments) => { state.tournaments = Array.isArray(tournaments) ? tournaments : []; renderTournaments(); }); socket.on('support:update', (support) => { state.support = support || state.support; renderSupportContact(); }); socket.on('room:state', (payload) => { if (!payload?.room?.id) return; if (payload.jackpot) { state.jackpot = { ...(state.jackpot || {}), ...payload.jackpot }; if ($('tab-ranking')?.classList.contains('active')) renderRankings(); } state.roomStates[payload.room.id] = payload; if (payload.room.id === state.activeRoomId) state.actionPending = false; if (!state.activeRoomId) state.activeRoomId = payload.room.id; const gameVisible = !$('gameView').classList.contains('hidden') && payload.room.id === state.activeRoomId; if (payload.game?.lastResult && !gameVisible) { state.resultKeys.add(`${payload.room.id}-${payload.game.handNo}`); } if (payload.room.id === state.activeRoomId) renderGame(payload); else renderMyRoomTabs(); notifyTurnIfNeeded(payload); }); socket.on('chat:new', (data) => { const payload = state.roomStates[data.roomId]; if (!payload) return; payload.chat ||= []; payload.chat.push(data.entry); if (data.roomId === state.activeRoomId) renderChat(payload.chat); }); socket.on('emoji:show', showFloatingEmoji); socket.on('rebuy:required', (data) => { if (data.roomId === state.activeRoomId) toast('์˜ฌ์ธ์œผ๋กœ ํฌ์ธํŠธ๊ฐ€ ์†Œ์ง„๋˜์—ˆ์Šต๋‹ˆ๋‹ค. 2๋ถ„ ์•ˆ์— ๋ฆฌ๋ฐ”์ดํ•˜์„ธ์š”.', 'error'); }); socket.on('toast', (data) => toast(data.message, data.type)); socket.on('rush:queued', (data) => toast(data.message, 'info')); socket.on('room:exitComplete', (data) => { const roomId = data?.roomId; if (!roomId) return; state.pendingExitRooms.delete(roomId); delete state.roomStates[roomId]; state.activeRooms = state.activeRooms.filter((room) => room.id !== roomId); if (state.activeRoomId === roomId) state.activeRoomId = state.activeRooms[0]?.id || null; renderMyRoomTabs(); showLobby(); toast('๋‚˜๊ฐ€๊ธฐ ์˜ˆ์•ฝ์ด ์™„๋ฃŒ๋˜์–ด ํ•ธ๋“œ ์ข…๋ฃŒ ํ›„ ๋กœ๋น„๋กœ ์ด๋™ํ–ˆ์Šต๋‹ˆ๋‹ค.', 'success'); }); socket.on('room:closed', (data) => { toast(data.reason || '๋ฐฉ์ด ์ข…๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', 'error'); if (data.roomId) { delete state.roomStates[data.roomId]; state.activeRooms = state.activeRooms.filter((room) => room.id !== data.roomId); } showLobby(); }); loadSupportContact(); if (forceLoginPage || !localStorage.getItem('royal-token')) { $('app').classList.add('hidden'); $('loginScreen').classList.remove('hidden'); } })();