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:
1 parent
66907345ff
commit
0d3a054f0d
12 files changed
+550
-46
No files matched your search
+17
-9
@@ -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);
|
||||
|
||||
@@ -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="満席(観戦のみ)"' : ''}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = '次のハンドを準備中…';
|
||||
|
||||
Reference in new issue
Block a user