Files
Poker/server/hand.js
T
agomizi1cmandClaude Opus 5.5 8de55f7ca5 Implement Texas Hold'em play: dealing, betting, side pots, showdown
- Hand engine (server/hand.js): crypto-shuffled deck kept server-side,
  blinds (heads-up button = SB), min-raise rules incl. incomplete all-in,
  uncalled bet refunds, side pots, split pots, all-in runout
- Hand evaluator (server/cards.js) and node:test suite (npm test)
- Table flow: hands auto-continue after host START, 30s action clock
  (timeout = check/fold + sit out), sit out / sit in, rebuy, mid-hand leave
  folds and keeps the seat reserved until the hand ends, pause freezes all clocks
- Only each player's own hole cards are sent until a showdown/runout reveal;
  all actions validated server-side
- Play UI: board, hole cards, bets, action tags, turn timer, raise panel with
  presets, showdown results with winning cards highlighted, compact 7+ layout
- Remove the host crown and the temporary "next phase" control

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

361 lines
12 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';
const config = require('./config');
const { newDeck, bestHand, compareScore } = require('./cards');
// 1ハンド分の進行(配札・ベッティング・ショーダウン・ポット分配)
// seats は Table.seats への参照で、スタックはここで直接増減する。
// 参加者の席はハンド終了まで空席にならない(退席者は Table 側で予約したまま)。
class Hand {
constructor({ number, seats, order, button, sb, bb, now }) {
this.number = number;
this.seats = seats;
this.order = order; // 参加者の席番号。ボタンの次から時計回り
this.button = button;
this.sb = sb;
this.bb = bb;
this.deck = newDeck();
this.board = [];
this.street = 'PREFLOP';
this.pot = 0; // 前のストリートまでに集めた額
this.currentBet = 0;
this.minRaise = bb;
this.toAct = null;
this.deadline = null;
this.runoutAt = null;
this.revealed = false; // オールインで以降のアクションが無いとき、ショーダウン時に公開
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,
cards: [],
bet: 0, // このストリートで出した額
committed: 0, // このハンドで出した合計
folded: false,
allIn: false,
acted: false,
last: null, // 直前のアクション(表示用)
won: 0,
best: null,
});
}
for (let r = 0; r < 2; r++) for (const i of order) this.players.get(i).cards.push(this.deck.pop());
// ヘッズアップはボタンが 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, now);
}
get list() {
return this.order.map((i) => this.players.get(i));
}
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, now) {
this.toAct = null;
this.deadline = null;
if (this.bettingClosed()) return this.endStreet(now);
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;
this.deadline = now + config.ACTION_TIME_MS;
// 離席中・退席済みの人は自動でチェック/フォールド
const s = this.seats[p.seat];
if (s.sittingOut || s.left) this.autoAct(now);
return;
}
this.endStreet(now);
}
// 手番の人の選択肢(手番でなければ 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, now) {
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, now);
return null;
}
autoAct(now) {
const o = this.options(this.toAct);
if (o) this.act(this.toAct, o.canCheck ? 'check' : 'fold', 0, now);
}
// 退席などで手番に関係なくフォールドさせる
forceFold(seat, now) {
const p = this.players.get(seat);
if (!p || p.folded || this.finished) return;
if (this.toAct === seat) return this.act(seat, 'fold', 0, now);
p.folded = true;
p.last = { type: 'FOLD' };
if (this.live().length === 1) {
this.runoutAt = null;
this.endStreet(now);
} else if (this.toAct != null && this.bettingClosed()) {
this.endStreet(now);
}
}
// 誰もコールしなかった分を返す
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(now) {
this.toAct = null;
this.deadline = 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.finish(false);
if (this.street === 'RIVER') return this.finish(true);
this.currentBet = 0;
this.minRaise = this.bb;
if (this.live().filter((p) => !p.allIn).length >= 2) {
this.dealStreet();
this.setNextActor(this.button, now);
} else {
// これ以上ベットが無いので手札を公開して残りのカードを順にめくる
this.revealed = true;
this.runoutAt = now + config.RUNOUT_STEP_MS;
}
}
dealStreet() {
this.deck.pop(); // バーン
if (this.street === 'PREFLOP') {
this.board.push(this.deck.pop(), this.deck.pop(), this.deck.pop());
this.street = 'FLOP';
} else if (this.street === 'TURN' || this.street === 'FLOP') {
this.board.push(this.deck.pop());
this.street = this.street === 'FLOP' ? 'TURN' : 'RIVER';
}
}
runoutStep(now) {
if (this.street === 'RIVER') {
this.runoutAt = null;
return this.finish(true);
}
this.dealStreet();
this.runoutAt = now + config.RUNOUT_STEP_MS;
}
// 手番の時間切れ。true なら時間切れ処理をした
timeout(now) {
if (this.finished || this.toAct == null || now < this.deadline) return false;
this.autoAct(now);
return true;
}
// メインポット・サイドポット(同じ参加者のポットはまとめる)
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;
}
finish(showdown) {
this.finished = true;
this.showdown = showdown;
this.toAct = null;
this.deadline = null;
this.runoutAt = null;
const results = [];
if (!showdown) {
const winner = this.live()[0];
winner.won = this.pot;
this.seats[winner.seat].stack += this.pot;
results.push({ amount: this.pot, winners: [winner.seat], handName: null, bestCards: [] });
} else {
this.revealed = true;
for (const p of this.live()) p.best = bestHand(p.cards.concat(this.board));
for (const pot of this.buildPots()) {
const contenders = pot.eligible.map((s) => this.players.get(s));
let top = null;
for (const c of contenders) if (!top || compareScore(c.best.score, top) > 0) top = c.best.score;
const winners = contenders.filter((c) => compareScore(c.best.score, top) === 0);
// 割り切れない端数はボタンの左から順に
const share = Math.floor(pot.amount / winners.length);
let rest = pot.amount - share * winners.length;
for (const w of winners) {
const amount = share + (rest > 0 ? 1 : 0);
if (rest > 0) rest--;
w.won += amount;
this.seats[w.seat].stack += amount;
}
results.push({
amount: pot.amount,
winners: winners.map((w) => w.seat),
handName: winners[0].best.name,
bestCards: winners[0].best.cards,
});
}
}
this.pot = results.reduce((sum, r) => sum + r.amount, 0);
this.results = results;
}
// 閲覧者 viewerSeat に見せてよい情報だけを返す(他人の手札は公開時のみ)
view(viewerSeat, viewerToken) {
const players = {};
for (const p of this.list) {
const mine = p.seat === viewerSeat && p.token === viewerToken;
const shown = mine || (this.revealed && !p.folded);
let handName = null;
if (shown && !p.folded && this.board.length >= 3) {
handName = (p.best || bestHand(p.cards.concat(this.board))).name;
}
players[p.seat] = {
bet: p.bet,
folded: p.folded,
allIn: p.allIn,
last: p.last,
cards: shown ? p.cards : null,
handName,
won: p.won,
};
}
return {
number: this.number,
street: this.street,
board: this.board,
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,
results: this.results,
players,
};
}
}
module.exports = { Hand };