Add CPU opponents and a table type selector (cash only / friends / CPU)

- Create screen: choose キャッシュオンリー (real cards, chips only), 友人戦
  (server deals and settles) or CPU戦 (友人戦 plus 1–8 CPU players); replaces
  the card toggle. The lobby shows each table's type
- Poker AI written from scratch (server/ai.js, no libraries): fast 7-card
  evaluator (checked against bestHand), Monte Carlo equity, and decisions from
  hand rank, pot odds, raise count, opponents and a per-CPU style (aggression,
  bluff rate); continuation bets via the last aggressor. It never sees other
  players' hole cards
- CPU seats: think time of 0.7–1.8s (faster when no human is left in the hand),
  automatic rebuy, never the host, no new hand unless a human is playing,
  re-think after pause
- Play UI: robot avatars and a "考えています…" status for CPU turns
- Tests for the evaluator, equity, legality of AI actions and CPU tables

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
agomizi1cmandClaude Opus 5.5 committed 2026-10-17 16:55:25 +09:00
1 parent 66907345ff
commit 0d3a054f0d
12 files changed
+550 -46

No files matched your search

+17 -9
View File
@@ -16,8 +16,8 @@
const timerInput = $('timer');
const intervalInput = $('interval');
const scheduleEl = $('schedule');
const cardsInput = $('cards');
const cardsHint = $('cards-hint');
const modeHint = $('mode-hint');
const cpuOptions = $('cpu-options');
const blindHint = $('blind-hint');
const errorEl = $('error');
const submitBtn = $('submit');
@@ -68,16 +68,23 @@
.join('');
}
function updateCardsHint() {
cardsHint.textContent = cardsInput.checked
? 'オン: 各自のスマホに手札が配られ、勝敗とポットの分配は自動'
: 'オフ: 実物のカードで遊び、ショーダウンの勝者はホストが選ぶ';
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();
updateCardsHint();
updateMode();
}
stackInput.addEventListener('input', () => {
@@ -100,7 +107,7 @@
});
timerInput.addEventListener('change', refresh);
intervalInput.addEventListener('input', refresh);
cardsInput.addEventListener('change', refresh);
form.querySelectorAll('input[type="radio"]').forEach((el) => el.addEventListener('change', refresh));
$('reroll').addEventListener('click', suggestName);
function validate() {
@@ -138,7 +145,8 @@
bigBlind: int(bbInput),
blindTimerEnabled: timerInput.checked,
blindIntervalMin: int(intervalInput),
cardsEnabled: cardsInput.checked,
mode: radioValue('mode'),
cpuCount: Number(radioValue('cpu-count')),
token: Store.token,
});
location.href = '/table/' + encodeURIComponent(id);
+3 -1
View File
@@ -36,6 +36,8 @@
if (btn && requireName()) location.href = '/table/' + encodeURIComponent(btn.dataset.join);
});
const MODE_LABEL = { chips: '💰 キャッシュオンリー', friends: '🃏 友人戦', cpu: '🤖 CPU戦' };
function phaseBadge(t) {
if (t.paused) return '<span class="badge paused">PAUSED</span>';
if (t.phase === 'WAITING') return '<span class="badge">WAITING</span>';
@@ -61,7 +63,7 @@
<span>開始 <b>${chips(t.startingStack)}</b></span>
<span class="gold">Lv.${t.level} <b class="gold">${chips(t.smallBlind)}/${chips(t.bigBlind)}</b></span>
<span>${timer}</span>
${t.cardsEnabled ? '<span>🃏 カードあり</span>' : ''}
<span>${MODE_LABEL[t.mode] || ''}</span>
</div>
</div>
<button class="btn btn-blue btn-sm" data-join="${esc(t.id)}" ${full ? 'title="満席(観戦のみ)"' : ''}>
+24
View File
@@ -255,6 +255,29 @@ input.invalid { border-color: var(--red); }
.switch input:checked + span::before { transform: translateX(22px); background: #fff; }
.switch input:focus-visible + span { outline: 2px solid var(--gold); }
/* 選択肢を横に並べたラジオボタン(テーブルの種類・CPU の人数) */
.segmented { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 6px; }
.segmented label { margin: 0; display: block; color: var(--text); }
.segmented input { position: absolute; opacity: 0; width: 0; height: 0; }
.segmented span {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
min-height: 52px;
padding: 6px 4px;
border-radius: 12px;
background: var(--input);
border: 2px solid transparent;
font-weight: 800;
font-size: 14px;
line-height: 1.2;
cursor: pointer;
}
.segmented input:checked + span { background: rgba(246, 199, 68, 0.15); border-color: var(--gold); color: var(--gold); }
.segmented input:focus-visible + span { outline: 2px solid var(--gold); outline-offset: 2px; }
.segmented.numbers span { min-height: 40px; font-size: 15px; }
.unit-input { position: relative; }
.unit-input input { padding-right: 56px; }
.unit-input .unit {
@@ -400,6 +423,7 @@ body.table-page {
}
.avatar svg { width: 28px; height: 28px; fill: #fff; }
.seat.me .avatar { background: #d35400; border-color: var(--gold); }
.seat.bot .avatar { background: #3d4a6b; border-color: #8fa2d6; }
.seat.offline .avatar { opacity: 0.45; }
.nameplate {
margin-top: -10px;
+8 -2
View File
@@ -9,6 +9,8 @@
const AVATAR_SVG =
'<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="4.5"/><path d="M3 21c0-4.4 4-7.5 9-7.5s9 3.1 9 7.5z"/></svg>';
const BOT_SVG =
'<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="11" y="1.5" width="2" height="3.5" rx="1"/><circle cx="12" cy="1.8" r="1.6"/><path d="M5 7.5A2.5 2.5 0 0 1 7.5 5h9A2.5 2.5 0 0 1 19 7.5v7a2.5 2.5 0 0 1-2.5 2.5h-9A2.5 2.5 0 0 1 5 14.5zM9 9.2a1.6 1.6 0 1 0 0 3.2 1.6 1.6 0 0 0 0-3.2zm6 0a1.6 1.6 0 1 0 0 3.2 1.6 1.6 0 0 0 0-3.2zM2 9.5h2v4H2zm18 0h2v4h-2zM7 18.5h10a3 3 0 0 1 3 3V23H4v-1.5a3 3 0 0 1 3-3z"/></svg>';
// U+FE0E: iOS で絵文字として描画されないように
const SUIT_SYMBOL = { s: '♠︎', h: '♥︎', d: '♦︎', c: '♣︎' };
const ACTION_LABEL = { FOLD: 'Fold', CHECK: 'Check', CALL: 'Call', BET: 'Bet', RAISE: 'Raise', ALLIN: 'All-in' };
@@ -234,6 +236,7 @@
.map((p, idx) => {
const classes = ['seat'];
if (p.seat === s.mySeat) classes.push('me');
if (p.bot) classes.push('bot');
if (!p.connected) classes.push('offline');
if (p.inHand && p.folded) classes.push('folded');
if (s.toAct === p.seat) classes.push('turn');
@@ -273,7 +276,7 @@
return `
<div class="${classes.join(' ')}">
<div class="avatar">${AVATAR_SVG}${markers}${cards}</div>
<div class="avatar">${p.bot ? BOT_SVG : AVATAR_SVG}${markers}${cards}</div>
<div class="nameplate">
${tag}
<div class="pname">${esc(p.name)}</div>
@@ -324,7 +327,10 @@
else if (m.canRebuy) status = 'チップがなくなりました';
else if (o) status = o.canCheck ? 'あなたの番です' : `あなたの番です(コール ${chips(o.toCall)})`;
else if (awaiting) status = m.canAward ? 'カードを見せ合って、勝った人を選んでください' : 'ホストが勝者を選んでいます';
else if (h && !h.finished && s.toAct != null) status = `${nameOf(s.toAct)} の番です`;
else if (h && !h.finished && s.toAct != null) {
const actor = s.seats[s.toAct];
status = actor && actor.bot ? `${actor.name}(CPU)が考えています…` : `${nameOf(s.toAct)} の番です`;
}
else if (h && !h.finished && h.runout) status = 'オールイン — 残りのカードをめくります';
else if (h && !h.finished && !seat.inHand) status = '次のハンドから参加します';
else if (h && h.finished) status = '次のハンドを準備中…';