Replace the hand-rolled betting rules, side pots, showdown and hand evaluator with the published @pokertools/engine and @pokertools/evaluator libraries (MIT, pinned to 2.1.0). - server/hand.js now wraps a PokerEngine per hand: it maps seats, mirrors stacks, staggers the all-in runout, and pays host-chosen winners on cash-only tables. - server/cards.js and server/ai.js use the library evaluator. - Out-of-turn folds go through the engine's stand; all-in hands stay live when the player leaves; uncalled bets return to a leaver. - The engine refuses some check-able folds that would leave a side pot without eligible players; those now return a message (or check for a leaving player) instead of crashing. - BB must be greater than SB (engine requirement); Node >= 24. - Tests rig cards via engine snapshots and cover the new edge cases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
163 lines
5.4 KiB
JavaScript
163 lines
5.4 KiB
JavaScript
(function () {
|
|
'use strict';
|
|
|
|
const { Store, api, chips } = App;
|
|
if (!Store.name) {
|
|
location.replace('/');
|
|
return;
|
|
}
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
const form = $('form');
|
|
const nameInput = $('table-name');
|
|
const stackInput = $('stack');
|
|
const sbInput = $('sb');
|
|
const bbInput = $('bb');
|
|
const timerInput = $('timer');
|
|
const intervalInput = $('interval');
|
|
const scheduleEl = $('schedule');
|
|
const modeHint = $('mode-hint');
|
|
const cpuOptions = $('cpu-options');
|
|
const blindHint = $('blind-hint');
|
|
const errorEl = $('error');
|
|
const submitBtn = $('submit');
|
|
|
|
// ブラインドを手で変えるまではスタックに追従させる
|
|
let blindsTouched = false;
|
|
|
|
async function suggestName() {
|
|
try {
|
|
const { name } = await api('GET', '/api/tables/suggest-name');
|
|
nameInput.value = name;
|
|
} catch { /* 失敗時は手入力 */ }
|
|
}
|
|
|
|
const int = (el) => {
|
|
const n = Number(el.value);
|
|
return Number.isInteger(n) ? n : NaN;
|
|
};
|
|
|
|
function applyDefaultBlinds() {
|
|
const stack = int(stackInput);
|
|
if (!(stack >= 10)) return;
|
|
const { sb, bb } = Blinds.defaultBlinds(stack);
|
|
sbInput.value = sb;
|
|
bbInput.value = bb;
|
|
}
|
|
|
|
function updateHint() {
|
|
const stack = int(stackInput);
|
|
const bb = int(bbInput);
|
|
if (blindsTouched && stack > 0 && bb > 0) {
|
|
blindHint.innerHTML = `開始スタック = ${Math.floor(stack / bb)} BB ・ <a href="#" id="reset-blinds" class="link-gold">おすすめに戻す</a>`;
|
|
} else {
|
|
blindHint.textContent = 'スタックに合わせて自動設定(BB ≒ スタックの1/100)';
|
|
}
|
|
}
|
|
|
|
function updateSchedule() {
|
|
const sb = int(sbInput);
|
|
const bb = int(bbInput);
|
|
const on = timerInput.checked;
|
|
intervalInput.disabled = !on;
|
|
scheduleEl.hidden = !on || !(sb > 0 && bb > sb);
|
|
if (scheduleEl.hidden) return;
|
|
const mins = int(intervalInput) > 0 ? int(intervalInput) : 0;
|
|
scheduleEl.innerHTML = Blinds.schedule(sb, bb, 8)
|
|
.map((l, i) => `<span>Lv.${l.level} <b>${chips(l.sb)}/${chips(l.bb)}</b>${mins ? ` · ${i * mins}分〜` : ''}</span>`)
|
|
.join('');
|
|
}
|
|
|
|
const MODE_HINTS = {
|
|
chips: '実物のカードで遊び、サイトはチップだけを管理。ショーダウンの勝者はホストが選ぶ',
|
|
friends: '各自のスマホに手札が配られ、勝敗とポットの分配は自動',
|
|
cpu: 'CPU と対戦(友達も空いている席に参加できる)。カードの配布・勝敗の判定は自動',
|
|
};
|
|
const radioValue = (name) => (form.querySelector(`input[name="${name}"]:checked`) || {}).value;
|
|
|
|
function updateMode() {
|
|
const mode = radioValue('mode');
|
|
modeHint.textContent = MODE_HINTS[mode] || '';
|
|
cpuOptions.hidden = mode !== 'cpu';
|
|
}
|
|
|
|
function refresh() {
|
|
updateHint();
|
|
updateSchedule();
|
|
updateMode();
|
|
}
|
|
|
|
stackInput.addEventListener('input', () => {
|
|
if (!blindsTouched) applyDefaultBlinds();
|
|
refresh();
|
|
});
|
|
[sbInput, bbInput].forEach((el) =>
|
|
el.addEventListener('input', () => {
|
|
blindsTouched = true;
|
|
if (el === sbInput && int(sbInput) > 0) bbInput.value = int(sbInput) * 2;
|
|
refresh();
|
|
})
|
|
);
|
|
blindHint.addEventListener('click', (e) => {
|
|
if (e.target.id !== 'reset-blinds') return;
|
|
e.preventDefault();
|
|
blindsTouched = false;
|
|
applyDefaultBlinds();
|
|
refresh();
|
|
});
|
|
timerInput.addEventListener('change', refresh);
|
|
intervalInput.addEventListener('input', refresh);
|
|
form.querySelectorAll('input[type="radio"]').forEach((el) => el.addEventListener('change', refresh));
|
|
$('reroll').addEventListener('click', suggestName);
|
|
|
|
function validate() {
|
|
const name = nameInput.value.trim();
|
|
const stack = int(stackInput);
|
|
const sb = int(sbInput);
|
|
const bb = int(bbInput);
|
|
const interval = int(intervalInput);
|
|
if (!name) return [nameInput, 'テーブル名を入力してください'];
|
|
if (!(stack >= 10)) return [stackInput, '開始スタックは10以上の整数にしてください'];
|
|
if (!(sb >= 1)) return [sbInput, 'SBは1以上の整数にしてください'];
|
|
if (!(bb > sb)) return [bbInput, 'BBはSBより大きくしてください'];
|
|
if (bb > stack) return [bbInput, 'BBが開始スタックより大きいです'];
|
|
if (timerInput.checked && !(interval >= 1 && interval <= 240)) return [intervalInput, 'インターバルは1〜240分にしてください'];
|
|
return null;
|
|
}
|
|
|
|
form.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
form.querySelectorAll('.invalid').forEach((el) => el.classList.remove('invalid'));
|
|
const problem = validate();
|
|
if (problem) {
|
|
problem[0].classList.add('invalid');
|
|
problem[0].focus();
|
|
errorEl.textContent = problem[1];
|
|
return;
|
|
}
|
|
errorEl.textContent = '';
|
|
submitBtn.disabled = true;
|
|
try {
|
|
const { id } = await api('POST', '/api/tables', {
|
|
name: nameInput.value.trim(),
|
|
startingStack: int(stackInput),
|
|
smallBlind: int(sbInput),
|
|
bigBlind: int(bbInput),
|
|
blindTimerEnabled: timerInput.checked,
|
|
blindIntervalMin: int(intervalInput),
|
|
mode: radioValue('mode'),
|
|
cpuCount: Number(radioValue('cpu-count')),
|
|
token: Store.token,
|
|
});
|
|
location.href = '/table/' + encodeURIComponent(id);
|
|
} catch (err) {
|
|
errorEl.textContent = err.message;
|
|
submitBtn.disabled = false;
|
|
}
|
|
});
|
|
|
|
applyDefaultBlinds();
|
|
refresh();
|
|
suggestName();
|
|
})();
|