(() => {
'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) => ``).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) => `
์ฒจ๋ถ์ฌ์ง ${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'
? ``
: `${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}๋ช
์ฃผ๊ฐ ๊ธธ๋ 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 ``;
}
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('')}
์ก์
๋ก๊ทธ
`;
$('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 = `์ด๋ชจํฐ์ฝ ๋ณด๋ด๊ธฐ
`;
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');
}
})();