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,100 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, seedN, type PlayerId } from '@bg/engine';
import { decideBotAction } from '../bots/index';
import { botPlayout } from '../bots/playout';
import { gostop, type FloorGroup, type GostopAction, type GostopOptions, type GostopState } from './index';
// Card ids: month m → (m-1)*4 + slot. 1월 0광 1홍단 2·3피, 2월 4열 5홍단 6·7피, 3월 8광 9홍단 10·11피,
// 5월 16열 17초단 18·19피, 6월 20열 21청단 22·23피, 7월 24열 25초단 26·27피, 8월 28광 29열 30·31피, 11월 40광 41쌍피 42·43피.
/** A play-phase state for players A (bot under test), B, C… with exactly these cards. */
function rigged(n: number, spec: { hands: number[][]; floor: number[]; deck: number[]; captured?: number[][] }, opts: Partial<GostopOptions> = {}): GostopState {
const players = ['A', 'B', 'C', 'D', 'E'].slice(0, n);
const s = gostop.setup({ players, options: gostop.optionsSchema.parse({ chongtong: 'off', ...opts }), rng: SeededRng.fromSeed(seedN(1)), now: 0 });
s.dealer = 0;
const r = s.round!;
const zeros = () => Array.from({ length: n }, () => 0);
const lists = () => Array.from({ length: n }, (): number[] => []);
Object.assign(r, {
participants: Array.from({ length: Math.min(n, 3) }, (_, i) => i),
sellOrder: [],
sellCursor: 0,
sellDecisions: [],
sellers: [],
step: 'play',
turn: 0,
pending: null,
stopBy: null,
chongtongBy: null,
bombDummies: zeros(),
revealed: lists(),
shakenMonths: lists(),
goCount: zeros(),
lastGoScore: zeros(),
goOrder: [],
shakes: zeros(),
bombs: zeros(),
ppeokCount: zeros(),
ppeokTurns: lists(),
turnsTaken: zeros(),
instant: zeros(),
});
r.hands = Array.from({ length: n }, (_, i) => [...(spec.hands[i] ?? [])]);
r.deck = [...spec.deck];
r.captured = Array.from({ length: n }, (_, i) => [...(spec.captured?.[i] ?? [])]);
const groups: FloorGroup[] = [];
for (const c of spec.floor) {
const m = Math.floor(c / 4) + 1;
const g = groups.find((x) => x.month === m);
if (g) g.cards.push(c);
else groups.push({ month: m, cards: [c], ppeokBy: null, natural: false });
}
r.floor = groups.sort((a, b) => a.month - b.month);
return s;
}
const decide = (s: GostopState) => decideBotAction(gostop, s, 'A', { now: 0, random: () => 0.5 }) as GostopAction;
describe('gostop bot', () => {
test('plays the card that captures a 광 rather than a plain 피', () => {
const s = rigged(2, { hands: [[9, 7, 42], [43]], floor: [8, 6, 20], deck: [24, 25, 26, 27, 16, 17] });
expect(decide(s)).toEqual({ type: 'play', card: 9 });
});
test('picks the 광 when two different floor cards of the month can be taken', () => {
const s = rigged(2, { hands: [[3], [43]], floor: [0, 2], deck: [24, 25, 26, 27] });
expect(decide(s)).toEqual({ type: 'play', card: 3, target: 0 });
});
test('with no match it throws away its cheapest card, not a 광', () => {
const s = rigged(2, { hands: [[8, 42, 16], [43]], floor: [20, 24], deck: [26, 27, 30, 31] });
expect(decide(s)).toEqual({ type: 'play', card: 42 });
});
test('go while the opponents have nothing, stop when one of them is close', () => {
const deck = [16, 17, 18, 19, 20, 21, 22, 23];
const hands = [[24, 25, 26, 27], [30, 31, 42, 43], [40, 41, 36, 37]];
const s = rigged(3, { hands, floor: [], deck, captured: [[0, 8, 28], [], []] });
s.round!.step = 'goStop';
expect(decide(s)).toEqual({ type: 'goStop', decision: 'go' });
const t = rigged(3, { hands, floor: [], deck, captured: [[0, 8, 28], [1, 5, 9], []] });
t.round!.step = 'goStop';
expect(decide(t)).toEqual({ type: 'goStop', decision: 'stop' });
});
test('bot-only games finish in every mode (맞고, 3인, 광팔기 4~5인, 국진 직접 선택, 총통, 보너스 3+1)', () => {
const isBot = (p: PlayerId) => p.startsWith('bot:');
const cases: [number, Partial<GostopOptions>][] = [
[2, { rounds: 5 }],
[3, { rounds: 5, gukjinMode: 'ask', bonusCards: '3+1' }],
[4, { rounds: 5, chongtong: 'win' }],
[5, { rounds: 5, gwangSellCards: 'gwangPlus', gukjinMode: 'ask' }],
];
for (const [i, [n, o]] of cases.entries()) {
const players = Array.from({ length: n }, (_, k) => `bot:${k}:x`);
const r = botPlayout(gostop, { players, options: gostop.optionsSchema.parse(o), seed: seedN(40 + i), isBot, maxSteps: 30_000 });
expect(r.finished).toBe(true);
expect(r.botTimeouts).toBe(0);
}
});
});