- 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>
101 lines
4.5 KiB
TypeScript
101 lines
4.5 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|