From 02c8e76237395d8328812114d7cb6e4bf7d7fbe2 Mon Sep 17 00:00:00 2001 From: KoyoNoguchi Date: Sat, 17 Oct 2026 17:03:58 +0900 Subject: [PATCH] Close CPU tables as soon as every human player leaves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - When the last human presses leave on a CPU table (even mid-hand), the table is deleted right away instead of lingering with CPUs only; friends / cash-only tables keep the existing 30-minute idle cleanup - Spectators still watching get a "テーブルが終了しました" notice, and the lobby updates immediately - Don't show the "接続が切れました" banner when the page disconnects on purpose (closed or missing table) - Test for the close condition; README notes the behavior Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- public/assets/table.js | 16 ++++++++++++++-- public/table.html | 4 ++-- server/index.js | 15 ++++++++++++++- server/tables.js | 5 +++++ test/engine.test.js | 27 +++++++++++++++++++++++++++ 6 files changed, 63 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4642570..9d57cd2 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ - ベッティングが終わると次のストリートへ。全員オールインならその時点でショーダウンになる(残りのカードは実物でめくる)。 - **ショーダウン(キャッシュオンリー)**: カードを見せ合い、ホストが勝った人を画面で選んで確定する(ポットの分配はテーブルのホストだけが行える)。サイドポットがあるときはポットごとに選ぶ。複数選ぶと山分け(端数はボタンの左から)。 - **カードを配るテーブル(友人戦・CPU戦)**: 各自のスマホに自分の手札だけが表示され、場札はストリートごとに画面に配られる。ショーダウンでは残った人の手札を公開して役を判定し、ポット(サイドポット含む)を自動で分配する。引き分けは山分け(端数はボタンの左から)。全員オールインになったら手札を公開し、残りの場札を順にめくる。山札は暗号論的乱数でシャッフルし、サーバの外に出さない(他人の手札は公開されるまで送らない)。 -- **CPU**: 自前実装(`server/ai.js`、外部ライブラリなし)。自分の手札と場札から、残っている相手に勝つ確率をモンテカルロ法で見積もり、ポットオッズ・レイズの回数・相手の人数から行動を決める。CPU ごとに攻撃性・ブラフ頻度がランダムに違う。相手の手札は見ない。1手ごとに 0.7〜1.8 秒考える。チップがなくなると次のハンドで自動でリバイする。人間が1人も参加していないと次のハンドは始まらない。CPU はホストにならない。 +- **CPU**: 自前実装(`server/ai.js`、外部ライブラリなし)。自分の手札と場札から、残っている相手に勝つ確率をモンテカルロ法で見積もり、ポットオッズ・レイズの回数・相手の人数から行動を決める。CPU ごとに攻撃性・ブラフ頻度がランダムに違う。相手の手札は見ない。1手ごとに 0.7〜1.8 秒考える。チップがなくなると次のハンドで自動でリバイする。人間が1人も参加していないと次のハンドは始まらない。CPU はホストにならない。人間が全員「退席」するとテーブルはすぐに削除される(ブラウザを閉じただけの場合は、ほかのテーブルと同じく誰も接続していない状態が30分続いたら削除)。 - 全員フォールドしたら残った人が自動でポットを獲得。誰もコールしなかった分は自動で返る。 - 「離席」にすると次のハンドから配られない(手番が来たら自動でチェック/フォールド)。「復帰する」で戻る。 - チップがなくなったら開始スタックでリバイできる。画面下に収支(現在のチップ − バイイン合計)を表示。 diff --git a/public/assets/table.js b/public/assets/table.js index 9aa8a2d..55f29a6 100644 --- a/public/assets/table.js +++ b/public/assets/table.js @@ -52,6 +52,7 @@ }); socket.on('disconnect', () => { $('conn-lost').hidden = false; }); socket.on('connect_error', () => { $('conn-lost').hidden = false; }); + socket.on('table:closed', () => showNotFound(true)); socket.on('table:state', (s) => { state = s; pending = false; @@ -90,11 +91,22 @@ render(); } - function showNotFound() { + // closed: プレイヤーが全員退席してテーブルが閉じられた + function showNotFound(closed) { + if (closed) { + $('not-found-title').textContent = 'テーブルが終了しました'; + $('not-found-text').textContent = 'プレイヤーが全員退席したため、テーブルを閉じました。'; + } $('main').hidden = true; $('action-bar').hidden = true; $('not-found').hidden = false; - if (socket) socket.disconnect(); + // 自分から切断するので「接続が切れました」は出さない + if (socket) { + socket.off('disconnect'); + socket.off('connect_error'); + socket.disconnect(); + } + $('conn-lost').hidden = true; } const nameOf = (seat) => (state.seats[seat] ? state.seats[seat].name : '退席者'); diff --git a/public/table.html b/public/table.html index a857ee6..e9fec5b 100644 --- a/public/table.html +++ b/public/table.html @@ -100,8 +100,8 @@ diff --git a/server/index.js b/server/index.js index c449906..f9236e2 100644 --- a/server/index.js +++ b/server/index.js @@ -192,6 +192,17 @@ function changed(table) { broadcastLobby(); } +// テーブルを削除し、残っている観戦者に知らせる(leaver: 退席した本人。すでにロビーへ戻るので知らせない) +function closeTable(table, leaver) { + tables.delete(table.id); + for (const s of roomSockets(table.id)) { + s.leave(roomOf(table.id)); + if (s.data.tableId === table.id) s.data.tableId = null; + if (s !== leaver) s.emit('table:closed'); + } + broadcastLobby(); +} + // 二重チェック(allowRequest を通った後も念のため) io.use((socket, next) => { if (auth.isAuthedCookieHeader(socket.handshake.headers.cookie)) next(); @@ -274,7 +285,9 @@ io.on('connection', (socket) => { if (!table) return reply(ack, { ok: false, error: 'NOT_FOUND' }); const error = fn(table, socket.data.token, p); reply(ack, error ? { ok: false, error } : { ok: true }); - if (!error) changed(table); + if (error) return; + if (event === 'table:leave' && table.shouldClose()) return closeTable(table, socket); + changed(table); }); } diff --git a/server/tables.js b/server/tables.js index 914b1be..47e582e 100644 --- a/server/tables.js +++ b/server/tables.js @@ -152,6 +152,11 @@ class Table { return true; } + // CPU戦で人間が全員退席したら、テーブルを残しておく意味がない + shouldClose() { + return this.mode === 'cpu' && !this.seats.some((s) => s && !s.left && !s.bot); + } + removeLeftSeats() { this.seats = this.seats.map((s) => (s && s.left ? null : s)); } diff --git a/test/engine.test.js b/test/engine.test.js index 6c72566..9e85060 100644 --- a/test/engine.test.js +++ b/test/engine.test.js @@ -698,3 +698,30 @@ test('CPU戦: CPU が席に着き、ホストは人間のまま、CPU は自動 t.join(T(5), 'friend'); assert.equal(t.isHost(T(5)), true); }); + +test('CPU戦: 人間が全員退席したらテーブルを閉じる(ハンド中でも)', () => { + const opts = { + id: 'c', + name: 'cpu', + startingStack: 1000, + smallBlind: 10, + bigBlind: 20, + blindTimerEnabled: false, + blindIntervalMin: 10, + hostToken: T(0), + }; + const t = new Table({ ...opts, mode: 'cpu', cpuCount: 2 }); + t.join(T(0), 'me'); + t.join(T(5), 'friend'); + t.start(T(0), 0); + t.leave(T(0)); + assert.equal(t.shouldClose(), false); // まだ友達がいる + t.leave(T(5)); + assert.equal(t.shouldClose(), true); + + // CPU戦以外は閉じない(これまでどおり、誰も接続していない状態が続いたら削除) + const f = new Table({ ...opts, mode: 'friends' }); + f.join(T(0), 'me'); + f.leave(T(0)); + assert.equal(f.shouldClose(), false); +});