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
+134
-6
@@ -395,7 +395,7 @@ test('カードなしのテーブルでは手札も場札も扱わない', () =>
|
||||
});
|
||||
|
||||
test('カードあり: 自分の手札だけが見え、ストリートごとに場札が配られ、勝者は自動で決まる', () => {
|
||||
const t = makeTable([1000, 1000, 1000], { cardsEnabled: true });
|
||||
const t = makeTable([1000, 1000, 1000], { mode: 'friends' });
|
||||
t.start(T(0), 0);
|
||||
const h = t.hand;
|
||||
const [sb, bb, btn] = h.order;
|
||||
@@ -433,7 +433,7 @@ test('カードあり: 自分の手札だけが見え、ストリートごとに
|
||||
});
|
||||
|
||||
test('カードあり: フォールドした人の手札は公開されない', () => {
|
||||
const t = makeTable([1000, 1000, 1000], { cardsEnabled: true });
|
||||
const t = makeTable([1000, 1000, 1000], { mode: 'friends' });
|
||||
t.start(T(0), 0);
|
||||
const h = t.hand;
|
||||
const [sb, bb, btn] = h.order;
|
||||
@@ -449,7 +449,7 @@ test('カードあり: フォールドした人の手札は公開されない',
|
||||
});
|
||||
|
||||
test('カードあり: 3人オールインで場札を順にめくり、サイドポットを自動で分配', () => {
|
||||
const t = makeTable([100, 300, 500], { cardsEnabled: true });
|
||||
const t = makeTable([100, 300, 500], { mode: 'friends' });
|
||||
t.start(T(0), 0);
|
||||
const h = t.hand;
|
||||
// 席0 が最強、席1 が2番目、席2 が最弱
|
||||
@@ -493,7 +493,7 @@ test('カードあり: 3人オールインで場札を順にめくり、サイ
|
||||
});
|
||||
|
||||
test('カードあり: 引き分けは山分け', () => {
|
||||
const t = makeTable([1000, 1000, 1000], { cardsEnabled: true });
|
||||
const t = makeTable([1000, 1000, 1000], { mode: 'friends' });
|
||||
t.start(T(0), 0);
|
||||
const h = t.hand;
|
||||
const [sb, bb, btn] = h.order;
|
||||
@@ -511,7 +511,7 @@ test('カードあり: 引き分けは山分け', () => {
|
||||
});
|
||||
|
||||
test('カードあり: めくっている途中に1人を残して退席したら残った人が勝つ', () => {
|
||||
const t = makeTable([1000, 1000], { cardsEnabled: true });
|
||||
const t = makeTable([1000, 1000], { mode: 'friends' });
|
||||
t.start(T(0), 0);
|
||||
const h = t.hand;
|
||||
t.act(tk(t, h.toAct), 'raise', h.options(h.toAct).maxRaiseTo);
|
||||
@@ -533,7 +533,7 @@ test('カードあり: ランダムに遊んでもチップの合計が変わら
|
||||
for (let game = 0; game < 40; game++) {
|
||||
const n = 2 + (game % 8);
|
||||
const stacks = Array.from({ length: n }, () => 20 + rand(2000));
|
||||
const t = makeTable(stacks, { cardsEnabled: true });
|
||||
const t = makeTable(stacks, { mode: 'friends' });
|
||||
const sum = stacks.reduce((a, b) => a + b, 0);
|
||||
t.start(T(0), 0);
|
||||
let now = 0;
|
||||
@@ -570,3 +570,131 @@ test('カードあり: ランダムに遊んでもチップの合計が変わら
|
||||
}
|
||||
assert.ok(showdowns > 100 && runouts > 20, `showdowns=${showdowns} runouts=${runouts}`);
|
||||
});
|
||||
|
||||
// ---------- CPU ----------
|
||||
|
||||
const ai = require('../server/ai');
|
||||
const { newDeck } = require('../server/cards');
|
||||
|
||||
// 再現できる乱数
|
||||
function seeded(seed) {
|
||||
let x = seed;
|
||||
return () => {
|
||||
x = (x * 1103515245 + 12345) % 2147483648;
|
||||
return x / 2147483648;
|
||||
};
|
||||
}
|
||||
|
||||
test('CPU: 高速な役判定は bestHand と同じ強さの順になる', () => {
|
||||
for (let i = 0; i < 3000; i++) {
|
||||
const d = newDeck();
|
||||
const a = d.slice(0, 7);
|
||||
const b = d.slice(7, 14);
|
||||
const fast = Math.sign(ai.evaluate(a.map(ai.toInt)) - ai.evaluate(b.map(ai.toInt)));
|
||||
const slow = Math.sign(compareScore(bestHand(a).score, bestHand(b).score));
|
||||
assert.equal(fast, slow, `${a} / ${b}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('CPU: 勝率の見積もり', () => {
|
||||
const rng = seeded(42);
|
||||
const near = (v, target) => assert.ok(Math.abs(v - target) < 0.03, `${v} ≉ ${target}`);
|
||||
near(ai.equity(['As', 'Ah'], [], 1, 4000, rng), 0.85);
|
||||
near(ai.equity(['7s', '2h'], [], 1, 4000, rng), 0.35);
|
||||
// ナッツは負けない
|
||||
assert.equal(ai.equity(['As', 'Ks'], ['Qs', 'Js', 'Ts'], 3, 500, rng), 1);
|
||||
});
|
||||
|
||||
test('CPU: 強い手はレイズし、弱い手は大きなベットに降りる', () => {
|
||||
const t = makeTable([1000, 1000, 1000], { mode: 'friends' });
|
||||
t.start(T(0), 0);
|
||||
const h = t.hand;
|
||||
const [sb, bb, btn] = h.order;
|
||||
rig(h, [['7c', '2d'], ['8h', '3s'], ['As', 'Ah']], ['Kd', '9c', '4s', '5h', 'Jd']);
|
||||
const rng = seeded(1);
|
||||
const d = ai.decide(h, btn, { rng });
|
||||
assert.equal(d.type, 'raise');
|
||||
t.act(tk(t, btn), 'raise', 1000); // オールイン
|
||||
assert.equal(ai.decide(h, sb, { rng }).type, 'fold');
|
||||
});
|
||||
|
||||
test('CPU: どんな状況でも正しいアクションを返す', () => {
|
||||
const rng = seeded(7);
|
||||
let actions = 0;
|
||||
for (let game = 0; game < 12; game++) {
|
||||
const n = 2 + (game % 8);
|
||||
const stacks = Array.from({ length: n }, () => 20 + Math.floor(rng() * 2000));
|
||||
const t = makeTable(stacks, { mode: 'friends' });
|
||||
const sum = stacks.reduce((a, b) => a + b, 0);
|
||||
t.start(T(0), 0);
|
||||
let now = 0;
|
||||
for (let step = 0; step < 5000 && t.hand && t.hand.number <= 15; step++) {
|
||||
const h = t.hand;
|
||||
if (!h.finished && h.toAct != null) {
|
||||
const style = ai.randomStyle(rng);
|
||||
const d = ai.decide(h, h.toAct, { style, rng, iterations: 60 });
|
||||
assert.equal(h.act(h.toAct, d.type, d.amount), null, JSON.stringify(d));
|
||||
actions++;
|
||||
} else {
|
||||
t.tick((now += 10 ** 5));
|
||||
}
|
||||
const inPot = t.hand && !t.hand.finished ? t.hand.potTotal : 0;
|
||||
assert.equal(t.seats.reduce((a, s) => a + (s ? s.stack : 0), 0) + inPot, sum);
|
||||
}
|
||||
}
|
||||
assert.ok(actions > 300, `actions=${actions}`);
|
||||
});
|
||||
|
||||
test('CPU戦: CPU が席に着き、ホストは人間のまま、CPU は自動で行動する', () => {
|
||||
const t = new Table({
|
||||
id: 'c',
|
||||
name: 'cpu',
|
||||
startingStack: 1000,
|
||||
smallBlind: 10,
|
||||
bigBlind: 20,
|
||||
blindTimerEnabled: false,
|
||||
blindIntervalMin: 10,
|
||||
hostToken: T(0),
|
||||
mode: 'cpu',
|
||||
cpuCount: 3,
|
||||
});
|
||||
assert.equal(t.cardsEnabled, true);
|
||||
assert.deepEqual(t.seats.map((s) => Boolean(s && s.bot)), [false, true, true, true, false, false, false, false, false]);
|
||||
assert.equal(t.join(T(0), 'me'), 0);
|
||||
assert.equal(t.isHost(T(0)), true);
|
||||
const v = t.publicState(T(0), new Set([T(0)]));
|
||||
assert.equal(v.mode, 'cpu');
|
||||
assert.ok(v.seats[1].bot && v.seats[1].connected);
|
||||
assert.equal(v.seats[0].bot, false);
|
||||
|
||||
assert.equal(t.start(T(0), 0), null);
|
||||
// 人間の手番まで CPU が進める(人間の手番は代わりに動かない)
|
||||
let now = 0;
|
||||
for (let k = 0; k < 200 && t.hand.toAct !== 0 && !t.hand.finished; k++) t.tick((now += 5000));
|
||||
if (!t.hand.finished) {
|
||||
const before = t.hand.actions;
|
||||
t.tick((now += 60000));
|
||||
t.tick((now += 60000));
|
||||
assert.equal(t.hand.toAct, 0);
|
||||
assert.equal(t.hand.actions, before);
|
||||
}
|
||||
|
||||
// 人間が離席すると CPU だけで次のハンドは始まらない
|
||||
t.setSittingOut(T(0), true);
|
||||
for (let k = 0; k < 400 && t.hand; k++) t.tick((now += 5000));
|
||||
assert.equal(t.hand, null);
|
||||
t.setSittingOut(T(0), false);
|
||||
|
||||
// チップがなくなった CPU は次のハンドでリバイ
|
||||
t.seats[2].stack = 0;
|
||||
for (let k = 0; k < 10 && !t.hand; k++) t.tick((now += 5000));
|
||||
assert.ok(t.hand);
|
||||
assert.equal(t.seats[2].buyIns, 2);
|
||||
assert.ok(t.hand.players.has(2));
|
||||
|
||||
// ホストが退席しても CPU はホストにならない
|
||||
t.leave(T(0));
|
||||
assert.equal(t.hostToken, null);
|
||||
t.join(T(5), 'friend');
|
||||
assert.equal(t.isHost(T(5)), true);
|
||||
});
|
||||
Reference in new issue
Block a user