M9: 컴퓨터 상대 — 게임 22종 컴퓨터 전략, 서버 구동(BotDriver), 연습 방
- packages/shared/src/bots.ts: 컴퓨터 id(bot:<n>:<ulid>)·프로필(컴퓨터 n)·BOT_GAMES
(도블·할리갈리·그림 맞히기·마피아 제외)
- packages/games/src/bots: 전략 계약(자기 view + 합법 수만), decideBotAction(validate 재검사),
게임별 <게임>/bot.ts 22개 + 행동 테스트, 전 게임 판 끝까지 돌리는 공통 테스트
- 서버: Room.addBot/removeBot(방장·대기실만, 방은 친구만), RoomManager.createPractice
(POST /api/rooms {practice:true}), BotDriver(사람 같은 지연, 사람과 같은 창이면 더 천천히,
재시작 후 다시 예약), 연습 게임은 전적 제외(result_json.practice), 사람이 모두 나가면 무효
- 프로토콜 addBot/removeBot, 문서 03·06 §12·08·09 §8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
70
packages/games/src/seotda/bot.test.ts
Normal file
70
packages/games/src/seotda/bot.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { SEOTDA_DECK, SeededRng, seedN } from '@bg/engine';
|
||||
import { decideBotAction } from '../bots/index';
|
||||
import { botPlayout } from '../bots/playout';
|
||||
import { seotda, type SeotdaAction, type SeotdaOptions, type SeotdaState } from './index';
|
||||
|
||||
/** Special card of a month (1·3·8 광, others 열끗) and the plain card. */
|
||||
const S = (m: number) => (m - 1) * 4;
|
||||
const P = (m: number) => (m - 1) * 4 + 1;
|
||||
let clock = 1000;
|
||||
|
||||
function make(opts: Partial<SeotdaOptions> = {}, players = ['A', 'B', 'C'], dealer = 'A'): SeotdaState {
|
||||
const options = seotda.optionsSchema.parse(opts);
|
||||
for (let seed = 1; seed < 500; seed++) {
|
||||
const s = seotda.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: 0 });
|
||||
if (s.hand!.dealer === dealer) return s;
|
||||
}
|
||||
throw new Error('no seed with that dealer');
|
||||
}
|
||||
function rig(s: SeotdaState, cards: Record<string, number[]>, next: number[] = []): SeotdaState {
|
||||
const h = s.hand!;
|
||||
for (const [id, cs] of Object.entries(cards)) h.cards[id] = [...cs];
|
||||
const used = new Set([...Object.values(h.cards).flat(), ...next]);
|
||||
h.deck = [...next, ...SEOTDA_DECK.filter((c) => !used.has(c))];
|
||||
return s;
|
||||
}
|
||||
const act = (s: SeotdaState, p: string, a: SeotdaAction) => seotda.apply(s, p, a, { rng: SeededRng.fromSeed([7, 7, 7, 7]), now: (clock += 1000) }).state;
|
||||
const decide = (s: SeotdaState, p: string, r = 0.1) => decideBotAction(seotda, s, p, { now: clock, random: () => r }) as SeotdaAction;
|
||||
|
||||
describe('seotda bot', () => {
|
||||
test('raises a 38광땡', () => {
|
||||
const s = rig(make({ dealMode: 'oneRound' }), { A: [S(3), S(8)], B: [P(5), P(6)], C: [P(2), P(7)] });
|
||||
expect(['half', 'quarter', 'full']).toContain(decide(s, 'A').type);
|
||||
});
|
||||
|
||||
test('dies with 망통 facing a half bet, calls it with 장땡', () => {
|
||||
let s = rig(make({ dealMode: 'oneRound' }), { A: [S(3), S(8)], B: [P(2), P(8)], C: [S(10), P(10)] });
|
||||
s = act(s, 'A', { type: 'half' });
|
||||
expect(decide(s, 'B', 0.9).type).toBe('die');
|
||||
s = act(s, 'B', { type: 'die' });
|
||||
expect(['call', 'ddadang', 'quarter', 'half']).toContain(decide(s, 'C', 0.9).type);
|
||||
});
|
||||
|
||||
test('checks a weak hand when betting is free (never dies for nothing)', () => {
|
||||
const s = rig(make({ dealMode: 'oneRound' }), { A: [P(2), P(3)], B: [P(5), P(6)], C: [P(2), P(7)] });
|
||||
expect(decide(s, 'A', 0.9).type).toBe('check');
|
||||
});
|
||||
|
||||
test('3장 섯다: keeps the best pair of its three cards', () => {
|
||||
let s = make({ cardMode: 'three' });
|
||||
s = rig(s, { A: [P(4), P(9)], B: [P(5), P(7)], C: [P(2), P(3)] }, [P(6), S(2), S(5)]);
|
||||
for (const p of ['A', 'B', 'C']) s = act(s, p, { type: 'check' });
|
||||
expect(s.hand!.phase).toBe('choose');
|
||||
const a = decide(s, 'A') as Extract<SeotdaAction, { type: 'choose' }>;
|
||||
expect([...a.cardIds].sort((x, y) => x - y)).toEqual([P(4), P(6)]); // 세륙 over 구사·5끗
|
||||
});
|
||||
|
||||
test.each([
|
||||
['2장 섯다', {}],
|
||||
['3장 섯다', { cardMode: 'three' }],
|
||||
] as const)('%s: 1 human + 3 bots finish without bot timeouts', (_, options) => {
|
||||
const opts = seotda.optionsSchema.parse(options);
|
||||
for (let s = 1; s <= 2; s++) {
|
||||
const r = botPlayout(seotda, { players: ['human', 'bot:1', 'bot:2', 'bot:3'], options: opts, seed: seedN(s), isBot: (p) => p.startsWith('bot:') });
|
||||
expect(r.finished).toBe(true);
|
||||
expect(r.botTimeouts).toBe(0);
|
||||
expect(r.maxBotMs).toBeLessThan(100);
|
||||
}
|
||||
});
|
||||
});
|
||||
112
packages/games/src/seotda/bot.ts
Normal file
112
packages/games/src/seotda/bot.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 섯다 computer player: a cautious beginner that bets by the strength of its own cards.
|
||||
* Strong 족보 raise, middling ones call cheap bets, weak ones check or die; an occasional small bluff.
|
||||
* Uses only its own view (own cards, pot, legal bet amounts) and the pure 족보 helpers.
|
||||
*/
|
||||
import type { BotStrategy } from '../bots/types';
|
||||
import { pick, typeOf } from '../bots/policy';
|
||||
import { evalSeotda, isGwang, monthOf, type HandRules } from './hands';
|
||||
import type { SeotdaAction, SeotdaView } from './index';
|
||||
|
||||
type Bet = SeotdaView['legal'][number];
|
||||
|
||||
const rulesOf = (o: SeotdaView['options']): HandRules => ({
|
||||
gwangTtaeng: o.gwangTtaeng,
|
||||
ttaengjabi: o.ttaengjabi,
|
||||
amhaeng: o.amhaeng,
|
||||
gusa: o.gusa,
|
||||
mungGusa: o.gusa ? o.mungGusa : 'off',
|
||||
});
|
||||
|
||||
/** Rough winning chance (0..1) of a 2-card hand; specials count for what they can do. */
|
||||
export function pairStrength(a: number, b: number, rules: HandRules): number {
|
||||
const e = evalSeotda(a, b, rules);
|
||||
let s: number;
|
||||
if (e.value >= 980) s = 0.96 + (e.value - 980) / 500; // 광땡
|
||||
else if (e.value >= 901) s = 0.8 + (e.value - 900) * 0.015; // 땡
|
||||
else if (e.value >= 801) s = 0.6 + (e.value - 801) * 0.025; // 알리 … 세륙
|
||||
else s = 0.12 + e.value * 0.045; // 망통 0.12 … 갑오 0.53
|
||||
if (e.special === 'ttaengjabi' || e.special === 'gusa') s = Math.max(s, 0.4);
|
||||
if (e.special === 'mungGusa') s = Math.max(s, 0.5);
|
||||
if (e.special === 'amhaeng') s = Math.max(s, 0.3);
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Strength from the cards I hold right now (1 card in 2장 섯다's first round, 2 of 3 before choosing). */
|
||||
function strength(cards: number[], rules: HandRules, threeMode: boolean): number {
|
||||
if (cards.length === 1) {
|
||||
const c = cards[0]!;
|
||||
const m = monthOf(c);
|
||||
// 광 and 10월 lead to the big hands; 1·4 to 알리/독사/세륙 and friends.
|
||||
return isGwang(c) || m === 10 ? 0.5 : m === 1 || m === 4 ? 0.45 : 0.35;
|
||||
}
|
||||
let s = 0;
|
||||
for (let i = 0; i < cards.length; i++) for (let j = i + 1; j < cards.length; j++) s = Math.max(s, pairStrength(cards[i]!, cards[j]!, rules));
|
||||
// 3장 섯다 first round: a third card is still coming.
|
||||
return threeMode && cards.length === 2 ? Math.min(1, s + 0.06) : s;
|
||||
}
|
||||
|
||||
export const seotdaBot: BotStrategy<SeotdaView, SeotdaAction> = ({ me, view, legal, random }) => {
|
||||
const rules = rulesOf(view.options);
|
||||
const mine = view.seats.find((x) => x.id === me);
|
||||
const cards = (mine?.cards ?? []).flatMap((c) => ('id' in c ? [c.id] : []));
|
||||
const first = legal[0]!;
|
||||
|
||||
switch (typeOf(first)) {
|
||||
case 'ready':
|
||||
return first;
|
||||
case 'rebuy':
|
||||
// Keep playing: take the chips while rebuys remain.
|
||||
return { type: 'rebuy', accept: true };
|
||||
case 'rejoin': {
|
||||
// Pay into a 구사 rematch only when it is cheap compared to the pot.
|
||||
const cheap = !!view.rejoin && view.rejoin.cost <= Math.min(view.pot / 3, (mine?.stack ?? 0) / 5);
|
||||
return legal.find((a) => a.type === 'rejoin' && a.accept === cheap) ?? first;
|
||||
}
|
||||
case 'choose': {
|
||||
// 3장 섯다: keep the pair with the best chance (specials such as 땡잡이 included).
|
||||
const chooses = legal.filter((a): a is Extract<SeotdaAction, { type: 'choose' }> => a.type === 'choose');
|
||||
let top = chooses[0]!;
|
||||
for (const c of chooses) if (pairStrength(c.cardIds[0], c.cardIds[1], rules) > pairStrength(top.cardIds[0], top.cardIds[1], rules)) top = c;
|
||||
return top;
|
||||
}
|
||||
}
|
||||
|
||||
// Betting.
|
||||
const s = strength(cards, rules, view.options.cardMode === 'three');
|
||||
const bets = view.legal;
|
||||
const by = (t: string) => bets.find((b) => b.type === t);
|
||||
const act = (b: Bet | undefined) => (b ? (legal.find((a) => a.type === b.type) ?? null) : null);
|
||||
const stack = mine?.stack ?? 0;
|
||||
const call = by('call');
|
||||
const toCall = call?.amount ?? 0;
|
||||
const pot = Math.max(view.pot, 1);
|
||||
// Raises that keep chips behind unless the hand is a monster.
|
||||
const raises = bets.filter((b) => ['ping', 'ddadang', 'quarter', 'half', 'full'].includes(b.type) && (!b.allIn || s >= 0.85));
|
||||
const raise = (big: boolean) => {
|
||||
const order = big ? ['half', 'full', 'ddadang', 'quarter', 'ping'] : ['ping', 'quarter', 'ddadang', 'half'];
|
||||
for (const t of order) {
|
||||
const b = raises.find((r) => r.type === t);
|
||||
if (b) return act(b);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (!call) {
|
||||
// Free to check (or open the betting).
|
||||
if (s >= 0.75) return raise(true) ?? act(by('check'))!;
|
||||
if ((s >= 0.5 && random() < 0.5) || random() < 0.08) return raise(false) ?? act(by('check'))!;
|
||||
return act(by('check')) ?? act(by('die'))!;
|
||||
}
|
||||
// Facing a bet: raise monsters, call when the hand is worth the price, otherwise die.
|
||||
if (s >= 0.8 && random() < 0.7) {
|
||||
const r = raise(true);
|
||||
if (r) return r;
|
||||
}
|
||||
const price = toCall / (pot + toCall);
|
||||
const allInCall = call.allIn || toCall >= stack;
|
||||
const worth = allInCall ? s >= 0.6 : s >= Math.max(0.3, price + 0.12);
|
||||
const cheapBluff = toCall <= view.baseUnit && random() < 0.3;
|
||||
if (worth || cheapBluff) return act(call)!;
|
||||
return act(by('die')) ?? pick(legal, random);
|
||||
};
|
||||
Reference in New Issue
Block a user