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,58 @@
/** 체스 computer player: mates, wins material without hanging it, promotes to a queen. */
import { describe, expect, test } from 'bun:test';
import { SeededRng } from '@bg/engine';
import { decideBotAction } from '../bots/index';
import { chess, type ChessAction, type ChessState } from './index';
import { fullFen, keyOf, load } from './rules';
/** White = 'A' (the bot in these tests), black = 'B'; both clocks running. */
function make(fen: string): ChessState {
const options = chess.optionsSchema.parse({ colorAssignment: 'hostWhite' });
const s = chess.setup({ players: ['A', 'B'], options, rng: SeededRng.fromSeed([1, 2, 3, 4]), now: 0 });
s.startFen = fullFen(load(fen));
s.fen = s.startFen;
s.repetition = { [keyOf(load(fen))]: 1 };
s.clock.started = { white: true, black: true };
return s;
}
const decide = (s: ChessState, seed = 1) => {
const r = SeededRng.fromSeed([seed, 7, 7, 7]);
return decideBotAction(chess, s, 'A', { now: 1000, random: () => r.next() }) as ChessAction;
};
const uci = (a: ChessAction) => (a.type === 'move' ? `${a.from}${a.to}${a.promotion ?? ''}` : a.type);
describe('chess bot', () => {
test('plays mate in one', () => {
for (let seed = 1; seed <= 5; seed++) expect(uci(decide(make('6k1/5ppp/8/8/8/8/8/R5K1 w - - 0 1'), seed))).toBe('a1a8');
});
test('takes a hanging queen', () => {
expect(uci(decide(make('4k3/8/8/3q4/8/8/8/3RK3 w - - 0 1')))).toBe('d1d5');
});
test('does not grab a defended pawn with the queen, and moves an attacked queen away', () => {
// Qxd5 would lose the queen to cxd5.
for (let seed = 1; seed <= 5; seed++) expect(uci(decide(make('4k3/8/2p5/3p4/8/8/8/3QK3 w - - 0 1'), seed))).not.toBe('d1d5');
// The queen on d5 is attacked by the c6 pawn: it must leave, to a square nothing black attacks.
for (let seed = 1; seed <= 5; seed++) {
const fen = '4k3/8/2p5/3Q4/8/8/8/4K3 w - - 0 1';
const a = decide(make(fen), seed) as Extract<ChessAction, { type: 'move' }>;
expect(a.from).toBe('d5');
const c = load(fen);
c.move({ from: a.from, to: a.to });
expect(c.isAttacked(a.to as 'a1', 'b')).toBe(false);
}
});
test('promotes to a queen', () => {
expect(uci(decide(make('8/P7/8/8/8/8/k7/4K3 w - - 0 1')))).toBe('a7a8q');
});
test("accepts the opponent's undo request", () => {
let s = make('4k3/8/8/8/8/8/4P3/4K3 w - - 0 1');
s = chess.apply(s, 'A', { type: 'move', from: 'e2', to: 'e4' }, { rng: SeededRng.fromSeed([1, 1, 1, 1]), now: 500 }).state;
s = chess.apply(s, 'B', { type: 'move', from: 'e8', to: 'd7' }, { rng: SeededRng.fromSeed([1, 1, 1, 1]), now: 600 }).state;
s = chess.apply(s, 'B', { type: 'requestUndo' }, { rng: SeededRng.fromSeed([1, 1, 1, 1]), now: 700 }).state;
expect(decide(s)).toEqual({ type: 'respondUndo', accept: true });
});
});