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戦)**: 各自のスマホに自分の手札だけが表示され、場札はストリートごとに画面に配られる。ショーダウンでは残った人の手札を公開して役を判定し、ポット(サイドポット含む)を自動で分配する。引き分けは山分け(端数はボタンの左から)。全員オールインになったら手札を公開し、残りの場札を順にめくる。山札は暗号論的乱数でシャッフルし、サーバの外に出さない(他人の手札は公開されるまで送らない)。
|
||||||
- **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('disconnect', () => { $('conn-lost').hidden = false; });
|
||||||
socket.on('connect_error', () => { $('conn-lost').hidden = false; });
|
socket.on('connect_error', () => { $('conn-lost').hidden = false; });
|
||||||
|
socket.on('table:closed', () => showNotFound(true));
|
||||||
socket.on('table:state', (s) => {
|
socket.on('table:state', (s) => {
|
||||||
state = s;
|
state = s;
|
||||||
pending = false;
|
pending = false;
|
||||||
@@ -90,11 +91,22 @@
|
|||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
function showNotFound() {
|
// closed: プレイヤーが全員退席してテーブルが閉じられた
|
||||||
|
function showNotFound(closed) {
|
||||||
|
if (closed) {
|
||||||
|
$('not-found-title').textContent = 'テーブルが終了しました';
|
||||||
|
$('not-found-text').textContent = 'プレイヤーが全員退席したため、テーブルを閉じました。';
|
||||||
|
}
|
||||||
$('main').hidden = true;
|
$('main').hidden = true;
|
||||||
$('action-bar').hidden = true;
|
$('action-bar').hidden = true;
|
||||||
$('not-found').hidden = false;
|
$('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 : '退席者');
|
const nameOf = (seat) => (state.seats[seat] ? state.seats[seat].name : '退席者');
|
||||||
|
|||||||
+2
-2
@@ -100,8 +100,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="center-message" id="not-found" hidden>
|
<div class="center-message" id="not-found" hidden>
|
||||||
<h2>テーブルが見つかりません</h2>
|
<h2 id="not-found-title">テーブルが見つかりません</h2>
|
||||||
<p>終了したか、URLが間違っている可能性があります。</p>
|
<p id="not-found-text">終了したか、URLが間違っている可能性があります。</p>
|
||||||
<a class="btn btn-blue" href="/">ロビーへ戻る</a>
|
<a class="btn btn-blue" href="/">ロビーへ戻る</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+14
-1
@@ -192,6 +192,17 @@ function changed(table) {
|
|||||||
broadcastLobby();
|
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 を通った後も念のため)
|
// 二重チェック(allowRequest を通った後も念のため)
|
||||||
io.use((socket, next) => {
|
io.use((socket, next) => {
|
||||||
if (auth.isAuthedCookieHeader(socket.handshake.headers.cookie)) 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' });
|
if (!table) return reply(ack, { ok: false, error: 'NOT_FOUND' });
|
||||||
const error = fn(table, socket.data.token, p);
|
const error = fn(table, socket.data.token, p);
|
||||||
reply(ack, error ? { ok: false, error } : { ok: true });
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CPU戦で人間が全員退席したら、テーブルを残しておく意味がない
|
||||||
|
shouldClose() {
|
||||||
|
return this.mode === 'cpu' && !this.seats.some((s) => s && !s.left && !s.bot);
|
||||||
|
}
|
||||||
|
|
||||||
removeLeftSeats() {
|
removeLeftSeats() {
|
||||||
this.seats = this.seats.map((s) => (s && s.left ? null : s));
|
this.seats = this.seats.map((s) => (s && s.left ? null : s));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -698,3 +698,30 @@ test('CPU戦: CPU が席に着き、ホストは人間のまま、CPU は自動
|
|||||||
t.join(T(5), 'friend');
|
t.join(T(5), 'friend');
|
||||||
assert.equal(t.isHost(T(5)), true);
|
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