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:
66
packages/games/src/omok/bot.test.ts
Normal file
66
packages/games/src/omok/bot.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/** 오목 computer player: wins, blocks fours and open threes, knows overlines, never plays a forbidden point. */
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { SeededRng } from '@bg/engine';
|
||||
import { decideBotAction } from '../bots/index';
|
||||
import { omok, type OmokAction, type OmokOptions, type OmokState } from './index';
|
||||
import { Board, forbiddenCells } from './rules';
|
||||
|
||||
const COLS = 'ABCDEFGHIJKLMNO';
|
||||
const at = (label: string) => ({ x: COLS.indexOf(label[0]!), y: 15 - Number(label.slice(1)) });
|
||||
|
||||
/** 15×15, A = black, B = white; `turn` is the bot to decide. Forbidden cells refreshed like the game does. */
|
||||
function make(stones: { black?: string[]; white?: string[] }, turn: 'black' | 'white', opts: Partial<OmokOptions> = {}): OmokState {
|
||||
const options = omok.optionsSchema.parse({ colorAssignment: 'hostBlack', ...opts });
|
||||
const s = omok.setup({ players: ['A', 'B'], options, rng: SeededRng.fromSeed([1, 2, 3, 4]), now: 0 });
|
||||
for (const [color, v] of [['black', 1], ['white', 2]] as const) {
|
||||
for (const l of stones[color] ?? []) {
|
||||
const p = at(l);
|
||||
s.board[p.y * 15 + p.x] = v;
|
||||
s.moves.push({ color, pt: p, at: 0, auto: false });
|
||||
}
|
||||
}
|
||||
s.turn = turn;
|
||||
s.forbidden = forbiddenCells(new Board(15, s.board), turn === 'black' ? 1 : 2, { ruleSet: options.ruleSet, freeWin: options.freeWin });
|
||||
return s;
|
||||
}
|
||||
const decide = (s: OmokState, seed = 1) => {
|
||||
const r = SeededRng.fromSeed([seed, 9, 9, 9]);
|
||||
return decideBotAction(omok, s, s.players[s.turn], { now: 1000, random: () => r.next() }) as OmokAction;
|
||||
};
|
||||
const place = (l: string): OmokAction => ({ type: 'place', ...at(l) });
|
||||
|
||||
describe('omok bot', () => {
|
||||
test('takes a winning five over blocking', () => {
|
||||
const s = make({ black: ['D8', 'E8', 'F8', 'G8', 'C3'], white: ['H8', 'D10', 'E10', 'F10', 'G10'] }, 'black');
|
||||
expect(decide(s)).toEqual(place('C8'));
|
||||
});
|
||||
|
||||
test('blocks a four', () => {
|
||||
const s = make({ black: ['D8', 'E8', 'F8', 'G8'], white: ['C8', 'J3', 'K4'] }, 'white');
|
||||
expect(decide(s)).toEqual(place('H8'));
|
||||
});
|
||||
|
||||
test('blocks an open three at one of its ends', () => {
|
||||
for (let seed = 1; seed <= 4; seed++) {
|
||||
const s = make({ black: ['F8', 'G8', 'H8', 'C3'], white: ['L12', 'M11'] }, 'white');
|
||||
expect([place('E8'), place('I8')]).toContainEqual(decide(s, seed));
|
||||
}
|
||||
});
|
||||
|
||||
test('korean rule: an overline is no win, so it completes the exact five', () => {
|
||||
// C8 makes exactly five (C–G); H8 would join six (D–I) which does not win under the korean rule.
|
||||
const s = make({ black: ['D8', 'E8', 'F8', 'G8', 'I8'], white: ['A1', 'B1', 'O15', 'N15', 'O14'] }, 'black');
|
||||
for (let seed = 1; seed <= 4; seed++) expect(decide(s, seed)).toEqual(place('C8'));
|
||||
});
|
||||
|
||||
test('renju: black never plays its forbidden double-three point', () => {
|
||||
// H8 would make two open threes for black (G8-I8 row + H7-H9 column) — the strongest-looking point, but forbidden.
|
||||
const s = make({ black: ['G8', 'I8', 'H7', 'H9'], white: ['A1', 'A3', 'A5', 'O15'] }, 'black', { ruleSet: 'renju' });
|
||||
expect(s.forbidden).toContain(at('H8').y * 15 + at('H8').x);
|
||||
for (let seed = 1; seed <= 4; seed++) {
|
||||
const a = decide(s, seed);
|
||||
expect(a).not.toEqual(place('H8'));
|
||||
expect(omok.validate(s, 'A', a).ok).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
74
packages/games/src/omok/bot.ts
Normal file
74
packages/games/src/omok/bot.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/** 오목 computer player: win > block a five > open four > block open three > build lines near the stones. */
|
||||
import type { BotStrategy } from '../bots/types';
|
||||
import { answerRequests, best, pick, playable, typeOf } from '../bots/policy';
|
||||
import type { OmokView } from './index';
|
||||
|
||||
type Place = { type: 'place'; x: number; y: number };
|
||||
const DIRS = [
|
||||
[1, 0],
|
||||
[0, 1],
|
||||
[1, 1],
|
||||
[1, -1],
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Value of the lines a stone of `color` at (x, y) would make. `overlineWins`: does a run of 6+ win for that
|
||||
* colour (korean / exact-five: no, it is neither a win nor a threat; renju white / free: yes).
|
||||
*/
|
||||
function lineValue(board: number[], size: number, x: number, y: number, color: number, overlineWins: boolean): number {
|
||||
let total = 0;
|
||||
for (const [dx, dy] of DIRS) {
|
||||
let count = 1;
|
||||
let open = 0;
|
||||
for (const sign of [1, -1]) {
|
||||
let cx = x + dx * sign;
|
||||
let cy = y + dy * sign;
|
||||
while (cx >= 0 && cy >= 0 && cx < size && cy < size && board[cy * size + cx] === color) {
|
||||
count++;
|
||||
cx += dx * sign;
|
||||
cy += dy * sign;
|
||||
}
|
||||
if (cx >= 0 && cy >= 0 && cx < size && cy < size && board[cy * size + cx] === 0) open++;
|
||||
}
|
||||
if (count === 5 || (count > 5 && overlineWins)) total += 100_000;
|
||||
else if (count > 5) continue;
|
||||
else if (count === 4) total += open === 2 ? 10_000 : open === 1 ? 1_000 : 0;
|
||||
else if (count === 3) total += open === 2 ? 1_000 : open === 1 ? 100 : 0;
|
||||
else if (count === 2) total += open === 2 ? 100 : open === 1 ? 10 : 0;
|
||||
else total += open;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
export const omokBot: BotStrategy<OmokView> = ({ 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 { size, board } = view;
|
||||
const me = view.myColor === 'black' ? 1 : 2;
|
||||
const opp = 3 - me;
|
||||
const near = (p: Place) => {
|
||||
for (let dy = -2; dy <= 2; dy++)
|
||||
for (let dx = -2; dx <= 2; dx++) {
|
||||
const x = p.x + dx;
|
||||
const y = p.y + dy;
|
||||
if (x >= 0 && y >= 0 && x < size && y < size && board[y * size + x] !== 0) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const close = places.filter(near);
|
||||
// 6+ in a row wins only for renju white and in free play (the view doesn't say whether free play is
|
||||
// "exact five"; assume the default "five or more").
|
||||
const overline = (c: number) => view.ruleSet === 'free' || (view.ruleSet === 'renju' && c === 2);
|
||||
const center = (size - 1) / 2;
|
||||
return best(
|
||||
close.length ? close : places,
|
||||
(p) =>
|
||||
lineValue(board, size, p.x, p.y, me, overline(me)) * 1.1 +
|
||||
lineValue(board, size, p.x, p.y, opp, overline(opp)) -
|
||||
(Math.abs(p.x - center) + Math.abs(p.y - center)) * 0.1 +
|
||||
random() * 0.5,
|
||||
random,
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user