Files
Poker/server/hand.js
T
agomizi1cmandClaude Opus 5.5 b5d84e5f65 Switch to chips-only play: real cards at the table, winners picked on screen
- Remove the deck, hole cards, board, hand evaluator and reveal logic
  (cards are dealt physically; the site only tracks bets, pots and stacks)
- Showdown now waits for a winner per pot (main/side), chosen by a player
  still in the hand or the host; multiple winners split the pot
- All-in with no further betting goes straight to showdown
- Remove the 30s action clock / auto sit-out (everyone is at the same table)
- UI: winner picker panel (main winner auto-fills side pots), compact table

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:46:10 +09:00

311 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// 1ハンド分のチップの動き(ブラインド・ベッティング・ポット分配)。
// カードは実物で配るので扱わない。ショーダウンでは勝者をプレイヤーが選ぶ。
// seats は Table.seats への参照で、スタックはここで直接増減する。
// 参加者の席はハンド終了まで空席にならない(退席者は Table 側で予約したまま)。
const STREETS = ['PREFLOP', 'FLOP', 'TURN', 'RIVER'];
class Hand {
constructor({ number, seats, order, button, sb, bb }) {
this.number = number;
this.seats = seats;
this.order = order; // 参加者の席番号。ボタンの次から時計回り
this.button = button;
this.sb = sb;
this.bb = bb;
this.street = 'PREFLOP'; // PREFLOP / FLOP / TURN / RIVER / SHOWDOWN
this.pot = 0; // 前のストリートまでに集めた額
this.currentBet = 0;
this.minRaise = bb;
this.toAct = null;
this.finished = false;
this.showdown = false;
this.results = null;
this.players = new Map();
for (const i of order) {
this.players.set(i, {
seat: i,
token: seats[i].token,
bet: 0, // このストリートで出した額
committed: 0, // このハンドで出した合計
folded: false,
allIn: false,
acted: false,
last: null, // 直前のアクション(表示用)
won: 0,
});
}
// ヘッズアップはボタンが SB
const headsUp = order.length === 2;
this.sbSeat = headsUp ? button : order[0];
this.bbSeat = headsUp ? order[0] : order[1];
this.post(this.sbSeat, sb);
this.post(this.bbSeat, bb);
this.currentBet = bb; // BB がショートでもコール額は BB
this.setNextActor(this.bbSeat);
}
get list() {
return this.order.map((i) => this.players.get(i));
}
get awaitingWinners() {
return this.street === 'SHOWDOWN' && !this.finished;
}
live() {
return this.list.filter((p) => !p.folded);
}
put(p, amount) {
const s = this.seats[p.seat];
s.stack -= amount;
p.bet += amount;
p.committed += amount;
if (s.stack === 0) p.allIn = true;
}
post(seat, amount) {
const p = this.players.get(seat);
this.put(p, Math.min(amount, this.seats[seat].stack));
}
needsToAct(p) {
return !p.folded && !p.allIn && (!p.acted || p.bet < this.currentBet);
}
bettingClosed() {
const live = this.live();
if (live.length <= 1) return true;
const actors = live.filter((p) => !p.allIn);
if (actors.length === 0) return true;
// 動ける人が1人だけで、誰かのベットに応じる必要もない
const maxBet = Math.max(...live.map((p) => p.bet));
if (actors.length === 1 && actors[0].bet >= maxBet) return true;
return actors.every((p) => !this.needsToAct(p));
}
setNextActor(fromSeat) {
this.toAct = null;
if (this.bettingClosed()) return this.endStreet();
const n = this.order.length;
const start = this.order.indexOf(fromSeat);
for (let k = 1; k <= n; k++) {
const p = this.players.get(this.order[(start + k) % n]);
if (!this.needsToAct(p)) continue;
this.toAct = p.seat;
// 離席中・退席済みの人は自動でチェック/フォールド
const s = this.seats[p.seat];
if (s.sittingOut || s.left) this.autoAct();
return;
}
this.endStreet();
}
// 手番の人の選択肢(手番でなければ null)
options(seat) {
if (this.finished || this.toAct !== seat) return null;
const p = this.players.get(seat);
const stack = this.seats[seat].stack;
const toCall = Math.min(this.currentBet - p.bet, stack);
const maxTo = p.bet + stack;
// 他の全員がオールインならレイズしても意味がない
const opponentCanAct = this.live().some((q) => q !== p && !q.allIn);
return {
toCall,
canCheck: p.bet >= this.currentBet,
canRaise: !p.acted && stack > toCall && opponentCanAct,
minRaiseTo: Math.min(this.currentBet + this.minRaise, maxTo),
maxRaiseTo: maxTo,
isBet: this.currentBet === 0,
};
}
// type: fold / check / call / raise(amount = このストリートでの合計額 "raise to")
act(seat, type, amount) {
const o = this.options(seat);
if (!o) return 'あなたの番ではありません';
const p = this.players.get(seat);
if (type === 'fold') {
p.folded = true;
p.last = { type: 'FOLD' };
} else if (type === 'check') {
if (!o.canCheck) return 'チェックできません';
p.last = { type: 'CHECK' };
} else if (type === 'call') {
if (o.canCheck || o.toCall <= 0) return 'コールする額がありません';
this.put(p, o.toCall);
p.last = { type: 'CALL', amount: p.bet };
} else if (type === 'raise') {
if (!o.canRaise) return 'レイズできません';
if (!Number.isInteger(amount) || amount < o.minRaiseTo || amount > o.maxRaiseTo) {
return `${o.minRaiseTo}〜${o.maxRaiseTo} の額を指定してください`;
}
const raiseSize = amount - this.currentBet;
this.put(p, amount - p.bet);
p.last = { type: o.isBet ? 'BET' : 'RAISE', amount };
if (raiseSize >= this.minRaise) {
// 正規のレイズ: 他の人はもう一度アクションできる
this.minRaise = raiseSize;
for (const q of this.list) if (q !== p) q.acted = false;
}
// 足りないオールインのレイズはアクション済みの人に再レイズ権を与えない
this.currentBet = amount;
} else {
return '不正なアクションです';
}
if (p.allIn) p.last = { type: 'ALLIN', amount: p.bet };
p.acted = true;
this.setNextActor(seat);
return null;
}
autoAct() {
const o = this.options(this.toAct);
if (o) this.act(this.toAct, o.canCheck ? 'check' : 'fold', 0);
}
// 退席などで手番に関係なくフォールドさせる
forceFold(seat) {
const p = this.players.get(seat);
if (!p || p.folded || this.finished) return;
if (this.toAct === seat) return this.act(seat, 'fold', 0);
p.folded = true;
p.last = { type: 'FOLD' };
if (this.live().length === 1) {
if (this.awaitingWinners) this.finishFoldWin();
else this.endStreet();
} else if (this.toAct != null && this.bettingClosed()) {
this.endStreet();
}
}
// 誰もコールしなかった分を返す
returnUncalled() {
const bets = this.list.map((p) => p.bet).sort((a, b) => b - a);
const excess = bets[0] - (bets[1] || 0);
if (excess <= 0) return;
const p = this.list.find((q) => q.bet === bets[0]);
if (p.folded) return; // 退席でフォールドした人のベットはそのままポットに残る
p.bet -= excess;
p.committed -= excess;
this.seats[p.seat].stack += excess;
p.allIn = this.seats[p.seat].stack === 0;
if (!p.allIn && p.last && p.last.type === 'ALLIN') p.last = null;
}
endStreet() {
this.toAct = null;
this.returnUncalled();
for (const p of this.list) {
this.pot += p.bet;
p.bet = 0;
p.acted = false;
if (p.last && p.last.type !== 'FOLD' && p.last.type !== 'ALLIN') p.last = null;
}
if (this.live().length === 1) return this.finishFoldWin();
this.currentBet = 0;
this.minRaise = this.bb;
// リバーが終わった、または全員オールインでこれ以上ベットが無い → ショーダウン
if (this.street === 'RIVER' || this.live().filter((p) => !p.allIn).length < 2) {
this.street = 'SHOWDOWN';
return;
}
this.street = STREETS[STREETS.indexOf(this.street) + 1];
this.setNextActor(this.button);
}
// メインポット・サイドポット(同じ参加者のポットはまとめる)
buildPots() {
const all = this.list;
const levels = [...new Set(all.map((p) => p.committed).filter((c) => c > 0))].sort((a, b) => a - b);
const pots = [];
let prev = 0;
for (const level of levels) {
let amount = 0;
for (const p of all) amount += Math.max(0, Math.min(p.committed, level) - prev);
prev = level;
let eligible = all.filter((p) => !p.folded && p.committed >= level).map((p) => p.seat);
const last = pots[pots.length - 1];
if (eligible.length === 0) eligible = last ? last.eligible : this.live().map((p) => p.seat);
if (last && last.eligible.join() === eligible.join()) last.amount += amount;
else pots.push({ amount, eligible });
}
return pots;
}
finishFoldWin() {
const winner = this.live()[0];
winner.won = this.pot;
this.seats[winner.seat].stack += this.pot;
this.end(false, [{ amount: this.pot, winners: [winner.seat] }]);
}
// ショーダウンの勝者を決める。winners[i] = i 番目のポットの勝者の席番号(引き分けは複数)
award(winners) {
if (!this.awaitingWinners) return 'ショーダウンではありません';
const pots = this.buildPots();
if (!Array.isArray(winners) || winners.length !== pots.length) return 'すべてのポットの勝者を選んでください';
for (let i = 0; i < pots.length; i++) {
const w = winners[i];
if (!Array.isArray(w) || w.length === 0) return 'すべてのポットの勝者を選んでください';
if (new Set(w).size !== w.length || !w.every((s) => pots[i].eligible.includes(s))) return '勝者の指定が不正です';
}
const results = [];
pots.forEach((pot, i) => {
// 割り切れない端数はボタンの左から順に
const ws = this.order.filter((s) => winners[i].includes(s));
const share = Math.floor(pot.amount / ws.length);
let rest = pot.amount - share * ws.length;
for (const s of ws) {
const amount = share + (rest > 0 ? 1 : 0);
if (rest > 0) rest--;
this.players.get(s).won += amount;
this.seats[s].stack += amount;
}
results.push({ amount: pot.amount, winners: ws });
});
this.end(true, results);
return null;
}
end(showdown, results) {
this.finished = true;
this.showdown = showdown;
this.toAct = null;
this.results = results;
}
view() {
const players = {};
for (const p of this.list) {
players[p.seat] = { bet: p.bet, folded: p.folded, allIn: p.allIn, last: p.last, won: p.won };
}
return {
number: this.number,
street: this.street,
pot: this.pot,
potTotal: this.pot + this.list.reduce((sum, p) => sum + p.bet, 0),
currentBet: this.currentBet,
button: this.button,
sbSeat: this.sbSeat,
bbSeat: this.bbSeat,
toAct: this.toAct,
finished: this.finished,
showdown: this.showdown,
pots: this.awaitingWinners ? this.buildPots() : null,
results: this.results,
players,
};
}
}
module.exports = { Hand };