Files
Poker/admin/range.js
T
agomizi1cmandClaude Opus 5.5 e89d867f85 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>
2026-10-16 12:13:14 +09:00

223 lines
8.2 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ハンドレンジ学習(管理者専用): レンジ表と色・分類クイズ
(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();
});
})();