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,48 @@
/** 오셀로 computer player: corners first, no X-squares next to an empty corner, no corner handed over. */
import { describe, expect, test } from 'bun:test';
import { SeededRng } from '@bg/engine';
import { decideBotAction } from '../bots/index';
import { othello, type OthelloAction, type OthelloState } from './index';
import type { Cell } from './rules';
const at = (l: string) => ({ x: 'abcdefgh'.indexOf(l[0]!), y: Number(l.slice(1)) - 1 });
/** A = black (the bot here). 8 rows of '.', 'B', 'W' (row 1 first). */
function fromRows(rows: string[]): OthelloState {
const options = othello.optionsSchema.parse({ colorAssignment: 'hostBlack' });
const s = othello.setup({ players: ['A', 'B'], options, rng: SeededRng.fromSeed([1, 2, 3, 4]), now: 0 });
s.board = rows.join('').split('').map((c) => (c === 'B' ? 1 : c === 'W' ? 2 : 0)) as Cell[];
s.turn = 'black';
return s;
}
const decide = (s: OthelloState, seed = 1) => {
const r = SeededRng.fromSeed([seed, 4, 4, 4]);
return decideBotAction(othello, s, 'A', { now: 1000, random: () => r.next() }) as OthelloAction;
};
const legalLabels = (s: OthelloState) => othello.view(s, 'A').legalMoves!.map((m) => 'abcdefgh'[m.x]! + (m.y + 1)).sort();
describe('othello bot', () => {
test('takes a corner when available', () => {
// a1 (corner, flips b2) and f4 (flips e4 + more) are both legal.
const s = fromRows(['........', '.W......', '..B.....', '...WWB..', '...BW...', '........', '........', '........']);
expect(legalLabels(s)).toContain('a1');
for (let seed = 1; seed <= 4; seed++) expect(decide(s, seed)).toEqual({ type: 'place', ...at('a1') });
});
test('avoids the X-square next to an empty corner even when it flips more', () => {
// b2 flips two discs (c3, d4); g5 flips one.
const s = fromRows(['........', '........', '..W.....', '...W....', '....BW..', '........', '........', '........']);
const legal = legalLabels(s);
expect(legal).toContain('b2');
for (let seed = 1; seed <= 4; seed++) expect(decide(s, seed)).not.toEqual({ type: 'place', ...at('b2') });
});
test("accepts the opponent's undo request", () => {
let s = othello.setup({ players: ['A', 'B'], options: othello.optionsSchema.parse({ colorAssignment: 'hostBlack' }), rng: SeededRng.fromSeed([1, 2, 3, 4]), now: 0 });
const step = (who: string, a: OthelloAction) => (s = othello.apply(s, who, a, { rng: SeededRng.fromSeed([1, 1, 1, 1]), now: 1000 }).state);
step('A', { type: 'place', ...at('d3') });
step('B', { type: 'place', ...at('c3') });
step('B', { type: 'requestUndo' });
expect(decide(s)).toEqual({ type: 'respondUndo', accept: true });
});
});

View File

@@ -0,0 +1,48 @@
/**
* 오셀로 computer player: corners first, avoid the squares next to an empty corner, don't hand the opponent a
* corner, keep the opponent's choices few; flip counts only matter near the end.
*/
import type { BotStrategy } from '../bots/types';
import { answerRequests, best, pick, playable, typeOf } from '../bots/policy';
import type { OthelloView } from './index';
import { CORNERS, legalMoves, type Cell, type Disc } from './rules';
type Place = { type: 'place'; x: number; y: number };
export const othelloBot: BotStrategy<OthelloView> = ({ view, legal, random }) => {
const req = answerRequests(legal);
if (req) return req;
const places = legal.filter((a) => typeOf(a) === 'place') as Place[];
if (!places.length || !view.myColor) return pick(playable(legal), random);
const n = view.size;
const last = n - 1;
const me: Disc = view.myColor === 'black' ? 1 : 2;
const opp: Disc = me === 1 ? 2 : 1;
const flipped = new Map((view.legalMoves ?? []).map((m) => [m.y * n + m.x, m.flipped]));
const endgame = view.discs.empty <= 12;
const cornerEmpty = (cx: number, cy: number) => view.board[cy * n + cx] === 0;
const score = ({ x, y }: Place): number => {
const i = y * n + x;
const flips = flipped.get(i) ?? [];
// Board after the move: what can the opponent answer?
const after = view.board.slice() as Cell[];
after[i] = me;
for (const f of flips) after[f] = me;
const replies = legalMoves(after, opp);
let v = (endgame ? flips.length * 3 : flips.length * 0.3) - replies.length * 1.5 + random() * 0.5;
if (replies.some((m) => CORNERS.includes(m.i))) v -= 40;
if (replies.length === 0 && view.discs.empty > 1) v += 15; // opponent must pass
const edgeX = x === 0 || x === last;
const edgeY = y === 0 || y === last;
if (edgeX && edgeY) return v + 100;
// Nearest corner: X-square (diagonal) and C-squares (beside it) give it away while it is empty.
const cx = x < n / 2 ? 0 : last;
const cy = y < n / 2 ? 0 : last;
const dx = Math.abs(x - cx);
const dy = Math.abs(y - cy);
if (cornerEmpty(cx, cy) && dx <= 1 && dy <= 1) v -= dx === 1 && dy === 1 ? 50 : 20;
else if (edgeX || edgeY) v += 10;
return v;
};
return best(places, score, random);
};