Close CPU tables as soon as every human player leaves
- 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
0d3a054f0d
commit
02c8e76237
6 files changed
+63
-6
No files matched your search
@@ -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分続いたら削除)。
|
||||
- 全員フォールドしたら残った人が自動でポットを獲得。誰もコールしなかった分は自動で返る。
|
||||
- 「離席」にすると次のハンドから配られない(手番が来たら自動でチェック/フォールド)。「復帰する」で戻る。
|
||||
- チップがなくなったら開始スタックでリバイできる。画面下に収支(現在のチップ − バイイン合計)を表示。
|
||||
|
||||
+14
-2
@@ -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 : '退席者');
|
||||
|
||||
+2
-2
@@ -100,8 +100,8 @@
|
||||
</div>
|
||||
|
||||
<div class="center-message" id="not-found" hidden>
|
||||
<h2>テーブルが見つかりません</h2>
|
||||
<p>終了したか、URLが間違っている可能性があります。</p>
|
||||
<h2 id="not-found-title">テーブルが見つかりません</h2>
|
||||
<p id="not-found-text">終了したか、URLが間違っている可能性があります。</p>
|
||||
<a class="btn btn-blue" href="/">ロビーへ戻る</a>
|
||||
</div>
|
||||
|
||||
|
||||
+14
-1
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user