diff --git a/.env.example b/.env.example index 2ff8fd0..7c3ad28 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,7 @@ # .env にコピーして値を設定する(chmod 600 推奨) ACCESS_KEY=000000 +# 管理者キー(任意・ACCESS_KEY と別の6桁)。入力すると管理者として入室し、ハンドレンジ学習ページが見える +# ADMIN_KEY=000001 PORT=4671 # HOST=127.0.0.1 # 本番のみ設定(ローカル開発では設定しない) diff --git a/README.md b/README.md index 181bf97..5a8b711 100644 --- a/README.md +++ b/README.md @@ -37,11 +37,20 @@ http://localhost:4671 を開く。 | 変数 | 既定値 | 内容 | | --- | --- | --- | | `ACCESS_KEY` | なし(必須) | 6桁の参加キー | +| `ADMIN_KEY` | なし(任意) | 6桁の管理者キー(`ACCESS_KEY` と別の値)。入力すると管理者として入室する | | `PORT` | `4671` | 待ち受けポート | | `HOST` | `127.0.0.1` | 待ち受けアドレス。既定では外部から直接つながらない(nginx 経由で公開する) | | `PUBLIC_ORIGIN` | なし | 本番の公開URL `https://poker.kanawo.net`。設定するとこのドメイン以外(IP直打ちや別ドメイン)へのアクセスと、他オリジンからの操作を拒否する。ローカル開発では設定しない | | `SESSION_SECRET` | 自動生成 | Cookie 署名用。未指定なら `data/session-secret` に生成・保存 | +## 管理者(ハンドレンジ学習) + +`ADMIN_KEY` を入力して入室すると管理者になり、ロビーに「♠ ハンドレンジ学習」が表示される(`/range`:レンジ表と色・分類クイズ)。管理者は通常のプレイもそのままできる。 + +- 初めて開くときは鍵入力画面で `ADMIN_KEY` を入れる。すでに参加キーで入室済みのブラウザは `https://poker.kanawo.net/key?admin` を開いて管理者キーを入れる。 +- 管理者以外には `/range` とその画像・スクリプトは 404 を返す(ページがあること自体を見せない)。ファイルは `public/` の外の `admin/` に置いている。 +- 管理者キーを変えると、管理者の Cookie だけが無効になる。 + ## セキュリティ - **参加キー**: 正しいキーを入れたブラウザに HttpOnly / SameSite=Lax(HTTPS 時は Secure)の Cookie を保存。アクセスのたびに有効期限(400日)が延長されるので、以降は入力不要。 @@ -67,6 +76,7 @@ http://localhost:4671 を開く。 | `server/config.js` | `.env` の読み込みと各種上限値 | | `public/assets/blinds.js` | ブラインド計算(サーバ・ブラウザ共用) | | `public/*.html`, `public/assets/*.js` | 各画面 | +| `admin/` | 管理者専用ページ(ハンドレンジ学習・カード画像) | - テーブルはメモリ上にのみ保持(サーバ再起動で消える)。誰も接続していない状態が30分続くと自動削除。 - 名前とプレイヤーID はブラウザの localStorage に保存。リロードしても同じ席に戻れる。 diff --git a/admin/cards/10_of_clubs.png b/admin/cards/10_of_clubs.png new file mode 100644 index 0000000..18af741 Binary files /dev/null and b/admin/cards/10_of_clubs.png differ diff --git a/admin/cards/10_of_diamonds.png b/admin/cards/10_of_diamonds.png new file mode 100644 index 0000000..3bbc4e0 Binary files /dev/null and b/admin/cards/10_of_diamonds.png differ diff --git a/admin/cards/10_of_hearts.png b/admin/cards/10_of_hearts.png new file mode 100644 index 0000000..3eb83d7 Binary files /dev/null and b/admin/cards/10_of_hearts.png differ diff --git a/admin/cards/10_of_spades.png b/admin/cards/10_of_spades.png new file mode 100644 index 0000000..0b3d294 Binary files /dev/null and b/admin/cards/10_of_spades.png differ diff --git a/admin/cards/2_of_clubs.png b/admin/cards/2_of_clubs.png new file mode 100644 index 0000000..291ed97 Binary files /dev/null and b/admin/cards/2_of_clubs.png differ diff --git a/admin/cards/2_of_diamonds.png b/admin/cards/2_of_diamonds.png new file mode 100644 index 0000000..4deee7c Binary files /dev/null and b/admin/cards/2_of_diamonds.png differ diff --git a/admin/cards/2_of_hearts.png b/admin/cards/2_of_hearts.png new file mode 100644 index 0000000..75a014f Binary files /dev/null and b/admin/cards/2_of_hearts.png differ diff --git a/admin/cards/2_of_spades.png b/admin/cards/2_of_spades.png new file mode 100644 index 0000000..1ce0ffe Binary files /dev/null and b/admin/cards/2_of_spades.png differ diff --git a/admin/cards/3_of_clubs.png b/admin/cards/3_of_clubs.png new file mode 100644 index 0000000..076ab31 Binary files /dev/null and b/admin/cards/3_of_clubs.png differ diff --git a/admin/cards/3_of_diamonds.png b/admin/cards/3_of_diamonds.png new file mode 100644 index 0000000..8ee0b4b Binary files /dev/null and b/admin/cards/3_of_diamonds.png differ diff --git a/admin/cards/3_of_hearts.png b/admin/cards/3_of_hearts.png new file mode 100644 index 0000000..8e74673 Binary files /dev/null and b/admin/cards/3_of_hearts.png differ diff --git a/admin/cards/3_of_spades.png b/admin/cards/3_of_spades.png new file mode 100644 index 0000000..f9e06b4 Binary files /dev/null and b/admin/cards/3_of_spades.png differ diff --git a/admin/cards/4_of_clubs.png b/admin/cards/4_of_clubs.png new file mode 100644 index 0000000..8be9e08 Binary files /dev/null and b/admin/cards/4_of_clubs.png differ diff --git a/admin/cards/4_of_diamonds.png b/admin/cards/4_of_diamonds.png new file mode 100644 index 0000000..70e82e8 Binary files /dev/null and b/admin/cards/4_of_diamonds.png differ diff --git a/admin/cards/4_of_hearts.png b/admin/cards/4_of_hearts.png new file mode 100644 index 0000000..ceecbfe Binary files /dev/null and b/admin/cards/4_of_hearts.png differ diff --git a/admin/cards/4_of_spades.png b/admin/cards/4_of_spades.png new file mode 100644 index 0000000..95abe3e Binary files /dev/null and b/admin/cards/4_of_spades.png differ diff --git a/admin/cards/5_of_clubs.png b/admin/cards/5_of_clubs.png new file mode 100644 index 0000000..bde9777 Binary files /dev/null and b/admin/cards/5_of_clubs.png differ diff --git a/admin/cards/5_of_diamonds.png b/admin/cards/5_of_diamonds.png new file mode 100644 index 0000000..bb92525 Binary files /dev/null and b/admin/cards/5_of_diamonds.png differ diff --git a/admin/cards/5_of_hearts.png b/admin/cards/5_of_hearts.png new file mode 100644 index 0000000..d923456 Binary files /dev/null and b/admin/cards/5_of_hearts.png differ diff --git a/admin/cards/5_of_spades.png b/admin/cards/5_of_spades.png new file mode 100644 index 0000000..53a1aad Binary files /dev/null and b/admin/cards/5_of_spades.png differ diff --git a/admin/cards/6_of_clubs.png b/admin/cards/6_of_clubs.png new file mode 100644 index 0000000..a9660a0 Binary files /dev/null and b/admin/cards/6_of_clubs.png differ diff --git a/admin/cards/6_of_diamonds.png b/admin/cards/6_of_diamonds.png new file mode 100644 index 0000000..78a80ad Binary files /dev/null and b/admin/cards/6_of_diamonds.png differ diff --git a/admin/cards/6_of_hearts.png b/admin/cards/6_of_hearts.png new file mode 100644 index 0000000..361643e Binary files /dev/null and b/admin/cards/6_of_hearts.png differ diff --git a/admin/cards/6_of_spades.png b/admin/cards/6_of_spades.png new file mode 100644 index 0000000..40242a7 Binary files /dev/null and b/admin/cards/6_of_spades.png differ diff --git a/admin/cards/7_of_clubs.png b/admin/cards/7_of_clubs.png new file mode 100644 index 0000000..9d6b545 Binary files /dev/null and b/admin/cards/7_of_clubs.png differ diff --git a/admin/cards/7_of_diamonds.png b/admin/cards/7_of_diamonds.png new file mode 100644 index 0000000..6ad5f15 Binary files /dev/null and b/admin/cards/7_of_diamonds.png differ diff --git a/admin/cards/7_of_hearts.png b/admin/cards/7_of_hearts.png new file mode 100644 index 0000000..19b89a2 Binary files /dev/null and b/admin/cards/7_of_hearts.png differ diff --git a/admin/cards/7_of_spades.png b/admin/cards/7_of_spades.png new file mode 100644 index 0000000..b9f1b93 Binary files /dev/null and b/admin/cards/7_of_spades.png differ diff --git a/admin/cards/8_of_clubs.png b/admin/cards/8_of_clubs.png new file mode 100644 index 0000000..cec743c Binary files /dev/null and b/admin/cards/8_of_clubs.png differ diff --git a/admin/cards/8_of_diamonds.png b/admin/cards/8_of_diamonds.png new file mode 100644 index 0000000..ed12951 Binary files /dev/null and b/admin/cards/8_of_diamonds.png differ diff --git a/admin/cards/8_of_hearts.png b/admin/cards/8_of_hearts.png new file mode 100644 index 0000000..fb39723 Binary files /dev/null and b/admin/cards/8_of_hearts.png differ diff --git a/admin/cards/8_of_spades.png b/admin/cards/8_of_spades.png new file mode 100644 index 0000000..b6b3b38 Binary files /dev/null and b/admin/cards/8_of_spades.png differ diff --git a/admin/cards/9_of_clubs.png b/admin/cards/9_of_clubs.png new file mode 100644 index 0000000..2174db5 Binary files /dev/null and b/admin/cards/9_of_clubs.png differ diff --git a/admin/cards/9_of_diamonds.png b/admin/cards/9_of_diamonds.png new file mode 100644 index 0000000..0b933fb Binary files /dev/null and b/admin/cards/9_of_diamonds.png differ diff --git a/admin/cards/9_of_hearts.png b/admin/cards/9_of_hearts.png new file mode 100644 index 0000000..7b196d6 Binary files /dev/null and b/admin/cards/9_of_hearts.png differ diff --git a/admin/cards/9_of_spades.png b/admin/cards/9_of_spades.png new file mode 100644 index 0000000..3c3b5ff Binary files /dev/null and b/admin/cards/9_of_spades.png differ diff --git a/admin/cards/ace_of_clubs.png b/admin/cards/ace_of_clubs.png new file mode 100644 index 0000000..42bf5ec Binary files /dev/null and b/admin/cards/ace_of_clubs.png differ diff --git a/admin/cards/ace_of_diamonds.png b/admin/cards/ace_of_diamonds.png new file mode 100644 index 0000000..79cd3b8 Binary files /dev/null and b/admin/cards/ace_of_diamonds.png differ diff --git a/admin/cards/ace_of_hearts.png b/admin/cards/ace_of_hearts.png new file mode 100644 index 0000000..b422124 Binary files /dev/null and b/admin/cards/ace_of_hearts.png differ diff --git a/admin/cards/ace_of_spades.png b/admin/cards/ace_of_spades.png new file mode 100644 index 0000000..103f56d Binary files /dev/null and b/admin/cards/ace_of_spades.png differ diff --git a/admin/cards/jack_of_clubs.png b/admin/cards/jack_of_clubs.png new file mode 100644 index 0000000..5e003be Binary files /dev/null and b/admin/cards/jack_of_clubs.png differ diff --git a/admin/cards/jack_of_diamonds.png b/admin/cards/jack_of_diamonds.png new file mode 100644 index 0000000..131a977 Binary files /dev/null and b/admin/cards/jack_of_diamonds.png differ diff --git a/admin/cards/jack_of_hearts.png b/admin/cards/jack_of_hearts.png new file mode 100644 index 0000000..bf342bc Binary files /dev/null and b/admin/cards/jack_of_hearts.png differ diff --git a/admin/cards/jack_of_spades.png b/admin/cards/jack_of_spades.png new file mode 100644 index 0000000..f539c19 Binary files /dev/null and b/admin/cards/jack_of_spades.png differ diff --git a/admin/cards/king_of_clubs.png b/admin/cards/king_of_clubs.png new file mode 100644 index 0000000..68e5774 Binary files /dev/null and b/admin/cards/king_of_clubs.png differ diff --git a/admin/cards/king_of_diamonds.png b/admin/cards/king_of_diamonds.png new file mode 100644 index 0000000..e21d6a0 Binary files /dev/null and b/admin/cards/king_of_diamonds.png differ diff --git a/admin/cards/king_of_hearts.png b/admin/cards/king_of_hearts.png new file mode 100644 index 0000000..1d3c468 Binary files /dev/null and b/admin/cards/king_of_hearts.png differ diff --git a/admin/cards/king_of_spades.png b/admin/cards/king_of_spades.png new file mode 100644 index 0000000..2edbbc1 Binary files /dev/null and b/admin/cards/king_of_spades.png differ diff --git a/admin/cards/queen_of_clubs.png b/admin/cards/queen_of_clubs.png new file mode 100644 index 0000000..7be5f9a Binary files /dev/null and b/admin/cards/queen_of_clubs.png differ diff --git a/admin/cards/queen_of_diamonds.png b/admin/cards/queen_of_diamonds.png new file mode 100644 index 0000000..928f650 Binary files /dev/null and b/admin/cards/queen_of_diamonds.png differ diff --git a/admin/cards/queen_of_hearts.png b/admin/cards/queen_of_hearts.png new file mode 100644 index 0000000..21839e6 Binary files /dev/null and b/admin/cards/queen_of_hearts.png differ diff --git a/admin/cards/queen_of_spades.png b/admin/cards/queen_of_spades.png new file mode 100644 index 0000000..7983d03 Binary files /dev/null and b/admin/cards/queen_of_spades.png differ diff --git a/admin/range.css b/admin/range.css new file mode 100644 index 0000000..7eb67af --- /dev/null +++ b/admin/range.css @@ -0,0 +1,82 @@ +/* ハンドレンジ学習(管理者専用)。基本のデザインは /assets/style.css を使う */ + +.range-wrap { padding-bottom: 32px; } + +/* タブ */ +.seg { display: flex; gap: 6px; background: var(--panel); border-radius: 12px; padding: 4px; margin: 12px 0 16px; } +.seg-btn { + flex: 1; + font: inherit; + font-size: 15px; + font-weight: 800; + border: 0; + border-radius: 9px; + padding: 10px 6px; + background: transparent; + color: var(--muted); + cursor: pointer; +} +.seg-btn.active { background: var(--panel-2); color: var(--text); } + +/* レンジ表の分類ごとの色(レンジ表のデータとしての色なのでサイトの配色とは別) */ +.cat-navy { --c: #20365d; --fg: #fff; } +.cat-red { --c: #9e342e; --fg: #fff; } +.cat-yellow { --c: #ddb356; --fg: #14191f; } +.cat-green { --c: #4e8051; --fg: #101910; } +.cat-blue { --c: #6aa5c9; --fg: #14191f; } +.cat-white { --c: #f7f7f7; --fg: #222; } +.cat-gray, .cat-purple { --c: #929292; --fg: #222; } + +/* 13×13 の表 */ +.range-matrix { display: grid; grid-template-columns: repeat(13, 1fr); gap: 2px; } +.range-cell { + aspect-ratio: 1; + min-width: 0; + padding: 0; + border: 0; + border-radius: 3px; + background: var(--c); + color: var(--fg); + font: inherit; + font-size: clamp(8px, 2.4vw, 12px); + font-weight: 800; + letter-spacing: -0.04em; + cursor: pointer; + position: relative; +} +.range-cell.cat-purple { box-shadow: inset 0 0 0 2px #c12cff; } +.range-cell.selected { outline: 2px solid var(--gold); outline-offset: 1px; z-index: 1; } + +/* 凡例 */ +.range-legend { display: flex; flex-wrap: wrap; gap: 6px 12px; margin-top: 12px; font-size: 12px; color: var(--muted); } +.range-legend span { display: inline-flex; align-items: center; gap: 6px; } +.swatch-dot { width: 14px; height: 14px; border-radius: 4px; background: var(--c); flex: none; } +.swatch-dot.cat-purple { box-shadow: inset 0 0 0 2px #c12cff; } + +/* 選択したハンド */ +.sel-hand { font-size: 28px; font-weight: 900; line-height: 1.1; } +.sel-label { display: flex; align-items: center; gap: 8px; font-weight: 800; margin-top: 6px; } + +/* クイズ */ +.quiz-progress { height: 6px; background: var(--input); border-radius: 999px; overflow: hidden; margin: 16px 0 8px; } +.quiz-progress i { display: block; height: 100%; width: 0; background: var(--green); transition: width 0.2s; } +.quiz-stats { display: flex; justify-content: center; gap: 12px; font-size: 13px; color: var(--muted); } +.quiz-hand { text-align: center; margin: 12px 0; padding: 18px 12px; } +.quiz-cards { display: flex; justify-content: center; gap: 10px; min-height: 0; } +.quiz-cards img { width: min(32%, 120px); height: auto; filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.4)); } +.quiz-hand-name { font-size: 40px; font-weight: 900; margin-top: 8px; line-height: 1.1; } +.quiz-answers { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; } +.quiz-answers .btn { + font-size: 15px; + padding: 12px 8px; + justify-content: flex-start; + background: var(--panel-2); + color: var(--text); + border: 2px solid transparent; +} +.quiz-answers .btn:disabled { opacity: 1; } +.quiz-answers .btn.correct { border-color: var(--green); background: rgba(46, 204, 113, 0.18); } +.quiz-answers .btn.wrong { border-color: var(--red); background: rgba(255, 90, 90, 0.15); } +.quiz-feedback { margin-top: 12px; padding: 12px 14px; border-radius: 10px; background: var(--panel); font-size: 14px; } +.quiz-feedback.good { box-shadow: inset 0 0 0 2px var(--green); } +.quiz-feedback.bad { box-shadow: inset 0 0 0 2px var(--red); } diff --git a/admin/range.html b/admin/range.html new file mode 100644 index 0000000..aca7443 --- /dev/null +++ b/admin/range.html @@ -0,0 +1,75 @@ + + + + + + + + ハンドレンジ - Hold'em Night + + + + + +
+
+ ← +

ハンドレンジ

+
+ +
+ + +
+ + +
+
+
+ +
+
—
+
セルをタップすると分類を確認できます
+
+ + +
+ + + +
+ + + + diff --git a/admin/range.js b/admin/range.js new file mode 100644 index 0000000..194cb66 --- /dev/null +++ b/admin/range.js @@ -0,0 +1,222 @@ +// ハンドレンジ学習(管理者専用): レンジ表と色・分類クイズ +(function () { + 'use strict'; + + const $ = (id) => document.getElementById(id); + const RANKS = ['A', 'K', 'Q', 'J', 'T', '9', '8', '7', '6', '5', '4', '3', '2']; + + const CATEGORIES = [ + { id: 'navy', label: '8人(強)' }, + { id: 'red', label: '8人(弱)' }, + { id: 'yellow', label: '6〜7人' }, + { id: 'green', label: '4〜5人' }, + { id: 'blue', label: '3人' }, + { id: 'white', label: '2人' }, + { id: 'purple', label: '紫枠' }, + { id: 'gray', label: 'フォールド' }, + ]; + + // 全169ハンドの分類(載っていないハンドはフォールド)。紫枠はクイズ上の独立した回答 + const RANGE = { + AA: 'navy', AKs: 'navy', AQs: 'red', AJs: 'red', ATs: 'red', A9s: 'green', A8s: 'green', A7s: 'green', A6s: 'green', A5s: 'green', A4s: 'green', A3s: 'green', A2s: 'green', + AKo: 'navy', KK: 'navy', KQs: 'red', KJs: 'yellow', KTs: 'green', K9s: 'green', K8s: 'white', K7s: 'white', K6s: 'white', K5s: 'white', K4s: 'white', K3s: 'white', K2s: 'white', + AQo: 'red', KQo: 'yellow', QQ: 'navy', QJs: 'yellow', QTs: 'green', Q9s: 'blue', Q8s: 'white', Q7s: 'white', Q6s: 'white', Q5s: 'purple', Q4s: 'purple', Q3s: 'purple', Q2s: 'purple', + AJo: 'yellow', KJo: 'green', QJo: 'blue', JJ: 'red', JTs: 'yellow', J9s: 'blue', J8s: 'white', J7s: 'white', J6s: 'purple', + ATo: 'green', KTo: 'blue', QTo: 'white', JTo: 'blue', TT: 'red', T9s: 'green', T8s: 'blue', T7s: 'purple', + A9o: 'blue', K9o: 'white', Q9o: 'white', J9o: 'white', T9o: 'white', 99: 'red', '98s': 'blue', '97s': 'white', '96s': 'purple', + A8o: 'white', '98o': 'purple', 88: 'yellow', '87s': 'white', '86s': 'purple', + A7o: 'white', 77: 'yellow', '76s': 'white', '75s': 'purple', + A6o: 'purple', 66: 'green', '65s': 'white', '64s': 'purple', + 55: 'green', '54s': 'purple', + 44: 'blue', + 33: 'blue', + 22: 'blue', + }; + + const handAt = (r, c) => (r === c ? RANKS[r] + RANKS[c] : r < c ? RANKS[r] + RANKS[c] + 's' : RANKS[c] + RANKS[r] + 'o'); + const categoryOf = (hand) => RANGE[hand] || 'gray'; + const labelOf = (id) => (CATEGORIES.find((c) => c.id === id) || CATEGORIES[CATEGORIES.length - 1]).label; + + function swatch(id) { + const s = document.createElement('span'); + s.className = `swatch-dot cat-${id}`; + return s; + } + + // ---------- タブ ---------- + function showView(name) { + $('view-range').hidden = name !== 'range'; + $('view-quiz').hidden = name !== 'quiz'; + for (const b of $('tabs').children) b.classList.toggle('active', b.dataset.view === name); + } + $('tabs').addEventListener('click', (e) => { + const b = e.target.closest('[data-view]'); + if (b) showView(b.dataset.view); + }); + + // ---------- レンジ表 ---------- + for (const c of CATEGORIES) { + const item = document.createElement('span'); + item.append(swatch(c.id), c.label); + $('legend').append(item); + } + + for (let r = 0; r < 13; r++) { + for (let c = 0; c < 13; c++) { + const hand = handAt(r, c); + const cat = categoryOf(hand); + const b = document.createElement('button'); + b.type = 'button'; + b.className = `range-cell cat-${cat}`; + b.textContent = hand; + b.title = `${hand}:${labelOf(cat)}`; + b.addEventListener('click', () => select(b, hand, cat)); + $('matrix').append(b); + } + } + + function select(cell, hand, cat) { + document.querySelectorAll('.range-cell.selected').forEach((x) => x.classList.remove('selected')); + cell.classList.add('selected'); + const box = $('selected'); + box.replaceChildren(); + const name = document.createElement('div'); + name.className = 'sel-hand'; + name.textContent = hand; + const label = document.createElement('div'); + label.className = 'sel-label'; + label.append(swatch(cat), labelOf(cat)); + const note = document.createElement('div'); + note.className = 'hint'; + note.textContent = cat === 'purple' ? '灰色+紫枠。クイズの答えは「紫枠」です。' : 'このハンドの分類です。'; + box.append(name, label, note); + } + + $('start-from-range').addEventListener('click', () => { + showView('quiz'); + startQuiz(); + }); + + // ---------- クイズ ---------- + const SUITS = ['hearts', 'clubs', 'diamonds', 'spades']; + const RANK_FILE = { A: 'ace', K: 'king', Q: 'queen', J: 'jack', T: '10' }; + let quizCount = 10; + let deck = []; + let index = 0; + let correct = 0; + let answered = 0; + let locked = false; + + $('count').addEventListener('click', (e) => { + const b = e.target.closest('[data-count]'); + if (!b) return; + quizCount = Number(b.dataset.count); + for (const x of $('count').children) x.classList.toggle('active', x === b); + }); + + function shuffle(a) { + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; + } + + function startQuiz() { + const pool = []; + for (let r = 0; r < 13; r++) for (let c = 0; c < 13; c++) pool.push(handAt(r, c)); + deck = shuffle(pool); + if (quizCount > 0) deck = deck.slice(0, quizCount); + index = 0; + correct = 0; + answered = 0; + $('start-quiz').textContent = '最初から'; + renderQuestion(); + } + + // スーテッドは同じマーク、それ以外は別々のマーク + function showCards(hand) { + const s1 = SUITS[Math.floor(Math.random() * 4)]; + let s2 = s1; + if (hand[2] !== 's') while (s2 === s1) s2 = SUITS[Math.floor(Math.random() * 4)]; + [hand[0], hand[1]].forEach((rank, i) => { + const img = $(`card-${i + 1}`); + const suit = i === 0 ? s1 : s2; + img.hidden = true; + img.onload = () => { img.hidden = false; }; + img.onerror = () => { img.hidden = true; }; + img.alt = `${RANK_FILE[rank] || rank} of ${suit}`; + img.src = `/range/files/cards/${RANK_FILE[rank] || rank}_of_${suit}.png`; + }); + } + + function updateStats() { + $('q-score').textContent = `正解 ${correct} / ${answered}`; + $('q-accuracy').textContent = `正答率 ${answered ? Math.round((correct / answered) * 100) + '%' : '--'}`; + } + + function renderQuestion() { + locked = false; + $('answers').replaceChildren(); + $('feedback').hidden = true; + $('next-question').disabled = true; + if (index >= deck.length) return finishQuiz(); + + const hand = deck[index]; + $('q-hand').textContent = hand; + $('q-prompt').textContent = 'このハンドの色・分類は?'; + $('q-count').textContent = `問題 ${index + 1} / ${deck.length}`; + $('progress-bar').style.width = `${(index / deck.length) * 100}%`; + updateStats(); + showCards(hand); + + for (const cat of CATEGORIES) { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'btn'; + b.dataset.cat = cat.id; + b.append(swatch(cat.id), cat.label); + b.addEventListener('click', () => answer(cat.id, b)); + $('answers').append(b); + } + } + + function answer(choice, button) { + if (locked) return; + locked = true; + const hand = deck[index]; + const right = categoryOf(hand); + const ok = choice === right; + if (ok) correct++; + answered++; + for (const b of $('answers').children) { + if (b.dataset.cat === right) b.classList.add('correct'); + else if (b === button) b.classList.add('wrong'); + b.disabled = true; + } + const fb = $('feedback'); + fb.className = `quiz-feedback ${ok ? 'good' : 'bad'}`; + fb.textContent = `${ok ? '正解!' : '不正解'} ${hand} は「${labelOf(right)}」です。`; + fb.hidden = false; + updateStats(); + $('next-question').disabled = false; + } + + function finishQuiz() { + $('q-hand').textContent = '終了!'; + $('q-prompt').textContent = `${deck.length}問中 ${correct}問正解(${deck.length ? Math.round((correct / deck.length) * 100) : 0}%)`; + $('q-count').textContent = '完了'; + $('progress-bar').style.width = '100%'; + $('card-1').hidden = $('card-2').hidden = true; + const fb = $('feedback'); + fb.className = 'quiz-feedback'; + fb.textContent = '「最初から」で再挑戦できます。'; + fb.hidden = false; + } + + $('start-quiz').addEventListener('click', startQuiz); + $('next-question').addEventListener('click', () => { + index++; + renderQuestion(); + }); +})(); diff --git a/public/assets/key.js b/public/assets/key.js index fcc0e56..23d4918 100644 --- a/public/assets/key.js +++ b/public/assets/key.js @@ -8,6 +8,12 @@ const submitBtn = document.getElementById('submit'); let busy = false; + // /key?admin … 入室済みの人が管理者キーを入れるための画面 + if (new URLSearchParams(location.search).has('admin')) { + document.getElementById('key-title').textContent = '管理者キーを入力'; + document.getElementById('key-hint').textContent = '管理者用の6桁の数字'; + } + const value = () => inputs.map((i) => i.value).join(''); function fill(digits, from) { diff --git a/public/assets/lobby.js b/public/assets/lobby.js index 5c5abfd..6fae91e 100644 --- a/public/assets/lobby.js +++ b/public/assets/lobby.js @@ -22,6 +22,11 @@ return false; } + // 管理者だけにハンドレンジ学習へのリンクを出す(ページ自体もサーバ側で管理者以外は 404) + App.api('GET', '/api/me') + .then((me) => { document.getElementById('range-link').hidden = !me.admin; }) + .catch(() => {}); + document.getElementById('create').addEventListener('click', () => { if (requireName()) location.href = '/create'; }); diff --git a/public/key.html b/public/key.html index c96c0e7..7c85b91 100644 --- a/public/key.html +++ b/public/key.html @@ -14,8 +14,8 @@
-

参加キーを入力

-

オーナーから教えてもらった6桁の数字

+

参加キーを入力

+

オーナーから教えてもらった6桁の数字

diff --git a/public/lobby.html b/public/lobby.html index f67aa16..366142f 100644 --- a/public/lobby.html +++ b/public/lobby.html @@ -18,6 +18,7 @@
+
開催中のテーブル diff --git a/server/auth.js b/server/auth.js index d4cf145..1514ec9 100644 --- a/server/auth.js +++ b/server/auth.js @@ -25,11 +25,13 @@ function loadSecret() { return secret; } -// 鍵を変えると既存の Cookie はすべて無効になる -const AUTH_TOKEN = crypto - .createHmac('sha256', loadSecret()) - .update('access:' + config.ACCESS_KEY) - .digest('base64url'); +// Cookie の値はキーと役割から作る。キーを変えると既存の Cookie はすべて無効になる +const SECRET = loadSecret(); +const tokenFor = (role, key) => crypto.createHmac('sha256', SECRET).update(role + ':' + key).digest('base64url'); +const TOKENS = { + member: tokenFor('access', config.ACCESS_KEY), // 以前と同じ値(既存の Cookie はそのまま有効) + admin: config.ADMIN_KEY ? tokenFor('admin', config.ADMIN_KEY) : null, +}; function parseCookies(header) { const out = {}; @@ -51,18 +53,31 @@ function safeEqual(a, b) { return crypto.timingSafeEqual(ha, hb); } -function isAuthedCookieHeader(cookieHeader) { +// 'admin' / 'member' / null +function roleOf(cookieHeader) { const value = parseCookies(cookieHeader)[COOKIE_NAME]; - return Boolean(value) && safeEqual(value, AUTH_TOKEN); + if (!value) return null; + // どちらも必ず比較する(どちらに一致したかを処理時間から推測させない) + const admin = TOKENS.admin ? safeEqual(value, TOKENS.admin) : false; + const member = safeEqual(value, TOKENS.member); + return admin ? 'admin' : member ? 'member' : null; +} + +function isAuthedCookieHeader(cookieHeader) { + return roleOf(cookieHeader) !== null; } function isAuthed(req) { return isAuthedCookieHeader(req.headers.cookie); } -function setAuthCookie(req, res) { +function isAdmin(req) { + return roleOf(req.headers.cookie) === 'admin'; +} + +function setAuthCookie(req, res, role) { const parts = [ - `${COOKIE_NAME}=${AUTH_TOKEN}`, + `${COOKIE_NAME}=${TOKENS[role]}`, 'Path=/', `Max-Age=${COOKIE_MAX_AGE_SEC}`, 'HttpOnly', @@ -163,7 +178,10 @@ function handleLogin(req, res) { if (!/^\d{6}$/.test(input)) { return res.status(400).json({ error: '6桁の数字を入力してください。' }); } - if (!safeEqual(input, config.ACCESS_KEY)) { + // どちらも必ず比較する + const adminMatch = config.ADMIN_KEY ? safeEqual(input, config.ADMIN_KEY) : false; + const memberMatch = safeEqual(input, config.ACCESS_KEY); + if (!adminMatch && !memberMatch) { w.count++; global.count++; console.warn(`[auth] 鍵の入力ミス from ${key} (${w.count}/${config.AUTH_MAX_FAILURES}, 全体 ${global.count}/${config.AUTH_GLOBAL_MAX_FAILURES})`); @@ -174,15 +192,18 @@ function handleLogin(req, res) { return res.status(401).json({ error: left > 0 ? `鍵が違います。(あと${left}回)` : '鍵が違います。' }); } perClient.delete(key); - console.log(`[auth] 入室 from ${key}`); - setAuthCookie(req, res); - res.json({ ok: true }); + // 管理者がメンバー用のキーを入れ直しても管理者のまま + const role = adminMatch || isAdmin(req) ? 'admin' : 'member'; + console.log(`[auth] 入室 from ${key}${role === 'admin' ? '(管理者)' : ''}`); + setAuthCookie(req, res, role); + res.json({ ok: true, admin: role === 'admin' }); } // /key 以外のページ・API を保護する function requireAuth(req, res, next) { - if (isAuthed(req)) { - if (req.method === 'GET' && !req.path.startsWith('/api/')) setAuthCookie(req, res); + const role = roleOf(req.headers.cookie); + if (role) { + if (req.method === 'GET' && !req.path.startsWith('/api/')) setAuthCookie(req, res, role); return next(); } if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'unauthorized' }); @@ -190,6 +211,12 @@ function requireAuth(req, res, next) { res.status(401).end(); } +// 管理者専用。管理者以外にはページがあること自体を知らせない(404) +function requireAdmin(req, res, next) { + if (isAdmin(req)) return next(); + res.status(404).type('text/plain').send('Not Found'); +} + // 状態を変える API は同じサイトからのみ受け付ける function requireSameOrigin(req, res, next) { if (req.method === 'GET' || req.method === 'HEAD' || isSameOrigin(req.headers)) return next(); @@ -203,12 +230,14 @@ function safeNext(next) { module.exports = { isAuthed, + isAdmin, isAuthedCookieHeader, isSameOrigin, isAllowedHost, requireAllowedHost, handleLogin, requireAuth, + requireAdmin, requireSameOrigin, safeNext, }; diff --git a/server/config.js b/server/config.js index f0eef7e..40da189 100644 --- a/server/config.js +++ b/server/config.js @@ -30,6 +30,13 @@ if (!ACCESS_KEY || !/^\d{6}$/.test(ACCESS_KEY)) { process.exit(1); } +// 管理者キー(任意)。入力すると管理者として入室でき、ハンドレンジ学習ページが見える +const ADMIN_KEY = process.env.ADMIN_KEY || null; +if (ADMIN_KEY && (!/^\d{6}$/.test(ADMIN_KEY) || ADMIN_KEY === ACCESS_KEY)) { + console.error('[config] ADMIN_KEY は ACCESS_KEY と異なる6桁の数字にしてください。'); + process.exit(1); +} + module.exports = { PORT: Number(process.env.PORT) || 4671, @@ -43,6 +50,7 @@ module.exports = { // 参加キー(6桁の数字)。.env の ACCESS_KEY ACCESS_KEY, + ADMIN_KEY, // 1テーブルの最大着席数 MAX_SEATS: 9, diff --git a/server/index.js b/server/index.js index 8d5dd6d..05d244c 100644 --- a/server/index.js +++ b/server/index.js @@ -9,7 +9,9 @@ const auth = require('./auth'); const { TableManager } = require('./tables'); const PUBLIC = path.join(__dirname, '..', 'public'); -const page = (file) => (req, res) => res.sendFile(path.join(PUBLIC, file)); +// 管理者専用のファイル(public の外に置き、管理者の Cookie があるときだけ返す) +const ADMIN = path.join(__dirname, '..', 'admin'); +const page = (file, dir = PUBLIC) => (req, res) => res.sendFile(path.join(dir, file)); const app = express(); app.set('trust proxy', 'loopback'); // nginx 等のリバースプロキシ越しでも req.ip / req.secure を正しく @@ -70,7 +72,9 @@ app.use('/assets', express.static(path.join(PUBLIC, 'assets'), { index: false, d app.get('/favicon.ico', (req, res) => res.sendFile(path.join(PUBLIC, 'assets', 'favicon.ico'))); app.get('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nDisallow: /\n')); app.get('/key', (req, res) => { - if (auth.isAuthed(req)) return res.redirect(auth.safeNext(req.query.next)); + // 入室済みなら飛ばす。ただし /key?admin は管理者キーを入れるために開ける + const wantsAdmin = req.query.admin !== undefined && !auth.isAdmin(req); + if (auth.isAuthed(req) && !wantsAdmin) return res.redirect(auth.safeNext(req.query.next)); page('key.html')(req, res); }); app.post('/api/auth', auth.handleLogin); @@ -83,6 +87,20 @@ app.get('/', page('lobby.html')); app.get('/create', page('create.html')); app.get('/table/:id', page('table.html')); +// ---------- 管理者専用: ハンドレンジ学習 ---------- +app.get('/range', auth.requireAdmin, page('range.html', ADMIN)); +app.use( + '/range/files', + auth.requireAdmin, + express.static(ADMIN, { + index: false, + dotfiles: 'deny', + // カード画像を毎回読み込まないよう、本人のブラウザにだけキャッシュさせる + setHeaders: (res) => res.setHeader('Cache-Control', 'private, max-age=86400'), + }) +); + +app.get('/api/me', (req, res) => res.json({ admin: auth.isAdmin(req) })); app.get('/api/tables', (req, res) => res.json(tables.list())); app.get('/api/tables/suggest-name', (req, res) => res.json({ name: tables.suggestName() }));