Files
野口煌陽andClaude Opus 5.5 f64c502086 Wait for the host to press "next hand" on every table
Previously only the review CPU table paused after each hand; cash-only,
friends and CPU tables moved on automatically after a short delay. Now
every table keeps the result on screen until the host presses 次のハンドへ,
and the next hand is dealt right away. If the host has left, the new host
presses it; with no human seated the table still advances on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:56:37 +09:00

665 lines
24 KiB
JavaScript

'use strict';
const crypto = require('crypto');
const config = require('./config');
const Blinds = require('../public/assets/blinds');
const { Hand } = require('./hand');
const ai = require('./ai');
const { analyzeHand } = require('./review/analyze');
const reviews = require('./review/store');
const { handStats } = require('./stats/hand');
const stats = require('./stats/store');
// テーブルの種類
// chips : キャッシュオンリー。カードは実物で配り、サイトはチップだけを管理する
// friends: 友人戦。サーバがカードを配り、ショーダウンも自動で判定する
// cpu : CPU戦。友人戦に CPU プレイヤーが加わる
// review : レビュー付きCPU戦(管理者専用)。作成者と CPU だけで遊び、ハンドごとにレビューを作る。
// レビューを見てから次のハンドに進めるよう、ハンドが終わると作成者が「次のハンドへ」を押すまで待つ
const MODES = ['chips', 'friends', 'cpu', 'review'];
const CPU_NAMES = ['ハル', 'ソラ', 'リク', 'ミオ', 'レン', 'ユイ', 'カイ', 'ナギ'];
class Table {
constructor(opts) {
this.id = opts.id;
this.name = opts.name;
this.startingStack = opts.startingStack;
this.baseSmallBlind = opts.smallBlind;
this.baseBigBlind = opts.bigBlind;
this.timerEnabled = opts.blindTimerEnabled;
this.intervalMin = opts.blindIntervalMin;
this.mode = MODES.includes(opts.mode) ? opts.mode : 'chips';
// カード機能: サーバがカードを配り、ショーダウンの勝敗も自動で判定する(キャッシュオンリーは実物のカード)
this.cardsEnabled = this.mode !== 'chips';
this.hostToken = opts.hostToken || null;
this.maxSeats = config.MAX_SEATS;
// { token, name, stack, buyIns, sittingOut, left, bot, style }
// left: ハンド中に退席した人。ハンドが終わるまで席を予約しておく
// bot : CPU プレイヤー(style は思考の種類と性格。ブラウザには渡さない)
this.seats = new Array(this.maxSeats).fill(null);
this.botTurn = null; // { key, at }: CPU が行動する時刻
this.pendingSeating = null; // ホストが決めた席順(時計回りのトークン)。ハンド中は次のハンドの始めに並べ替える
this.withBots = this.mode === 'cpu' || this.mode === 'review';
if (this.withBots) this.seatBots(opts.cpuCount);
// レビュー付きCPU戦: session = 保存する対局、last = 直前のハンドのレビュー
this.review = this.mode === 'review' ? { session: null, last: null } : null;
this.hand = null;
this.started = false;
this.paused = false;
this.pausedAt = null;
this.level = 1;
this.handNumber = 0;
this.buttonSeat = null;
this.nextHandAt = null;
this.runoutAt = null; // オールイン後、次の場札をめくる時刻
this.levelEndsAt = null;
this.levelRemainingMs = this.intervalMs;
this.createdAt = Date.now();
this.emptySince = null;
}
// CPU は席1から順に座らせる(席0は作成者)
seatBots(count) {
const n = Math.max(1, Math.min(this.maxSeats - 1, Number.isInteger(count) ? count : 1));
const names = CPU_NAMES.slice();
for (let i = names.length - 1; i > 0; i--) {
const j = crypto.randomInt(i + 1);
[names[i], names[j]] = [names[j], names[i]];
}
const styles = ai.makeStyles(n); // 思考(使うライブラリ)はなるべくばらけさせる。画面には出さない
for (let k = 0; k < n; k++) {
this.seats[k + 1] = {
token: crypto.randomBytes(16).toString('hex'), // ブラウザには渡さない
name: names[k],
stack: this.startingStack,
buyIns: 1,
sittingOut: false,
left: false,
bot: true,
style: styles[k],
};
}
}
get intervalMs() {
return this.intervalMin * 60 * 1000;
}
get blinds() {
return Blinds.blindsAt(this.baseSmallBlind, this.baseBigBlind, this.level);
}
get phase() {
if (!this.hand) return 'WAITING';
return this.hand.finished ? 'FINISHING' : this.hand.street;
}
get handActive() {
return Boolean(this.hand && !this.hand.finished);
}
get playerCount() {
return this.seats.filter((s) => s && !s.left).length;
}
seatOf(token) {
return token ? this.seats.findIndex((s) => s && s.token === token) : -1;
}
isHost(token) {
return Boolean(token) && token === this.hostToken;
}
// 次のハンドに参加できる席
eligibleSeats() {
return this.seats
.map((s, i) => (s && !s.left && !s.sittingOut && s.stack > 0 ? i : -1))
.filter((i) => i >= 0);
}
// ハンドが終わったら、ホストが「次のハンドへ」を押すまで次のハンドを始めない
// (ホストが着席していなければ待たずに進める)
get awaitingNext() {
const h = this.hand;
if (!h || !h.finished || h.nextApproved) return false;
const host = this.seatOf(this.hostToken);
return host >= 0 && !this.seats[host].left;
}
nextHand(token, now = Date.now()) {
if (!this.isHost(token)) return 'ホストのみ操作できます';
if (this.paused) return '一時停止中です';
if (!this.awaitingNext) return 'ハンドが終わっていません';
this.hand.nextApproved = true;
this.nextHandAt = now; // 待たずに次の tick で始める
return null;
}
// CPU だけでハンドを続けても意味がないので、人間が1人は参加している必要がある
canDeal() {
const eligible = this.eligibleSeats();
return this.started && !this.paused && eligible.length >= 2 && eligible.some((i) => !this.seats[i].bot);
}
// 着席した席番号を返す。満席なら -1(観戦)
join(token, name) {
const existing = this.seatOf(token);
if (existing >= 0) {
const s = this.seats[existing];
s.name = name;
if (s.left) {
s.left = false; // ハンド中に退席して戻ってきた
if (this.seatOf(this.hostToken) < 0 || this.seats[this.seatOf(this.hostToken)].left) this.hostToken = token;
}
return existing;
}
if (this.mode === 'review' && token !== this.hostToken) return -1; // 作成者と CPU だけのテーブル
const free = this.seats.findIndex((s) => !s);
if (free < 0) return -1;
this.seats[free] = { token, name, stack: this.startingStack, buyIns: 1, sittingOut: false, left: false };
// ホスト(通常は作成者)が着席していなければ、この人がホストになる
const host = this.seatOf(this.hostToken);
if (host < 0 || this.seats[host].left) this.hostToken = token;
return free;
}
leave(token) {
const i = this.seatOf(token);
if (i < 0 || this.seats[i].left) return false;
if (this.hand && this.hand.players.has(i)) {
// 結果表示やポット計算のため、ハンドが終わるまで席は残す
this.seats[i].left = true;
this.hand.forceFold(i);
} else {
this.seats[i] = null;
}
if (this.hostToken === token) {
const next = this.seats.find((s) => s && !s.left && !s.bot);
this.hostToken = next ? next.token : null;
}
return true;
}
// CPU戦で人間が全員退席したら、テーブルを残しておく意味がない
shouldClose() {
return this.withBots && !this.seats.some((s) => s && !s.left && !s.bot);
}
removeLeftSeats() {
this.seats = this.seats.map((s) => (s && s.left ? null : s));
}
nextOf(list, from) {
for (let k = 1; k <= this.maxSeats; k++) {
const i = (from + k) % this.maxSeats;
if (list.includes(i)) return i;
}
return null;
}
startNewHand() {
this.removeLeftSeats();
this.applySeating();
// チップがなくなった CPU は自動でリバイ
for (const s of this.seats) {
if (s && s.bot && s.stack === 0) {
s.stack = this.startingStack;
s.buyIns++;
}
}
const eligible = this.eligibleSeats();
this.buttonSeat =
this.buttonSeat == null
? eligible[crypto.randomInt(eligible.length)]
: this.nextOf(eligible, this.buttonSeat);
const order = [];
for (let i = this.nextOf(eligible, this.buttonSeat); order.length < eligible.length; i = this.nextOf(eligible, i)) {
order.push(i);
}
this.handNumber++;
const { sb, bb } = this.blinds;
this.nextHandAt = null;
this.runoutAt = null;
this.botTurn = null;
this.hand = new Hand({
number: this.handNumber,
seats: this.seats,
order,
button: this.buttonSeat,
sb,
bb,
cards: this.cardsEnabled,
});
}
// ---- プレイヤー操作(エラー時はメッセージを返す) ----
act(token, type, amount) {
const i = this.seatOf(token);
if (i < 0) return '着席していません';
if (!this.handActive) return 'ハンドが進行していません';
if (this.paused) return '一時停止中です';
return this.hand.act(i, type, amount);
}
// ショーダウンの勝者(ポットの分配)を決められるのはテーブルのホストだけ
canAward(token) {
return this.handActive && this.hand.awaitingWinners && this.isHost(token);
}
award(token, winners) {
if (!this.handActive || !this.hand.awaitingWinners) return 'ショーダウンではありません';
if (!this.canAward(token)) return 'ポットの分配はホストのみ行えます';
if (this.paused) return '一時停止中です';
return this.hand.award(winners);
}
setSittingOut(token, value) {
const i = this.seatOf(token);
if (i < 0) return '着席していません';
const s = this.seats[i];
s.sittingOut = value;
// 手番中に離席したら、その場でチェック/フォールド
if (value && this.handActive && this.hand.toAct === i && !this.paused) this.hand.autoAct();
return null;
}
rebuy(token) {
const i = this.seatOf(token);
if (i < 0) return '着席していません';
const s = this.seats[i];
if (s.stack > 0) return 'チップが残っています';
if (this.handActive && this.hand.players.has(i) && !this.hand.players.get(i).folded) return 'ハンドの終了後にリバイできます';
s.stack = this.startingStack;
s.buyIns++;
s.sittingOut = false;
return null;
}
// ---- ホスト操作 ----
// 席順を変える。order = 着席している全員の席番号を時計回りに並べたもの。
// 進行中(結果の表示中を含む)のハンドは席番号で参加者を覚えているので、そのときは次のハンドの始めに並べ替える
setSeating(token, order) {
if (!this.isHost(token)) return 'ホストのみ操作できます';
const current = this.seats.map((s, i) => (s && !s.left ? i : -1)).filter((i) => i >= 0);
const valid =
Array.isArray(order) &&
order.length === current.length &&
new Set(order).size === order.length &&
order.every((i) => current.includes(i));
if (!valid) return '着席している人が変わりました。もう一度並べてください';
this.pendingSeating = order.map((i) => this.seats[i].token);
if (!this.hand) this.applySeating();
return null;
}
// 決めてある席順に並べ替える。席は詰めて 0 番から座らせる(空席は画面に出ないので、並びだけが意味を持つ)。
// 決めた後に座った人は最後(ホストの並びの後ろ)に、元の順のまま加える。ボタンは同じ人に付いたまま
applySeating() {
const tokens = this.pendingSeating;
if (!tokens) return;
this.pendingSeating = null;
const rank = (s) => {
const k = tokens.indexOf(s.token);
return k < 0 ? tokens.length : k;
};
const button = this.buttonSeat != null && this.seats[this.buttonSeat] ? this.seats[this.buttonSeat].token : null;
const players = this.seats
.map((s, k) => ({ s, k }))
.filter((x) => x.s)
.sort((a, b) => rank(a.s) - rank(b.s) || a.k - b.k)
.map((x) => x.s);
this.seats = new Array(this.maxSeats).fill(null);
players.forEach((s, i) => {
this.seats[i] = s;
});
this.buttonSeat = button ? this.seatOf(button) : null;
}
start(token, now = Date.now()) {
if (!this.isHost(token)) return 'ホストのみ開始できます';
if (this.started) return 'すでに開始しています';
if (this.eligibleSeats().length < 2) return '2人以上必要です';
this.started = true;
if (this.timerEnabled) this.levelEndsAt = now + this.intervalMs;
this.startNewHand();
return null;
}
togglePause(token, now = Date.now()) {
if (!this.isHost(token)) return 'ホストのみ操作できます';
if (!this.started) return 'まだ開始していません';
if (this.paused) {
// 止めていた時間だけ次のハンドの開始・場札をめくる時刻を後ろにずらす
if (this.nextHandAt != null) this.nextHandAt += now - this.pausedAt;
if (this.runoutAt != null) this.runoutAt += now - this.pausedAt;
this.botTurn = null; // CPU は再開してから考え直す
if (this.timerEnabled) this.levelEndsAt = now + this.levelRemainingMs;
this.paused = false;
this.pausedAt = null;
} else {
this.paused = true;
this.pausedAt = now;
if (this.timerEnabled) {
this.levelRemainingMs = Math.max(0, this.levelEndsAt - now);
this.levelEndsAt = null;
}
}
return null;
}
// CPU の考える時間。人間がハンドに残っていなければ早めに進める
botDelay() {
const h = this.hand;
const humanLive = h.live().some((p) => !this.seats[p.seat].bot && !this.seats[p.seat].left);
if (!humanLive) return config.BOT_FAST_MS;
return config.BOT_THINK_MIN_MS + crypto.randomInt(config.BOT_THINK_MAX_MS - config.BOT_THINK_MIN_MS + 1);
}
// 手番の CPU が考える時間を過ぎていたら行動する。行動したら true
botStep(now) {
const h = this.hand;
if (!h || h.finished || h.toAct == null || !this.seats[h.toAct].bot) return false;
const key = `${h.number}:${h.actions}`;
if (!this.botTurn || this.botTurn.key !== key) {
this.botTurn = { key, at: now + this.botDelay() };
return false;
}
if (now < this.botTurn.at) return false;
this.botTurn = null;
const seat = h.toAct;
const d = ai.decide(h, seat, { style: this.seats[seat].style, iterations: config.BOT_ITERATIONS });
// 万一判断が不正でも止まらないよう、チェック/フォールドにする
if (!d || h.act(seat, d.type, d.amount)) h.autoAct();
return true;
}
// 時間で進む処理(ブラインドタイマー・CPU の行動・オールイン後の場札・次のハンドの開始)
// 状態が変わったら true
tick(now) {
let changed = false;
while (this.levelEndsAt != null && now >= this.levelEndsAt) {
this.level++;
this.levelEndsAt += this.intervalMs;
changed = true;
}
if (this.paused) return changed;
if (this.botStep(now)) changed = true;
const h = this.hand;
if (h && h.runout) {
if (this.runoutAt == null) this.runoutAt = now + config.RUNOUT_STEP_MS;
else if (now >= this.runoutAt) {
h.runoutStep();
this.runoutAt = h.runout ? now + config.RUNOUT_STEP_MS : null;
changed = true;
}
}
if (this.hand && this.hand.finished && !this.hand.statsRecorded) this.recordStats();
if (this.hand && this.hand.finished && this.review && !this.hand.reviewed) {
this.reviewHand();
changed = true;
}
if (this.awaitingNext) return changed;
if (this.hand && this.hand.finished) {
if (this.nextHandAt == null) {
const showdownMs = this.cardsEnabled ? config.CARD_SHOWDOWN_MS : config.SHOWDOWN_MS;
this.nextHandAt = now + (this.hand.showdown ? showdownMs : config.FOLD_WIN_MS);
} else if (now >= this.nextHandAt) {
if (this.canDeal()) this.startNewHand();
else {
this.hand = null;
this.nextHandAt = null;
this.removeLeftSeats();
this.applySeating();
}
changed = true;
}
} else if (!this.hand) {
if (!this.canDeal()) this.nextHandAt = null;
else if (this.nextHandAt == null) this.nextHandAt = now + config.NEXT_HAND_DELAY_MS;
else if (now >= this.nextHandAt) {
this.startNewHand();
changed = true;
}
}
return changed;
}
// ---- 統計 ----
// 終わったハンドを、参加した人間のプレイヤーごとの統計に足す(CPU は数えない)。
// レビュー付きCPU戦は CPU戦としてまとめる
recordStats() {
const h = this.hand;
h.statsRecorded = true;
const mode = this.mode === 'review' ? 'cpu' : this.mode;
for (const s of h.order) {
const seat = this.seats[s];
if (!seat || seat.bot) continue;
try {
stats.record(seat.token, mode, handStats(h, s), { startedAt: h.startedAt, endedAt: h.endedAt || Date.now() });
} catch (err) {
console.error('[stats] 統計を数えられませんでした', err);
}
}
}
// ---- レビュー付きCPU戦 ----
// 終わったハンドのレビューを作って保存する(作成者が見終わって「次のハンドへ」を押すまで次のハンドは始まらない)
reviewHand() {
const h = this.hand;
h.reviewed = true;
const hero = this.seatOf(this.hostToken);
if (hero < 0 || !h.players.has(hero)) return;
let record;
try {
record = analyzeHand(h, hero, {
names: new Map(h.order.map((s) => [s, this.seats[s] ? this.seats[s].name : '退席者'])),
bots: new Set(h.order.filter((s) => this.seats[s] && this.seats[s].bot)),
iterations: config.REVIEW_ITERATIONS,
});
} catch (err) {
console.error('[review] レビューを作れませんでした', err);
return;
}
if (!this.review.session) {
this.review.session = reviews.create({ tableName: this.name, heroName: this.seats[hero].name, startingStack: this.startingStack });
}
reviews.addHand(this.review.session, record);
this.review.last = record;
}
// 作成者に送るレビュー(直前のハンドの要約)
reviewView() {
const r = this.review;
const last = r.last;
const hands = r.session ? r.session.hands : [];
const scores = hands.map((x) => x.handScore).filter((x) => x != null);
return {
sessionId: r.session ? r.session.id : null,
hands: hands.length,
average: scores.length ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length) : null,
last: last && {
number: last.number,
bb: last.bb,
hero: last.hero,
preflop: last.preflop,
decisions: last.decisions.map((d) => ({
street: d.street,
equity: d.equity,
need: d.need,
actual: d.actual,
best: d.best,
lossBB: d.lossBB,
score: d.score,
mark: d.mark,
})),
streetScores: last.streetScores,
handScore: last.handScore,
},
};
}
summary() {
const { sb, bb } = this.blinds;
return {
id: this.id,
name: this.name,
playerCount: this.playerCount,
maxSeats: this.maxSeats,
startingStack: this.startingStack,
smallBlind: sb,
bigBlind: bb,
level: this.level,
phase: this.phase,
paused: this.paused,
timerEnabled: this.timerEnabled,
intervalMin: this.intervalMin,
cardsEnabled: this.cardsEnabled,
mode: this.mode,
createdAt: this.createdAt,
};
}
inLiveHand(seat) {
return Boolean(this.handActive && this.hand.players.has(seat) && !this.hand.players.get(seat).folded);
}
// ランキング用: 持っているチップ(進行中のハンドで出した額は、降りていなければまだ本人の分として含める)と
// 収支(それ − バイインの合計)
standing(seat) {
const s = this.seats[seat];
const held = s.stack + (this.inLiveHand(seat) ? this.hand.players.get(seat).committed : 0);
return { held, net: held - s.buyIns * this.startingStack };
}
// token = 閲覧者のトークン(他人のトークンや手札は送らない)
publicState(token, connectedTokens) {
const now = Date.now();
const { sb, bb } = this.blinds;
const next = Blinds.blindsAt(this.baseSmallBlind, this.baseBigBlind, this.level + 1);
const mySeat = this.seatOf(token);
const h = this.hand;
const hv = h ? h.view(mySeat, token) : null;
const me = mySeat >= 0 ? this.seats[mySeat] : null;
const inLiveHand = this.inLiveHand(mySeat);
return {
id: this.id,
name: this.name,
startingStack: this.startingStack,
maxSeats: this.maxSeats,
cardsEnabled: this.cardsEnabled,
mode: this.mode,
level: this.level,
smallBlind: sb,
bigBlind: bb,
nextBlinds: { smallBlind: next.sb, bigBlind: next.bb },
timer: {
enabled: this.timerEnabled,
intervalMin: this.intervalMin,
endsAt: this.levelEndsAt,
remainingMs: this.levelEndsAt != null ? this.levelEndsAt - now : this.levelRemainingMs,
running: this.levelEndsAt != null,
},
phase: this.phase,
started: this.started,
paused: this.paused,
awaitingNext: this.awaitingNext,
handNumber: this.handNumber,
hand: hv,
toAct: this.handActive ? h.toAct : null,
seats: this.seats.map((s, i) =>
s && !s.left
? {
seat: i,
name: s.name,
stack: s.stack,
...this.standing(i),
connected: Boolean(s.bot) || connectedTokens.has(s.token),
bot: Boolean(s.bot),
sittingOut: s.sittingOut,
...(hv && hv.players[i] ? { inHand: true, ...hv.players[i] } : { inHand: false }),
}
: null
),
mySeat: me && !me.left ? mySeat : -1,
me:
me && !me.left
? {
stack: me.stack,
net: this.standing(mySeat).net,
buyIns: me.buyIns,
sittingOut: me.sittingOut,
canRebuy: me.stack === 0 && !inLiveHand,
options: this.handActive && !this.paused ? h.options(mySeat) : null,
canAward: this.canAward(token),
}
: null,
isHost: this.isHost(token),
// ホストだけに: 次のハンドから使う席順(席番号を時計回りに)
pendingSeating:
this.isHost(token) && this.pendingSeating
? this.pendingSeating.map((t) => this.seatOf(t)).filter((i) => i >= 0 && !this.seats[i].left)
: null,
review: this.review && this.isHost(token) ? this.reviewView() : null,
serverNow: now,
};
}
}
class TableManager {
constructor() {
this.tables = new Map();
}
get(id) {
return this.tables.get(id);
}
// レビュー付きCPU戦は管理者にだけ見せる
list(admin = false) {
return [...this.tables.values()]
.filter((t) => admin || t.mode !== 'review')
.sort((a, b) => b.createdAt - a.createdAt)
.map((t) => t.summary());
}
nameTaken(name) {
const n = name.toLowerCase();
return [...this.tables.values()].some((t) => t.name.toLowerCase() === n);
}
suggestName() {
for (;;) {
const name = `Table #${1000 + crypto.randomInt(9000)}`;
if (!this.nameTaken(name)) return name;
}
}
create(opts) {
let id;
do id = crypto.randomBytes(4).toString('hex');
while (this.tables.has(id));
const table = new Table({ ...opts, id });
this.tables.set(id, table);
return table;
}
delete(id) {
const t = this.tables.get(id);
if (t && t.review && t.review.session) reviews.close(t.review.session);
this.tables.delete(id);
}
}
module.exports = { TableManager, Table, MODES };