Skip to content

Commit 6debafd

Browse files
committed
Expand Omokmaru AI to nine stronger levels
1 parent f3348ef commit 6debafd

6 files changed

Lines changed: 93 additions & 28 deletions

File tree

‎src/domains/games/renju/ai.ts‎

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,23 @@
11
import { analyzeMove, index, inside, SIZE, type Board, type Color, type Point } from './rules';
22

3-
export type Difficulty = 'easy' | 'normal' | 'hard' | 'master';
3+
export type Difficulty = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
4+
5+
const LEVELS: Record<Difficulty, { picks: number; depth: number; beam: number; time: number }> = {
6+
1: { picks: 8, depth: 0, beam: 0, time: 0 },
7+
2: { picks: 5, depth: 0, beam: 0, time: 0 },
8+
3: { picks: 3, depth: 0, beam: 0, time: 0 },
9+
4: { picks: 1, depth: 0, beam: 0, time: 0 },
10+
5: { picks: 1, depth: 2, beam: 4, time: 300 },
11+
6: { picks: 1, depth: 3, beam: 5, time: 850 },
12+
7: { picks: 1, depth: 5, beam: 7, time: 2800 },
13+
8: { picks: 1, depth: 7, beam: 8, time: 5000 },
14+
9: { picks: 1, depth: 9, beam: 10, time: 8500 },
15+
};
16+
17+
export function normalizeDifficulty(value: unknown): Difficulty {
18+
const number = Number(value);
19+
return Number.isInteger(number) && number >= 1 && number <= 9 ? (number as Difficulty) : 5;
20+
}
421
const directions = [
522
[1, 0],
623
[0, 1],
@@ -72,7 +89,7 @@ function lineScore(board: Board, point: Point, color: Color): number {
7289
if (board[index(x, y)] === color) mine++;
7390
else blank++;
7491
}
75-
if (!blocked && blank && mine >= 3) score += mine === 4 ? 1400 : 90;
92+
if (!blocked && blank && mine >= 3) score += mine === 4 ? 5000 : 500;
7693
}
7794
}
7895
board[index(point.x, point.y)] = 0;
@@ -94,16 +111,25 @@ function ranked(board: Board, color: Color, search?: Search): Candidate[] {
94111
const cached = search?.positions.get(key);
95112
if (cached) return cached;
96113
const result: Candidate[] = [];
97-
for (const point of candidates(board)) {
98-
const verdict = analyzeMove(board, point.x, point.y, color);
114+
const legality = new Map<string, ReturnType<typeof analyzeMove>>();
115+
const scored = candidates(board)
116+
.map((point) => {
117+
const offense = lineScore(board, point, color);
118+
const defense = lineScore(board, point, other(color));
119+
const center = (7 - Math.abs(7 - point.x) + 7 - Math.abs(7 - point.y)) * 2;
120+
return { point, offense, defense, center, score: offense + defense * 0.82 + center };
121+
})
122+
.sort((a, b) => b.score - a.score);
123+
// Deep search spends its time on promising local moves while immediate wins
124+
// and blocks stay at the front of the list through their five-in-a-row score.
125+
const shortlist = search ? scored.slice(0, Math.max(18, search.beam * 2)) : scored;
126+
for (const { point, offense, defense, center } of shortlist) {
127+
const verdict = analyzeMove(board, point.x, point.y, color, legality);
99128
if (!verdict.legal) continue;
100-
const offense = lineScore(board, point, color);
101-
const defense = analyzeMove(board, point.x, point.y, other(color)).legal
102-
? lineScore(board, point, other(color))
103-
: 0;
129+
const legalDefense = analyzeMove(board, point.x, point.y, other(color), legality).legal ? defense : 0;
104130
result.push({
105131
point,
106-
score: offense + defense * 0.82 + (7 - Math.abs(7 - point.x) + 7 - Math.abs(7 - point.y)) * 2,
132+
score: offense + legalDefense * 0.82 + center,
107133
offense,
108134
win: verdict.win,
109135
});
@@ -179,26 +205,27 @@ function searchPosition(
179205
}
180206

181207
export function chooseAiMove(board: Board, color: Color, difficulty: Difficulty): Point | null {
208+
const level = LEVELS[normalizeDifficulty(difficulty)];
182209
const choices = ranked(board, color);
183210
if (!choices.length) return null;
184211
if (choices.length === 1) return choices[0].point;
185212
const winning = choices.find((choice) => choice.win);
186213
if (winning) return winning.point;
187-
if (difficulty === 'easy') return choices[Math.floor(Math.random() * Math.min(6, choices.length))].point;
214+
if (level.picks > 1) return choices[Math.floor(Math.random() * Math.min(level.picks, choices.length))].point;
188215
const threats = ranked(board, other(color)).filter((choice) => choice.win);
189216
const threatCells = new Set(threats.map((move) => index(move.point.x, move.point.y)));
190217
const roots = threats.length ? choices.filter((move) => threatCells.has(index(move.point.x, move.point.y))) : choices;
191218
if (!roots.length) return choices[0].point;
192219
if (roots.length === 1) return roots[0].point;
193-
if (difficulty === 'normal') return roots[0].point;
220+
if (!level.depth) return roots[0].point;
194221
const search: Search = {
195-
deadline: Date.now() + (difficulty === 'master' ? 2800 : 850),
196-
beam: difficulty === 'master' ? 7 : 5,
222+
deadline: Date.now() + level.time,
223+
beam: level.beam,
197224
positions: new Map(),
198225
transpositions: new Map(),
199226
};
200227
let best = roots[0];
201-
const maximumDepth = difficulty === 'master' ? 5 : 3;
228+
const maximumDepth = level.depth;
202229
for (let depth = 1; depth <= maximumDepth; depth++) {
203230
let iteration = best,
204231
bestScore = -Infinity,

‎src/domains/games/renju/solo.css‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@
1414
color: #625f57;
1515
font-size: 12px;
1616
}
17+
.solo-ai-hint {
18+
color: #77746c;
19+
font-size: 11px;
20+
line-height: 1.45;
21+
margin-top: -10px;
22+
}
1723
.solo-field select,
1824
.segmented button {
1925
color: #294052;

‎src/domains/games/renju/solo.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import { RenjuBoard, coordinate } from './board-3d';
22
import { analyzeMove, boardFromMoves, openingMove, SIZE, type Color, type Move, type Point } from './rules';
3-
import type { Difficulty } from './ai';
3+
import { normalizeDifficulty } from './ai';
44
import { mountRulesHelp } from './rules-help';
55
import { mountMoveConfirm } from './move-confirm';
66
import { mountGameSound } from './sound';
77
import { appearanceForSeat, DEFAULT_APPEARANCE, watchAppearance, type Appearance } from './appearance';
88

99
const $ = <T extends HTMLElement>(selector: string) => document.querySelector<T>(selector)!;
10+
const difficultySelect = $<HTMLSelectElement>('#difficulty');
11+
const savedDifficulty = localStorage.getItem('omokmaru:ai-level');
12+
if (savedDifficulty !== null) difficultySelect.value = String(normalizeDifficulty(savedDifficulty));
13+
difficultySelect.addEventListener('change', () => {
14+
localStorage.setItem('omokmaru:ai-level', String(normalizeDifficulty(difficultySelect.value)));
15+
});
1016
const board = new RenjuBoard($('#canvas'));
1117
let appearance: Appearance = { ...DEFAULT_APPEARANCE };
1218
function applySeatAppearance() {
@@ -151,7 +157,7 @@ function aiTurn() {
151157
worker.postMessage({
152158
board: boardFromMoves(moves),
153159
color: player === 1 ? 2 : 1,
154-
difficulty: $<HTMLSelectElement>('#difficulty').value as Difficulty,
160+
difficulty: normalizeDifficulty(difficultySelect.value),
155161
id,
156162
});
157163
}

‎src/pages/playroom/omokmaru/solo.astro‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ import SeoHead from '../../../shared/components/SeoHead.astro';
4747
<div class="solo-settings">
4848
<strong>AI 대국</strong>
4949
<div class="solo-field"><span>내 돌</span><div class="segmented"><button id="side-black" class="selected" type="button">● 흑</button><button id="side-white" type="button">○ 백</button></div></div>
50-
<label class="solo-field"><span>난이도</span><select id="difficulty"><option value="easy">쉬움</option><option value="normal" selected>보통</option><option value="hard">어려움</option><option value="master">마스터</option></select></label>
50+
<label class="solo-field"><span>난이도</span><select id="difficulty"><option value="1">1단계 · 입문</option><option value="2">2단계</option><option value="3">3단계</option><option value="4">4단계</option><option value="5" selected>5단계 · 보통</option><option value="6">6단계</option><option value="7">7단계</option><option value="8">8단계</option><option value="9">9단계 · 최상</option></select></label>
51+
<small class="solo-ai-hint">7~9단계는 깊게 수를 읽으며, 9단계는 최대 약 9초 생각합니다.</small>
5152
<button id="start" class="primary">대국 시작</button>
5253
<button id="resign-solo" class="secondary" hidden>기권</button>
5354
<button id="undo" class="secondary" hidden>↶ 한 수 되돌리기</button>

‎tests/e2e/layout.spec.ts‎

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -665,7 +665,7 @@ test('Playroom animations stay active under reduced motion', async ({ page }) =>
665665
await page.goto('/playroom/omokmaru/solo/');
666666
await expect(page.locator('#stage-overlay .overlay-card')).toHaveCSS('animation-name', 'overlay-enter');
667667
await page.locator('#side-white').click();
668-
await page.locator('#difficulty').selectOption('easy');
668+
await page.locator('#difficulty').selectOption('1');
669669
await page.locator('#start').click();
670670
await expect(page.locator('#status')).toHaveText('내 차례');
671671
await expect(page.locator('.player.active .piece')).toHaveCSS('animation-name', 'turn-piece-pulse');
@@ -758,7 +758,7 @@ test('solo match replaces start with resign until the match ends', async ({ page
758758
test('desktop board click places a stone immediately', async ({ page }) => {
759759
await page.goto('/playroom/omokmaru/solo/');
760760
await page.locator('#side-white').click();
761-
await page.locator('#difficulty').selectOption('easy');
761+
await page.locator('#difficulty').selectOption('1');
762762
await page.locator('#start').click();
763763
const canvas = page.locator('.canvas canvas');
764764
const box = await canvas.boundingBox();
@@ -773,7 +773,9 @@ test('desktop board click places a stone immediately', async ({ page }) => {
773773

774774
test('solo board renders the AI reply after the player move', async ({ page }) => {
775775
await page.goto('/playroom/omokmaru/solo/');
776-
await page.locator('#difficulty').selectOption('easy');
776+
await expect(page.locator('#difficulty option')).toHaveCount(9);
777+
await expect(page.locator('#difficulty')).toHaveValue('5');
778+
await page.locator('#difficulty').selectOption('1');
777779
await page.locator('#start').click();
778780
await expect(page.locator('#canvas')).toHaveAttribute('data-rendered-moves', '2');
779781
const canvas = page.locator('.canvas canvas');
@@ -789,6 +791,25 @@ test('solo board renders the AI reply after the player move', async ({ page }) =
789791
await expect(page.locator('#canvas')).toHaveAttribute('data-rendered-moves', '2');
790792
});
791793

794+
test('level nine AI finishes a searched reply', async ({ page }) => {
795+
test.setTimeout(45_000);
796+
await page.goto('/playroom/omokmaru/solo/');
797+
await page.locator('#difficulty').selectOption('9');
798+
await page.locator('#start').click();
799+
await expect(page.locator('#canvas')).toHaveAttribute('data-rendered-moves', '2', { timeout: 20_000 });
800+
await page.locator('.canvas canvas').focus();
801+
for (let step = 0; step < 5; step++) {
802+
await page.keyboard.press('ArrowRight');
803+
if ((await page.locator('#board-keyboard-status').textContent())?.includes('빈 자리')) break;
804+
}
805+
await expect(page.locator('#board-keyboard-status')).toContainText('빈 자리');
806+
await page.keyboard.press('Enter');
807+
await expect(page.locator('#canvas')).toHaveAttribute('data-rendered-moves', '4', { timeout: 20_000 });
808+
await expect(page.locator('#status')).toHaveText('내 차례');
809+
await page.reload();
810+
await expect(page.locator('#difficulty')).toHaveValue('9');
811+
});
812+
792813
test('Omokmaru sound setting persists between visits', async ({ page }) => {
793814
await page.goto('/playroom/omokmaru/solo/');
794815
await page.locator('#sound').click();
@@ -938,7 +959,7 @@ test('Omokmaru studio puts the 3D preview before options on a phone', async ({ b
938959
test('keyboard can inspect coordinates and place a solo move', async ({ page }) => {
939960
await page.goto('/playroom/omokmaru/solo/');
940961
await page.locator('#side-white').click();
941-
await page.locator('#difficulty').selectOption('easy');
962+
await page.locator('#difficulty').selectOption('1');
942963
await page.locator('#start').click();
943964
const canvas = page.locator('.canvas canvas');
944965
await expect(canvas).toHaveAttribute('tabindex', '0');
@@ -991,7 +1012,7 @@ test('touch board tap previews a stone until the move button confirms it', async
9911012
const page = await context.newPage();
9921013
try {
9931014
await page.goto('/playroom/omokmaru/solo/');
994-
await page.locator('#difficulty').selectOption('easy');
1015+
await page.locator('#difficulty').selectOption('1');
9951016
await page.locator('#start').click();
9961017
const box = await page.locator('.canvas canvas').boundingBox();
9971018
const center = { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 };

‎tests/games/renju-ai.test.ts‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import { describe, expect, it } from 'vitest';
2-
import { chooseAiMove } from '../../src/domains/games/renju/ai';
2+
import { chooseAiMove, normalizeDifficulty } from '../../src/domains/games/renju/ai';
33
import { analyzeMove, boardFromMoves, emptyBoard, index, type Move } from '../../src/domains/games/renju/rules';
44

55
describe('Renju AI', () => {
6+
it('accepts exactly nine difficulty levels', () => {
7+
for (let level = 1; level <= 9; level++) expect(normalizeDifficulty(String(level))).toBe(level);
8+
for (const invalid of ['0', '10', '3.5', 'master', null]) expect(normalizeDifficulty(invalid)).toBe(5);
9+
});
610
it('opens at the required center point', () => {
7-
for (const difficulty of ['easy', 'normal', 'hard', 'master'] as const)
11+
for (const difficulty of [1, 2, 3, 4, 5, 6, 7, 8, 9] as const)
812
expect(chooseAiMove(boardFromMoves([]), 1, difficulty)).toEqual({ x: 7, y: 7 });
913
});
1014
it('takes an immediate legal win', () => {
@@ -18,7 +22,7 @@ describe('Renju AI', () => {
1822
{ x: 10, y: 7, color: 1 },
1923
{ x: 5, y: 2, color: 2 },
2024
];
21-
for (const difficulty of ['easy', 'normal', 'hard', 'master'] as const) {
25+
for (const difficulty of [1, 2, 3, 4, 5, 6, 7, 8, 9] as const) {
2226
const point = chooseAiMove(boardFromMoves(moves), 1, difficulty);
2327
expect(point?.y).toBe(7);
2428
expect([6, 11]).toContain(point?.x);
@@ -28,7 +32,7 @@ describe('Renju AI', () => {
2832
const board = emptyBoard();
2933
for (const x of [7, 8, 9, 10]) board[index(x, 7)] = 2;
3034
board[index(6, 7)] = 1;
31-
for (const difficulty of ['normal', 'hard', 'master'] as const)
35+
for (const difficulty of [4, 5, 6, 7, 8, 9] as const)
3236
expect(chooseAiMove(board.slice(), 1, difficulty)).toEqual({ x: 11, y: 7 });
3337
});
3438
it('never selects a black double-three forbidden point', () => {
@@ -42,9 +46,9 @@ describe('Renju AI', () => {
4246
board[index(x, y)] = 1;
4347
expect(analyzeMove(board, 7, 7, 1).forbidden).toBe('double-three');
4448
const before = board.slice();
45-
for (const difficulty of ['easy', 'normal', 'hard', 'master'] as const) {
49+
for (const difficulty of [1, 4, 9] as const) {
4650
expect(chooseAiMove(board, 1, difficulty)).not.toEqual({ x: 7, y: 7 });
4751
expect(board).toEqual(before);
4852
}
49-
});
53+
}, 15_000);
5054
});

0 commit comments

Comments
 (0)