Initial commit: private Texas Hold'em ring game site (lobby + table stub)

- 6-digit access key gate with long-lived HttpOnly cookie, brute-force limits
- Lobby with saved player name and live table list
- Table creation (name, starting stack, blinds, blind timer)
- Table play screen stub (seats, button/SB/BB, phase, blinds, timer)
- Security hardening: strict CSP, origin checks, host pinning, input sanitizing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
agomizi1cmandClaude Opus 5.5 committed 2026-10-15 16:51:15 +09:00
commit b2c91062f1
22 files changed
+3450

No files matched your search

+187
View File
@@ -0,0 +1,187 @@
// プレイ画面(スタブ): サーバから受け取ったテーブル状態を表示するだけ
(function () {
'use strict';
const { Store, esc, chips, toast } = App;
const $ = (id) => document.getElementById(id);
const tableId = decodeURIComponent(location.pathname.split('/').pop());
const AVATAR_SVG =
'<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="4.5"/><path d="M3 21c0-4.4 4-7.5 9-7.5s9 3.1 9 7.5z"/></svg>';
let socket = null;
let state = null;
let clockOffset = 0; // serverNow - clientNow
// ---------- 名前 ----------
function ensureName() {
if (Store.name) return connect();
$('name-modal').hidden = false;
$('name-input').focus();
}
$('name-form').addEventListener('submit', (e) => {
e.preventDefault();
const name = $('name-input').value.trim();
if (!name) return $('name-input').classList.add('invalid');
Store.name = name;
$('name-modal').hidden = true;
connect();
});
// ---------- 通信 ----------
function connect() {
socket = App.connectSocket();
socket.on('connect', () => {
$('conn-lost').hidden = true;
socket.emit('table:join', { tableId, token: Store.token, name: Store.name }, (res) => {
if (res && res.ok) return;
if (res && res.error === 'NOT_FOUND') return showNotFound();
toast('参加できませんでした: ' + ((res && res.error) || '不明なエラー'));
});
});
socket.on('disconnect', () => { $('conn-lost').hidden = false; });
socket.on('connect_error', () => { $('conn-lost').hidden = false; });
socket.on('table:state', (s) => {
state = s;
clockOffset = s.serverNow - Date.now();
render();
});
}
function emit(event) {
if (!socket) return;
socket.emit(event, null, (res) => {
if (res && !res.ok) toast(res.error === 'NOT_FOUND' ? 'テーブルが見つかりません' : res.error);
});
}
function showNotFound() {
$('main').hidden = true;
$('action-bar').hidden = true;
$('not-found').hidden = false;
if (socket) socket.disconnect();
}
// ---------- 描画 ----------
function render() {
const s = state;
$('main').hidden = false;
document.title = `${s.name} - Hold'em Night`;
$('table-name').textContent = s.name;
$('level').textContent = `Lv.${s.level}`;
$('blinds').textContent = `SB: ${chips(s.smallBlind)} / BB: ${chips(s.bigBlind)}`;
$('next-blinds').textContent = s.timer.enabled
? `次: ${chips(s.nextBlinds.smallBlind)} / ${chips(s.nextBlinds.bigBlind)}(${s.timer.intervalMin}分毎)`
: 'ブラインドタイマーなし';
$('stack-info').textContent = `開始スタック ${chips(s.startingStack)}`;
$('pot').textContent = chips(s.pot);
$('phase').textContent = App.PHASE_LABELS[s.phase] || s.phase;
$('hand-no').textContent = s.handNumber ? `Hand #${s.handNumber}` : '';
// ホスト操作
const players = s.seats.filter(Boolean).length;
$('host-controls').hidden = !s.isHost;
$('host-spacer').hidden = s.isHost;
$('start').disabled = s.phase !== 'WAITING' || players < 2;
$('start').title = players < 2 ? '2人以上で開始できます' : '';
$('next-phase').disabled = s.phase === 'WAITING';
$('pause').disabled = !s.started;
$('pause').textContent = s.paused ? '再開' : '一時停止';
// 自分
const me = s.mySeat >= 0 ? s.seats[s.mySeat] : null;
$('action-bar').hidden = !me;
$('spectator-note').hidden = Boolean(me);
$('leave').title = me ? '退席する' : 'ロビーへ戻る';
if (me) $('my-stack').textContent = chips(me.stack);
renderSeats(s);
renderTimer();
}
// 着席者を楕円上に配置。自分が一番下、そこから時計回り。
function renderSeats(s) {
const occupied = s.seats.filter(Boolean);
const startIdx = Math.max(0, occupied.findIndex((p) => p.seat === s.mySeat));
const ordered = occupied.slice(startIdx).concat(occupied.slice(0, startIdx));
const n = ordered.length;
const container = $('seats');
container.innerHTML = ordered
.map((p) => {
const classes = ['seat'];
if (p.seat === s.mySeat) classes.push('me');
if (!p.connected) classes.push('offline');
const markers = [
p.seat === s.buttonSeat ? '<span class="chip-marker dealer" title="ディーラーボタン">D</span>' : '',
p.seat === s.sbSeat ? '<span class="blind-marker sb">SB</span>' : '',
p.seat === s.bbSeat ? '<span class="blind-marker bb">BB</span>' : '',
].join('');
return `
<div class="${classes.join(' ')}">
<div class="avatar">${AVATAR_SVG}${markers}</div>
<div class="nameplate">
<div class="pname">${p.seat === s.hostSeat ? '<span title="ホスト">👑</span>' : ''}${esc(p.name)}</div>
<div class="pstack">${chips(p.stack)}</div>
${p.connected ? '' : '<div class="offline-tag">オフライン</div>'}
</div>
</div>`;
})
.join('');
// 位置は CSSOM で設定(CSP で style 属性を禁止しているため)
[...container.children].forEach((el, i) => {
const angle = Math.PI / 2 + (i * 2 * Math.PI) / Math.max(n, 1);
el.style.left = `${(50 + 38 * Math.cos(angle)).toFixed(2)}%`;
el.style.top = `${(50 + 41 * Math.sin(angle)).toFixed(2)}%`;
});
}
function renderTimer() {
const el = $('timer');
if (!state) return;
const t = state.timer;
el.classList.toggle('paused', state.paused);
if (!t.enabled) {
el.textContent = state.paused ? '⏸ PAUSED' : '';
return;
}
const remaining = t.running ? t.endsAt - (Date.now() + clockOffset) : t.remainingMs;
const sec = Math.max(0, Math.ceil(remaining / 1000));
const time = `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}`;
el.textContent = state.paused ? `⏸ ${time}` : time;
el.classList.toggle('warn', t.running && sec <= 60);
}
setInterval(renderTimer, 250);
// ---------- 操作 ----------
$('start').addEventListener('click', () => emit('table:start'));
$('next-phase').addEventListener('click', () => emit('table:nextPhase'));
$('pause').addEventListener('click', () => emit('table:pause'));
$('leave').addEventListener('click', () => {
const seated = state && state.mySeat >= 0;
if (!seated) return (location.href = '/');
if (!confirm('テーブルから退席しますか?(チップは失われます)')) return;
socket.emit('table:leave', null, () => { location.href = '/'; });
});
$('share').addEventListener('click', async () => {
const url = location.origin + '/';
try {
await navigator.clipboard.writeText(url);
} catch {
const ta = document.createElement('textarea');
ta.value = url;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
toast('サイトのリンクをコピーしました');
});
ensureName();
})();