Compare commits

...
2 Commits
Author SHA1 Message Date
agomizi1cmandClaude Opus 5.5 6d33440048 Only the table host splits the pot; host buttons beside the table name
- Showdown winners can now be chosen only by the table host (others see
  "ホストが勝者を選んでいます"); if the host leaves, the next host does it
- START / pause moved into the header to the right of the table name
  (pause is an icon button), freeing the row they used to occupy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:15:19 +09:00
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
69 changed files with 527 additions and 53 deletions

No files matched your search

+2
View File
@@ -1,5 +1,7 @@
# .env にコピーして値を設定する(chmod 600 推奨) # .env にコピーして値を設定する(chmod 600 推奨)
ACCESS_KEY=000000 ACCESS_KEY=000000
# 管理者キー(任意・ACCESS_KEY と別の6桁)。入力すると管理者として入室し、ハンドレンジ学習ページが見える
# ADMIN_KEY=000001
PORT=4671 PORT=4671
# HOST=127.0.0.1 # HOST=127.0.0.1
# 本番のみ設定(ローカル開発では設定しない) # 本番のみ設定(ローカル開発では設定しない)
+12 -2
View File
@@ -13,7 +13,7 @@
- ブラインドは自動で支払われ、ハンド開始時のレベルで固定。ヘッズアップはボタンが SB。 - ブラインドは自動で支払われ、ハンド開始時のレベルで固定。ヘッズアップはボタンが SB。
- 各自が自分のスマホで Fold / Check / Call / Bet / Raise を行う。最低レイズ額は直前のレイズ幅以上。足りないオールインのレイズは、すでにアクションした人に再レイズ権を与えない。 - 各自が自分のスマホで Fold / Check / Call / Bet / Raise を行う。最低レイズ額は直前のレイズ幅以上。足りないオールインのレイズは、すでにアクションした人に再レイズ権を与えない。
- ベッティングが終わると次のストリートへ。全員オールインならその時点でショーダウンになる(残りのカードは実物でめくる)。 - ベッティングが終わると次のストリートへ。全員オールインならその時点でショーダウンになる(残りのカードは実物でめくる)。
- **ショーダウン**: カードを見せ合い、勝った人を画面で選んで確定する(選べるのはショーダウンに残った人とホスト)。サイドポットがあるときはポットごとに選ぶ。複数選ぶと山分け(端数はボタンの左から)。 - **ショーダウン**: カードを見せ合い、ホストが勝った人を画面で選んで確定する(ポットの分配はテーブルのホストだけが行える)。サイドポットがあるときはポットごとに選ぶ。複数選ぶと山分け(端数はボタンの左から)。
- 全員フォールドしたら残った人が自動でポットを獲得。誰もコールしなかった分は自動で返る。 - 全員フォールドしたら残った人が自動でポットを獲得。誰もコールしなかった分は自動で返る。
- 「離席」にすると次のハンドから配られない(手番が来たら自動でチェック/フォールド)。「復帰する」で戻る。 - 「離席」にすると次のハンドから配られない(手番が来たら自動でチェック/フォールド)。「復帰する」で戻る。
- チップがなくなったら開始スタックでリバイできる。画面下に収支(現在のチップ − バイイン合計)を表示。 - チップがなくなったら開始スタックでリバイできる。画面下に収支(現在のチップ − バイイン合計)を表示。
@@ -37,11 +37,20 @@ http://localhost:4671 を開く。
| 変数 | 既定値 | 内容 | | 変数 | 既定値 | 内容 |
| --- | --- | --- | | --- | --- | --- |
| `ACCESS_KEY` | なし(必須) | 6桁の参加キー | | `ACCESS_KEY` | なし(必須) | 6桁の参加キー |
| `ADMIN_KEY` | なし(任意) | 6桁の管理者キー(`ACCESS_KEY` と別の値)。入力すると管理者として入室する |
| `PORT` | `4671` | 待ち受けポート | | `PORT` | `4671` | 待ち受けポート |
| `HOST` | `127.0.0.1` | 待ち受けアドレス。既定では外部から直接つながらない(nginx 経由で公開する) | | `HOST` | `127.0.0.1` | 待ち受けアドレス。既定では外部から直接つながらない(nginx 経由で公開する) |
| `PUBLIC_ORIGIN` | なし | 本番の公開URL `https://poker.kanawo.net`。設定するとこのドメイン以外(IP直打ちや別ドメイン)へのアクセスと、他オリジンからの操作を拒否する。ローカル開発では設定しない | | `PUBLIC_ORIGIN` | なし | 本番の公開URL `https://poker.kanawo.net`。設定するとこのドメイン以外(IP直打ちや別ドメイン)へのアクセスと、他オリジンからの操作を拒否する。ローカル開発では設定しない |
| `SESSION_SECRET` | 自動生成 | Cookie 署名用。未指定なら `data/session-secret` に生成・保存 | | `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日)が延長されるので、以降は入力不要。 - **参加キー**: 正しいキーを入れたブラウザに HttpOnly / SameSite=Lax(HTTPS 時は Secure)の Cookie を保存。アクセスのたびに有効期限(400日)が延長されるので、以降は入力不要。
@@ -67,10 +76,11 @@ http://localhost:4671 を開く。
| `server/config.js` | `.env` の読み込みと各種上限値 | | `server/config.js` | `.env` の読み込みと各種上限値 |
| `public/assets/blinds.js` | ブラインド計算(サーバ・ブラウザ共用) | | `public/assets/blinds.js` | ブラインド計算(サーバ・ブラウザ共用) |
| `public/*.html`, `public/assets/*.js` | 各画面 | | `public/*.html`, `public/assets/*.js` | 各画面 |
| `admin/` | 管理者専用ページ(ハンドレンジ学習・カード画像) |
- テーブルはメモリ上にのみ保持(サーバ再起動で消える)。誰も接続していない状態が30分続くと自動削除。 - テーブルはメモリ上にのみ保持(サーバ再起動で消える)。誰も接続していない状態が30分続くと自動削除。
- 名前とプレイヤーID はブラウザの localStorage に保存。リロードしても同じ席に戻れる。 - 名前とプレイヤーID はブラウザの localStorage に保存。リロードしても同じ席に戻れる。
- 作成者がホスト(START・一時停止ができる)。ホストが退席すると次の着席者に移る。 - 作成者がホスト(START・一時停止・ショーダウン後のポットの分配ができる。ボタンはテーブル名の右に出る)。ホストが退席すると次の着席者に移る。
## VPS へのデプロイ例(Ubuntu + nginx + systemd + HTTPS) ## VPS へのデプロイ例(Ubuntu + nginx + systemd + HTTPS)
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

+82
View File
@@ -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); }
+75
View File
@@ -0,0 +1,75 @@
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#24493b">
<meta name="robots" content="noindex, nofollow">
<title>ハンドレンジ - Hold'em Night</title>
<link rel="icon" href="/assets/favicon.ico">
<link rel="stylesheet" href="/assets/style.css">
<link rel="stylesheet" href="/range/files/range.css">
</head>
<body>
<main class="wrap range-wrap">
<div class="topbar">
<a class="icon-btn" href="/" aria-label="ロビーへ戻る">←</a>
<h1>ハンドレンジ</h1>
</div>
<div class="seg" id="tabs">
<button type="button" class="seg-btn active" data-view="range">レンジ表</button>
<button type="button" class="seg-btn" data-view="quiz">クイズ</button>
</div>
<!-- レンジ表 -->
<section id="view-range">
<div class="range-matrix" id="matrix" aria-label="13×13 ハンドレンジ表"></div>
<div class="range-legend" id="legend"></div>
<div class="card mt-12" id="selected">
<div class="sel-hand">—</div>
<div class="hint">セルをタップすると分類を確認できます</div>
</div>
<button type="button" class="btn btn-orange btn-block mt-12" id="start-from-range">クイズを始める</button>
</section>
<!-- クイズ -->
<section id="view-quiz" hidden>
<div class="label">問題数(全169ハンドからランダム)</div>
<div class="seg" id="count">
<button type="button" class="seg-btn active" data-count="10">10問</button>
<button type="button" class="seg-btn" data-count="20">20問</button>
<button type="button" class="seg-btn" data-count="0">全問</button>
</div>
<div class="quiz-progress"><i id="progress-bar"></i></div>
<div class="quiz-stats">
<span id="q-count">開始前</span>
<span id="q-score">正解 0 / 0</span>
<span id="q-accuracy">正答率 --</span>
</div>
<div class="card quiz-hand">
<div class="quiz-cards">
<img id="card-1" alt="" hidden>
<img id="card-2" alt="" hidden>
</div>
<div class="quiz-hand-name" id="q-hand">?</div>
<div class="hint" id="q-prompt">「開始」を押してください</div>
</div>
<div class="quiz-answers" id="answers"></div>
<div class="quiz-feedback" id="feedback" aria-live="polite" hidden></div>
<div class="row mt-12">
<button type="button" class="btn btn-dark" id="start-quiz">開始</button>
<button type="button" class="btn btn-blue" id="next-question" disabled>次の問題 →</button>
</div>
</section>
</main>
<script src="/range/files/range.js"></script>
</body>
</html>
+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();
});
})();
+6
View File
@@ -8,6 +8,12 @@
const submitBtn = document.getElementById('submit'); const submitBtn = document.getElementById('submit');
let busy = false; 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(''); const value = () => inputs.map((i) => i.value).join('');
function fill(digits, from) { function fill(digits, from) {
+5
View File
@@ -22,6 +22,11 @@
return false; return false;
} }
// 管理者だけにハンドレンジ学習へのリンクを出す(ページ自体もサーバ側で管理者以外は 404)
App.api('GET', '/api/me')
.then((me) => { document.getElementById('range-link').hidden = !me.admin; })
.catch(() => {});
document.getElementById('create').addEventListener('click', () => { document.getElementById('create').addEventListener('click', () => {
if (requireName()) location.href = '/create'; if (requireName()) location.href = '/create';
}); });
+8 -8
View File
@@ -304,10 +304,10 @@ body.table-page {
.table-page input { -webkit-user-select: text; user-select: text; } .table-page input { -webkit-user-select: text; user-select: text; }
.table-page .wrap { padding-top: 12px; padding-bottom: 0; flex: 1; min-height: 0; display: flex; flex-direction: column; width: 100%; } .table-page .wrap { padding-top: 12px; padding-bottom: 0; flex: 1; min-height: 0; display: flex; flex-direction: column; width: 100%; }
.table-header { display: flex; align-items: center; gap: 12px; } .table-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.table-header h1 { .table-header h1 {
flex: 1; flex: 1;
text-align: center; min-width: 0;
font-size: 22px; font-size: 22px;
font-weight: 800; font-weight: 800;
overflow: hidden; overflow: hidden;
@@ -316,8 +316,10 @@ body.table-page {
} }
.icon-btn.leave { color: var(--red); border-color: rgba(255, 90, 90, 0.35); } .icon-btn.leave { color: var(--red); border-color: rgba(255, 90, 90, 0.35); }
.host-controls { display: flex; justify-content: center; gap: 8px; margin: 16px 0 12px; flex-wrap: wrap; } /* ホスト操作(テーブル名の右) */
.host-controls .btn { border-radius: 999px; padding: 10px 16px; font-size: 14px; } .host-controls { display: flex; align-items: center; gap: 8px; flex: none; }
.host-controls .btn { border-radius: 999px; padding: 9px 14px; font-size: 13px; }
.host-controls .icon-btn.paused { color: var(--green); border-color: rgba(46, 204, 113, 0.5); }
.level-bar { .level-bar {
background: var(--panel); background: var(--panel);
@@ -643,9 +645,8 @@ body.table-page {
.table-page .wrap { padding-top: 8px; } .table-page .wrap { padding-top: 8px; }
.table-header .icon-btn { width: 38px; height: 38px; font-size: 16px; } .table-header .icon-btn { width: 38px; height: 38px; font-size: 16px; }
.table-header h1 { font-size: 18px; } .table-header h1 { font-size: 18px; }
.host-controls { margin: 8px 0 6px; } .table-header { margin-bottom: 8px; }
.host-controls .btn { padding: 7px 14px; } .host-controls .btn { padding: 7px 12px; }
.host-spacer { height: 6px; }
.level-bar { padding: 8px 14px; } .level-bar { padding: 8px 14px; }
.level-sub { display: none; } .level-sub { display: none; }
.felt-area { margin: 40px 0 34px; } .felt-area { margin: 40px 0 34px; }
@@ -677,4 +678,3 @@ body.table-page {
.slash { flex: none !important; color: var(--muted); font-weight: 800; } .slash { flex: none !important; color: var(--muted); font-weight: 800; }
label.switch { margin: 0; display: inline-block; } label.switch { margin: 0; display: inline-block; }
.link-gold { color: var(--gold); } .link-gold { color: var(--gold); }
.host-spacer { height: 12px; }
+5 -3
View File
@@ -121,13 +121,15 @@
// ホスト操作 // ホスト操作
$('host-controls').hidden = !s.isHost; $('host-controls').hidden = !s.isHost;
$('host-spacer').hidden = s.isHost;
const eligible = s.seats.filter((p) => p && !p.sittingOut && p.stack > 0).length; const eligible = s.seats.filter((p) => p && !p.sittingOut && p.stack > 0).length;
$('start').hidden = s.started; $('start').hidden = s.started;
$('start').disabled = eligible < 2; $('start').disabled = eligible < 2;
$('start').title = eligible < 2 ? '2人以上で開始できます' : ''; $('start').title = eligible < 2 ? '2人以上で開始できます' : '';
$('pause').hidden = !s.started; $('pause').hidden = !s.started;
$('pause').textContent = s.paused ? '再開' : '一時停止'; $('pause').textContent = s.paused ? '▶' : '⏸';
$('pause').title = s.paused ? '再開' : '一時停止';
$('pause').setAttribute('aria-label', $('pause').title);
$('pause').classList.toggle('paused', s.paused);
// 自分 // 自分
$('action-bar').hidden = !me; $('action-bar').hidden = !me;
@@ -250,7 +252,7 @@
else if (m.sittingOut) status = '離席中です。復帰すると次のハンドから参加します'; else if (m.sittingOut) status = '離席中です。復帰すると次のハンドから参加します';
else if (m.canRebuy) status = 'チップがなくなりました'; else if (m.canRebuy) status = 'チップがなくなりました';
else if (o) status = o.canCheck ? 'あなたの番です' : `あなたの番です(コール ${chips(o.toCall)})`; else if (o) status = o.canCheck ? 'あなたの番です' : `あなたの番です(コール ${chips(o.toCall)})`;
else if (awaiting) status = m.canAward ? 'カードを見せ合って、勝った人を選んでください' : '勝者の選択を待っています'; else if (awaiting) status = m.canAward ? 'カードを見せ合って、勝った人を選んでください' : 'ホストが勝者を選んでいます';
else if (h && !h.finished && s.toAct != null) status = `${nameOf(s.toAct)} の番です`; else if (h && !h.finished && s.toAct != null) status = `${nameOf(s.toAct)} の番です`;
else if (h && !h.finished && !seat.inHand) status = '次のハンドから参加します'; else if (h && !h.finished && !seat.inHand) status = '次のハンドから参加します';
else if (h && h.finished) status = '次のハンドを準備中…'; else if (h && h.finished) status = '次のハンドを準備中…';
+2 -2
View File
@@ -14,8 +14,8 @@
<div class="logo">Hold'em<br>Night<small>PRIVATE CLUB</small></div> <div class="logo">Hold'em<br>Night<small>PRIVATE CLUB</small></div>
<form id="form" class="card" autocomplete="off"> <form id="form" class="card" autocomplete="off">
<h2 class="key-title">参加キーを入力</h2> <h2 class="key-title" id="key-title">参加キーを入力</h2>
<p class="hint key-hint">オーナーから教えてもらった6桁の数字</p> <p class="hint key-hint" id="key-hint">オーナーから教えてもらった6桁の数字</p>
<div class="key-inputs" id="digits"> <div class="key-inputs" id="digits">
<input type="tel" inputmode="numeric" aria-label="1桁目" autocomplete="one-time-code"> <input type="tel" inputmode="numeric" aria-label="1桁目" autocomplete="one-time-code">
<input type="tel" inputmode="numeric" aria-label="2桁目"> <input type="tel" inputmode="numeric" aria-label="2桁目">
+1
View File
@@ -18,6 +18,7 @@
<div class="error-text" id="name-error"></div> <div class="error-text" id="name-error"></div>
<button id="create" class="btn btn-orange btn-block mt-4" type="button">+ テーブルを作成</button> <button id="create" class="btn btn-orange btn-block mt-4" type="button">+ テーブルを作成</button>
<a id="range-link" class="btn btn-dark btn-block mt-8" href="/range" hidden>♠ ハンドレンジ学習</a>
<div class="section-title"> <div class="section-title">
<span>開催中のテーブル</span> <span>開催中のテーブル</span>
+5 -6
View File
@@ -16,15 +16,14 @@
<div class="table-header"> <div class="table-header">
<button class="icon-btn leave" id="leave" aria-label="退席する" title="退席する">⏏</button> <button class="icon-btn leave" id="leave" aria-label="退席する" title="退席する">⏏</button>
<h1 id="table-name"></h1> <h1 id="table-name"></h1>
<!-- ホストだけに表示 -->
<span class="host-controls" id="host-controls" hidden>
<button class="btn btn-green btn-sm" id="start">START</button>
<button class="icon-btn" id="pause" aria-label="一時停止" title="一時停止">⏸</button>
</span>
<button class="icon-btn" id="share" aria-label="サイトのリンクをコピー" title="サイトのリンクをコピー">🔗</button> <button class="icon-btn" id="share" aria-label="サイトのリンクをコピー" title="サイトのリンクをコピー">🔗</button>
</div> </div>
<div class="host-controls" id="host-controls" hidden>
<button class="btn btn-green" id="start">START ▶</button>
<button class="btn btn-dark" id="pause">一時停止</button>
</div>
<div class="host-spacer" id="host-spacer"></div>
<div class="level-bar"> <div class="level-bar">
<span class="lv" id="level"></span> <span class="lv" id="level"></span>
<span class="blinds" id="blinds"></span> <span class="blinds" id="blinds"></span>
+44 -15
View File
@@ -25,11 +25,13 @@ function loadSecret() {
return secret; return secret;
} }
// 鍵を変えると既存の Cookie はすべて無効になる // Cookie の値はキーと役割から作る。キーを変えると既存の Cookie はすべて無効になる
const AUTH_TOKEN = crypto const SECRET = loadSecret();
.createHmac('sha256', loadSecret()) const tokenFor = (role, key) => crypto.createHmac('sha256', SECRET).update(role + ':' + key).digest('base64url');
.update('access:' + config.ACCESS_KEY) const TOKENS = {
.digest('base64url'); member: tokenFor('access', config.ACCESS_KEY), // 以前と同じ値(既存の Cookie はそのまま有効)
admin: config.ADMIN_KEY ? tokenFor('admin', config.ADMIN_KEY) : null,
};
function parseCookies(header) { function parseCookies(header) {
const out = {}; const out = {};
@@ -51,18 +53,31 @@ function safeEqual(a, b) {
return crypto.timingSafeEqual(ha, hb); return crypto.timingSafeEqual(ha, hb);
} }
function isAuthedCookieHeader(cookieHeader) { // 'admin' / 'member' / null
function roleOf(cookieHeader) {
const value = parseCookies(cookieHeader)[COOKIE_NAME]; 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) { function isAuthed(req) {
return isAuthedCookieHeader(req.headers.cookie); 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 = [ const parts = [
`${COOKIE_NAME}=${AUTH_TOKEN}`, `${COOKIE_NAME}=${TOKENS[role]}`,
'Path=/', 'Path=/',
`Max-Age=${COOKIE_MAX_AGE_SEC}`, `Max-Age=${COOKIE_MAX_AGE_SEC}`,
'HttpOnly', 'HttpOnly',
@@ -163,7 +178,10 @@ function handleLogin(req, res) {
if (!/^\d{6}$/.test(input)) { if (!/^\d{6}$/.test(input)) {
return res.status(400).json({ error: '6桁の数字を入力してください。' }); 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++; w.count++;
global.count++; global.count++;
console.warn(`[auth] 鍵の入力ミス from ${key} (${w.count}/${config.AUTH_MAX_FAILURES}, 全体 ${global.count}/${config.AUTH_GLOBAL_MAX_FAILURES})`); 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}回)` : '鍵が違います。' }); return res.status(401).json({ error: left > 0 ? `鍵が違います。(あと${left}回)` : '鍵が違います。' });
} }
perClient.delete(key); perClient.delete(key);
console.log(`[auth] 入室 from ${key}`); // 管理者がメンバー用のキーを入れ直しても管理者のまま
setAuthCookie(req, res); const role = adminMatch || isAdmin(req) ? 'admin' : 'member';
res.json({ ok: true }); console.log(`[auth] 入室 from ${key}${role === 'admin' ? '(管理者)' : ''}`);
setAuthCookie(req, res, role);
res.json({ ok: true, admin: role === 'admin' });
} }
// /key 以外のページ・API を保護する // /key 以外のページ・API を保護する
function requireAuth(req, res, next) { function requireAuth(req, res, next) {
if (isAuthed(req)) { const role = roleOf(req.headers.cookie);
if (req.method === 'GET' && !req.path.startsWith('/api/')) setAuthCookie(req, res); if (role) {
if (req.method === 'GET' && !req.path.startsWith('/api/')) setAuthCookie(req, res, role);
return next(); return next();
} }
if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'unauthorized' }); 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(); 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 は同じサイトからのみ受け付ける // 状態を変える API は同じサイトからのみ受け付ける
function requireSameOrigin(req, res, next) { function requireSameOrigin(req, res, next) {
if (req.method === 'GET' || req.method === 'HEAD' || isSameOrigin(req.headers)) return next(); if (req.method === 'GET' || req.method === 'HEAD' || isSameOrigin(req.headers)) return next();
@@ -203,12 +230,14 @@ function safeNext(next) {
module.exports = { module.exports = {
isAuthed, isAuthed,
isAdmin,
isAuthedCookieHeader, isAuthedCookieHeader,
isSameOrigin, isSameOrigin,
isAllowedHost, isAllowedHost,
requireAllowedHost, requireAllowedHost,
handleLogin, handleLogin,
requireAuth, requireAuth,
requireAdmin,
requireSameOrigin, requireSameOrigin,
safeNext, safeNext,
}; };
+8
View File
@@ -30,6 +30,13 @@ if (!ACCESS_KEY || !/^\d{6}$/.test(ACCESS_KEY)) {
process.exit(1); 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 = { module.exports = {
PORT: Number(process.env.PORT) || 4671, PORT: Number(process.env.PORT) || 4671,
@@ -43,6 +50,7 @@ module.exports = {
// 参加キー(6桁の数字)。.env の ACCESS_KEY // 参加キー(6桁の数字)。.env の ACCESS_KEY
ACCESS_KEY, ACCESS_KEY,
ADMIN_KEY,
// 1テーブルの最大着席数 // 1テーブルの最大着席数
MAX_SEATS: 9, MAX_SEATS: 9,
+20 -2
View File
@@ -9,7 +9,9 @@ const auth = require('./auth');
const { TableManager } = require('./tables'); const { TableManager } = require('./tables');
const PUBLIC = path.join(__dirname, '..', 'public'); 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(); const app = express();
app.set('trust proxy', 'loopback'); // nginx 等のリバースプロキシ越しでも req.ip / req.secure を正しく 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('/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('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nDisallow: /\n'));
app.get('/key', (req, res) => { 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); page('key.html')(req, res);
}); });
app.post('/api/auth', auth.handleLogin); app.post('/api/auth', auth.handleLogin);
@@ -83,6 +87,20 @@ app.get('/', page('lobby.html'));
app.get('/create', page('create.html')); app.get('/create', page('create.html'));
app.get('/table/:id', page('table.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', (req, res) => res.json(tables.list()));
app.get('/api/tables/suggest-name', (req, res) => res.json({ name: tables.suggestName() })); app.get('/api/tables/suggest-name', (req, res) => res.json({ name: tables.suggestName() }));
+3 -6
View File
@@ -152,17 +152,14 @@ class Table {
return this.hand.act(i, type, amount); return this.hand.act(i, type, amount);
} }
// ショーダウンの勝者を選べるのは、ショーダウンに残った人とホスト // ショーダウンの勝者(ポットの分配)を決められるのはテーブルのホストだけ
canAward(token) { canAward(token) {
if (!this.handActive || !this.hand.awaitingWinners) return false; return this.handActive && this.hand.awaitingWinners && this.isHost(token);
const i = this.seatOf(token);
const p = i >= 0 ? this.hand.players.get(i) : null;
return this.isHost(token) || Boolean(p && !p.folded && !this.seats[i].left);
} }
award(token, winners) { award(token, winners) {
if (!this.handActive || !this.hand.awaitingWinners) return 'ショーダウンではありません'; if (!this.handActive || !this.hand.awaitingWinners) return 'ショーダウンではありません';
if (!this.canAward(token)) return 'ショーダウンに残った人かホストが選んでください'; if (!this.canAward(token)) return 'ポットの分配はホストのみ行えます';
if (this.paused) return '一時停止中です'; if (this.paused) return '一時停止中です';
return this.hand.award(winners); return this.hand.award(winners);
} }
+27 -9
View File
@@ -131,22 +131,40 @@ test('勝者の指定: 山分け(端数はボタンの左から)と不正な
assert.equal(t.act(tk(t, bb), 'check', 0), null); assert.equal(t.act(tk(t, bb), 'check', 0), null);
checkDown(t); checkDown(t);
assert.equal(h.street, 'SHOWDOWN'); assert.equal(h.street, 'SHOWDOWN');
t.hostToken = T(7); // ホストの特例を除いて確認する const host = T(0);
assert.equal(t.award(tk(t, sb), [[bb]]), 'ショーダウンに残った人かホストが選んでください'); // ホスト以外は、ショーダウンに残った人でも分配できない
assert.ok(t.award(tk(t, bb), [])); for (const s of [sb, bb, btn]) {
assert.ok(t.award(tk(t, bb), [[]])); if (tk(t, s) !== host) assert.equal(t.award(tk(t, s), [[bb]]), 'ポットの分配はホストのみ行えます');
assert.ok(t.award(tk(t, bb), [[sb]])); // フォールドした人は勝てない }
assert.ok(t.award(tk(t, bb), [[bb, bb]]));
assert.ok(t.award(tk(t, bb), [[bb], [btn]])); // ポットは1つ
assert.ok(t.award(T(8), [[bb]])); // 着席していない assert.ok(t.award(T(8), [[bb]])); // 着席していない
assert.ok(t.award(host, []));
assert.ok(t.award(host, [[]]));
assert.ok(t.award(host, [[sb]])); // フォールドした人は勝てない
assert.ok(t.award(host, [[bb, bb]]));
assert.ok(t.award(host, [[bb], [btn]])); // ポットは1つ
assert.ok(!h.finished); assert.ok(!h.finished);
assert.equal(t.award(tk(t, bb), [[bb, btn]]), null); assert.equal(t.award(host, [[bb, btn]]), null);
// 50 を2人で → 25 ずつ // 50 を2人で → 25 ずつ
assert.equal(t.seats[bb].stack, 1005); assert.equal(t.seats[bb].stack, 1005);
assert.equal(t.seats[btn].stack, 1005); assert.equal(t.seats[btn].stack, 1005);
assert.equal(t.award(tk(t, bb), [[bb]]), 'ショーダウンではありません'); assert.equal(t.award(host, [[bb]]), 'ショーダウンではありません');
});
test('ホストが退席したら次のホストが分配する', () => {
const t = makeTable([1000, 1000, 1000]);
t.start(T(0), 0);
const h = t.hand;
// ホスト(席0)以外の2人と一緒にショーダウンまで進め、ホストが退席する
checkDown(t);
assert.equal(h.street, 'SHOWDOWN');
t.leave(T(0));
assert.notEqual(t.hostToken, T(0));
const live = h.live().map((p) => p.seat);
assert.equal(t.award(T(0), [[live[0]]]), 'ポットの分配はホストのみ行えます');
assert.equal(t.award(t.hostToken, [[live[0]]]), null);
assert.ok(h.finished);
}); });
test('端数はボタンの左から配る', () => { test('端数はボタンの左から配る', () => {