diff --git a/README.md b/README.md index 5a8b711..61f9ffd 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ - ブラインドは自動で支払われ、ハンド開始時のレベルで固定。ヘッズアップはボタンが SB。 - 各自が自分のスマホで Fold / Check / Call / Bet / Raise を行う。最低レイズ額は直前のレイズ幅以上。足りないオールインのレイズは、すでにアクションした人に再レイズ権を与えない。 - ベッティングが終わると次のストリートへ。全員オールインならその時点でショーダウンになる(残りのカードは実物でめくる)。 -- **ショーダウン**: カードを見せ合い、勝った人を画面で選んで確定する(選べるのはショーダウンに残った人とホスト)。サイドポットがあるときはポットごとに選ぶ。複数選ぶと山分け(端数はボタンの左から)。 +- **ショーダウン**: カードを見せ合い、ホストが勝った人を画面で選んで確定する(ポットの分配はテーブルのホストだけが行える)。サイドポットがあるときはポットごとに選ぶ。複数選ぶと山分け(端数はボタンの左から)。 - 全員フォールドしたら残った人が自動でポットを獲得。誰もコールしなかった分は自動で返る。 - 「離席」にすると次のハンドから配られない(手番が来たら自動でチェック/フォールド)。「復帰する」で戻る。 - チップがなくなったら開始スタックでリバイできる。画面下に収支(現在のチップ − バイイン合計)を表示。 @@ -80,7 +80,7 @@ http://localhost:4671 を開く。 - テーブルはメモリ上にのみ保持(サーバ再起動で消える)。誰も接続していない状態が30分続くと自動削除。 - 名前とプレイヤーID はブラウザの localStorage に保存。リロードしても同じ席に戻れる。 -- 作成者がホスト(START・一時停止ができる)。ホストが退席すると次の着席者に移る。 +- 作成者がホスト(START・一時停止・ショーダウン後のポットの分配ができる。ボタンはテーブル名の右に出る)。ホストが退席すると次の着席者に移る。 ## VPS へのデプロイ例(Ubuntu + nginx + systemd + HTTPS) diff --git a/public/assets/style.css b/public/assets/style.css index 9a4e1fc..8f85e26 100644 --- a/public/assets/style.css +++ b/public/assets/style.css @@ -304,10 +304,10 @@ body.table-page { .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-header { display: flex; align-items: center; gap: 12px; } +.table-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; } .table-header h1 { flex: 1; - text-align: center; + min-width: 0; font-size: 22px; font-weight: 800; overflow: hidden; @@ -316,8 +316,10 @@ body.table-page { } .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 { background: var(--panel); @@ -643,9 +645,8 @@ body.table-page { .table-page .wrap { padding-top: 8px; } .table-header .icon-btn { width: 38px; height: 38px; font-size: 16px; } .table-header h1 { font-size: 18px; } - .host-controls { margin: 8px 0 6px; } - .host-controls .btn { padding: 7px 14px; } - .host-spacer { height: 6px; } + .table-header { margin-bottom: 8px; } + .host-controls .btn { padding: 7px 12px; } .level-bar { padding: 8px 14px; } .level-sub { display: none; } .felt-area { margin: 40px 0 34px; } @@ -677,4 +678,3 @@ body.table-page { .slash { flex: none !important; color: var(--muted); font-weight: 800; } label.switch { margin: 0; display: inline-block; } .link-gold { color: var(--gold); } -.host-spacer { height: 12px; } diff --git a/public/assets/table.js b/public/assets/table.js index eaa0f0b..5d4f3f9 100644 --- a/public/assets/table.js +++ b/public/assets/table.js @@ -121,13 +121,15 @@ // ホスト操作 $('host-controls').hidden = !s.isHost; - $('host-spacer').hidden = s.isHost; const eligible = s.seats.filter((p) => p && !p.sittingOut && p.stack > 0).length; $('start').hidden = s.started; $('start').disabled = eligible < 2; $('start').title = eligible < 2 ? '2人以上で開始できます' : ''; $('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; @@ -250,7 +252,7 @@ else if (m.sittingOut) status = '離席中です。復帰すると次のハンドから参加します'; else if (m.canRebuy) status = 'チップがなくなりました'; 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 && !seat.inHand) status = '次のハンドから参加します'; else if (h && h.finished) status = '次のハンドを準備中…'; diff --git a/public/table.html b/public/table.html index 38c2694..15c61d5 100644 --- a/public/table.html +++ b/public/table.html @@ -16,15 +16,14 @@

+ +
- -
-
diff --git a/server/tables.js b/server/tables.js index 9d4198a..984bda3 100644 --- a/server/tables.js +++ b/server/tables.js @@ -152,17 +152,14 @@ class Table { return this.hand.act(i, type, amount); } - // ショーダウンの勝者を選べるのは、ショーダウンに残った人とホスト + // ショーダウンの勝者(ポットの分配)を決められるのはテーブルのホストだけ canAward(token) { - if (!this.handActive || !this.hand.awaitingWinners) return false; - 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); + return this.handActive && this.hand.awaitingWinners && this.isHost(token); } award(token, winners) { if (!this.handActive || !this.hand.awaitingWinners) return 'ショーダウンではありません'; - if (!this.canAward(token)) return 'ショーダウンに残った人かホストが選んでください'; + if (!this.canAward(token)) return 'ポットの分配はホストのみ行えます'; if (this.paused) return '一時停止中です'; return this.hand.award(winners); } diff --git a/test/engine.test.js b/test/engine.test.js index bf41b8a..f17598a 100644 --- a/test/engine.test.js +++ b/test/engine.test.js @@ -131,22 +131,40 @@ test('勝者の指定: 山分け(端数はボタンの左から)と不正な assert.equal(t.act(tk(t, bb), 'check', 0), null); checkDown(t); 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), [])); - assert.ok(t.award(tk(t, 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つ + // ホスト以外は、ショーダウンに残った人でも分配できない + for (const s of [sb, bb, btn]) { + if (tk(t, s) !== host) assert.equal(t.award(tk(t, s), [[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.equal(t.award(tk(t, bb), [[bb, btn]]), null); + assert.equal(t.award(host, [[bb, btn]]), null); // 50 を2人で → 25 ずつ assert.equal(t.seats[bb].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('端数はボタンの左から配る', () => {