Add admin role with an admin-only hand range study page

- Optional ADMIN_KEY (6 digits, distinct from ACCESS_KEY): entering it on the
  key screen signs in as admin (role-specific HMAC cookie; member cookies
  stay valid). Logged-in members can open /key?admin to enter it.
- /range (range chart + color/category quiz, ported from poker-hand and
  restyled to the site design) and its files under admin/ are served only to
  admins; everyone else gets 404. Card images cached privately for a day.
- Lobby shows a link to the page for admins only (GET /api/me).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
agomizi1cmandClaude Opus 5.5 committed 2026-10-16 12:13:14 +09:00
1 parent 1e1ac4e91d
commit e89d867f85
64 files changed
+477 -19

No files matched your search

+222
View File
@@ -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();
});
})();