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:
EJClaw
2026-10-06 13:32:28 +09:00
parent c27cb0a11a
commit a3f81ded37
67 changed files with 5113 additions and 18 deletions

View 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);
}
});
});

View 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);
};