A new table type, "review" (レビュー付きCPU戦), can only be created by admins. Only the creator and CPUs sit there: friends cannot join, and the table is hidden from non-admin lobbies. After every hand a review opens, and the next hand waits until the creator presses "次のハンドへ". - Preflop: shows which color of the registered hand range the hole cards are and whether the action followed it. With no entrant before you, open with the color for the number of players still to act; facing an entrant, estimate their range from their position, call one color above it and raise two above. Purple ranks just below white (SB only). - Flop onward: equity vs ranges estimated from those colors (narrowed by later bets/calls) gives a rough EV per option. The score is the EV lost against the best option, relative to the pot. It is a guide, not GTO. - Also: pot-odds call check, all-in equity and luck-adjusted P&L, VPIP/PFR/3bet/Cbet/WTSD/W$SD/AF, per-color and per-position compliance, habits to watch, worst-5 hands, a P&L chart and a step-by-step replay on the admin-only /review page. Hand now logs every action for the review. The range data moves to admin/range-data.js, shared by the range page and the server. Reviews are saved under data/reviews/ (newest 50 sessions). Admin scripts and CSS are no longer cached for a day, so updates show up immediately. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
574 lines
20 KiB
JavaScript
574 lines
20 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');
|
|
|
|
// テーブルの種類
|
|
// 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.withBots = this.mode === 'cpu' || this.mode === 'review';
|
|
if (this.withBots) this.seatBots(opts.cpuCount);
|
|
// レビュー付きCPU戦: session = 保存する対局、last = 直前のハンドのレビュー、waiting = 次のハンドへ進むのを待っている
|
|
this.review = this.mode === 'review' ? { session: null, last: null, waiting: false } : 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);
|
|
}
|
|
|
|
// 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();
|
|
// チップがなくなった 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++;
|
|
if (this.review) this.review.waiting = false;
|
|
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;
|
|
}
|
|
|
|
// ---- ホスト操作 ----
|
|
|
|
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.review && !this.hand.reviewed) {
|
|
this.reviewHand();
|
|
changed = true;
|
|
}
|
|
if (this.hand && this.hand.finished && this.review && this.review.waiting) 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();
|
|
}
|
|
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戦 ----
|
|
|
|
// 終わったハンドのレビューを作って保存し、作成者が見終わるまで次のハンドを待つ
|
|
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;
|
|
this.review.waiting = !this.seats[hero].left;
|
|
}
|
|
|
|
reviewNext(token) {
|
|
if (!this.review) return 'レビュー付きのテーブルではありません';
|
|
if (!this.isHost(token)) return '操作できません';
|
|
this.review.waiting = false;
|
|
return null;
|
|
}
|
|
|
|
// 作成者に送るレビュー(直前のハンドの要約)
|
|
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,
|
|
waiting: r.waiting,
|
|
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,
|
|
};
|
|
}
|
|
|
|
// 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 = Boolean(this.handActive && h.players.has(mySeat) && !h.players.get(mySeat).folded);
|
|
|
|
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,
|
|
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,
|
|
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: me.stack + (inLiveHand ? h.players.get(mySeat).committed : 0) - me.buyIns * this.startingStack,
|
|
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),
|
|
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 };
|