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,46 @@
/** 벽 쌓기 길찾기 computer player: shortest path, winning step, walls a rival who is about to win. */
import { describe, expect, test } from 'bun:test';
import { SeededRng } from '@bg/engine';
import { decideBotAction } from '../bots/index';
import { quoridor, type Cell, type QuoridorAction, type QuoridorState, type Seat } from './index';
import { emptyWallIndex, pathLength, setWall, type Wall } from './board';
/** 'e5' → { c: 4, r: 4 }. */
const at = (l: string): Cell => ({ c: 'abcdefghi'.indexOf(l[0]!), r: Number(l.slice(1)) - 1 });
/** 'h:d4' → horizontal wall at anchor d4. */
const wall = (w: string): Wall => ({ o: w[0] as 'h' | 'v', ...at(w.slice(2)) });
/** 2 players: A = 남 (the bot here, to move, goal row 9), B = 북 (goal row 1). */
function position(pos: Partial<Record<Seat, string>>, walls: string[] = []): QuoridorState {
const options = quoridor.optionsSchema.parse({ seatOrder: 'joinOrder' });
const s = quoridor.setup({ players: ['A', 'B'], options, rng: SeededRng.fromSeed([1, 2, 3, 4]), now: 0 });
for (const x of s.seats) if (pos[x.seat]) x.pos = at(pos[x.seat]!);
s.walls = walls.map(wall);
s.wallIndex = emptyWallIndex();
for (const w of s.walls) setWall(s.wallIndex, w, true);
s.turnIndex = s.seats.findIndex((x) => x.seat === 'S');
return s;
}
const decide = (s: QuoridorState, random = () => 0.5) => decideBotAction(quoridor, s, 'A', { now: 1000, random }) as QuoridorAction;
describe('quoridor bot', () => {
test('walks the shortest way around a wall', () => {
// The wall above e2/f2 blocks the straight way; d2 → d3 is shortest (f2 is blocked above as well).
const s = position({ S: 'e2', N: 'e9' }, ['h:e2']);
expect(decide(s)).toEqual({ type: 'move', to: at('d2') });
});
test('steps onto the goal row even when the rival is one step from winning', () => {
const s = position({ S: 'e8', N: 'a2' });
expect(decide(s)).toEqual({ type: 'move', to: at('e9') });
});
test('walls off a rival who is about to win', () => {
const s = position({ S: 'e5', N: 'e2' });
const a = decide(s);
expect(a.type).toBe('placeWall');
const after = quoridor.apply(s, 'A', a, { rng: SeededRng.fromSeed([1, 1, 1, 1]), now: 1000 }).state;
const north = after.seats.find((x) => x.seat === 'N')!;
expect(pathLength(after.wallIndex, north.pos!, 'N')).toBeGreaterThan(1);
});
});