chore: восстановление репозитория из снапшота v0.3.1
Прежняя git-история утрачена при переносе проекта на машину владельца (снапшот без .git). Хэши коммитов в docs/reports/* относятся к утраченной истории. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
1c85091186
184 changed files with 33303 additions and 0 deletions
14
packages/ai/package.json
Normal file
14
packages/ai/package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "@go-learn/ai",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Эвристический ИИ ур. 1–3: приоритеты атари (спасение/захват), ходы вблизи хода противника, фильтр своего глаза. Синхронный чистый вычислитель: случайность только через инжектируемый Rng, легальность только через applyMove из @go-learn/core.",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@go-learn/core": "0.1.0"
|
||||
}
|
||||
}
|
||||
98
packages/ai/src/ai.ts
Normal file
98
packages/ai/src/ai.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* Эвристический ИИ ур. 1–3 (контракт — docs/INTERFACES.md, раздел packages/ai).
|
||||
* Синхронный чистый вычислитель: без DOM, без воркер-API, без собственного
|
||||
* состояния; случайность — только через request.rng; легальность — только
|
||||
* через applyMove из @go-learn/core.
|
||||
*
|
||||
* Ролл-семантика уровней: на каждый ход — два ролла rng.next() в фиксированном
|
||||
* порядке: первый для save-atari, второй для capture-atari. Ролл < p(level) →
|
||||
* приоритет активен; при неактивном приоритете или отсутствии подходящих
|
||||
* кандидатов — переход к следующему. Роллы делаются всегда, даже если групп
|
||||
* в атари нет, — позиция в потоке rng не зависит от позиции на доске.
|
||||
*/
|
||||
import type { BoardState, Move, Point, Rng } from '@go-learn/core';
|
||||
import type { Candidate } from './candidates.js';
|
||||
import { legalCandidates } from './candidates.js';
|
||||
import { nearbyCandidates, pickUniform, pickWeighted } from './pick.js';
|
||||
import { captureMoves, savingMoves } from './tactics.js';
|
||||
|
||||
export type AiLevel = 1 | 2 | 3;
|
||||
|
||||
/**
|
||||
* Вероятность применения приоритетов «спасти своё атари» / «забрать чужое»
|
||||
* на каждом уровне (ур.1 ≈ 0.3, ур.3 ≈ 0.9 — по спеке).
|
||||
*/
|
||||
export const LEVEL_ATARI_PROBABILITY: Readonly<Record<AiLevel, number>> = {
|
||||
1: 0.3,
|
||||
2: 0.6,
|
||||
3: 0.9,
|
||||
};
|
||||
|
||||
export interface AiMoveRequest {
|
||||
readonly state: BoardState; // ходит state.toPlay
|
||||
readonly level: AiLevel;
|
||||
readonly rng: Rng; // seeded снаружи
|
||||
readonly lastOpponentMove?: Point | null; // для приоритета nearby
|
||||
}
|
||||
|
||||
export type AiReason =
|
||||
| 'save-atari' // спас свою группу в атари
|
||||
| 'capture-atari' // забрал чужую группу в атари
|
||||
| 'nearby' // взвешенный ход вблизи lastOpponentMove (Chebyshev ≤ 2)
|
||||
| 'random' // случайный легальный
|
||||
| 'pass'; // нет кандидатов
|
||||
|
||||
export interface AiMoveResult {
|
||||
readonly move: Move; // kind 'play' | 'pass'; resign на ур. 1–3 не генерируется
|
||||
readonly reason: AiReason;
|
||||
}
|
||||
|
||||
/** Результат-постановка камня. */
|
||||
function playResult(state: BoardState, candidate: Candidate, reason: AiReason): AiMoveResult {
|
||||
return {
|
||||
move: { kind: 'play', color: state.toPlay, point: candidate.point },
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
/** Приоритеты 1–2: тактика атари с роллами вероятности уровня. */
|
||||
function tacticalMove(
|
||||
state: BoardState,
|
||||
candidates: readonly Candidate[],
|
||||
probability: number,
|
||||
rng: Rng,
|
||||
): AiMoveResult | null {
|
||||
if (rng.next() < probability) {
|
||||
const saving = pickUniform(savingMoves(state, candidates), rng);
|
||||
if (saving !== null) return playResult(state, saving, 'save-atari');
|
||||
}
|
||||
if (rng.next() < probability) {
|
||||
const capturing = pickUniform(captureMoves(state, candidates), rng);
|
||||
if (capturing !== null) return playResult(state, capturing, 'capture-atari');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Выбор хода по приоритетам: save-atari → capture-atari → nearby → random →
|
||||
* pass (только когда кандидатов не осталось).
|
||||
* Вызов при state.over === true — программная ошибка: кидает Error.
|
||||
*/
|
||||
export function chooseMove(request: AiMoveRequest): AiMoveResult {
|
||||
const { state, level, rng } = request;
|
||||
if (state.over) throw new Error('chooseMove: партия уже завершена (state.over === true)');
|
||||
const candidates = legalCandidates(state);
|
||||
if (candidates.length === 0) {
|
||||
return { move: { kind: 'pass', color: state.toPlay }, reason: 'pass' };
|
||||
}
|
||||
const tactical = tacticalMove(state, candidates, LEVEL_ATARI_PROBABILITY[level], rng);
|
||||
if (tactical !== null) return tactical;
|
||||
const nearby = nearbyCandidates(candidates, request.lastOpponentMove ?? null);
|
||||
if (nearby.length > 0) {
|
||||
const picked = pickWeighted(nearby, rng);
|
||||
if (picked !== null) return playResult(state, picked, 'nearby');
|
||||
}
|
||||
const random = pickUniform(candidates, rng);
|
||||
if (random !== null) return playResult(state, random, 'random');
|
||||
return { move: { kind: 'pass', color: state.toPlay }, reason: 'pass' };
|
||||
}
|
||||
59
packages/ai/src/candidates.ts
Normal file
59
packages/ai/src/candidates.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* Кандидаты на ход: легальные постановки камня, не заполняющие свой глаз.
|
||||
* Легальность проверяется ТОЛЬКО через applyMove из @go-learn/core —
|
||||
* дублирование правил запрещено контрактом.
|
||||
*/
|
||||
import { applyMove, cellAt } from '@go-learn/core';
|
||||
import type { BoardState, Point } from '@go-learn/core';
|
||||
|
||||
/** Легальный ход-кандидат с предрасчитанным состоянием после хода. */
|
||||
export interface Candidate {
|
||||
readonly point: Point;
|
||||
/** Состояние после постановки камня в point. */
|
||||
readonly next: BoardState;
|
||||
}
|
||||
|
||||
/** Ортогональные соседи точки в пределах доски. */
|
||||
export function orthogonalNeighbors(size: number, point: Point): Point[] {
|
||||
const result: Point[] = [];
|
||||
const deltas = [
|
||||
{ x: -1, y: 0 },
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 0, y: -1 },
|
||||
{ x: 0, y: 1 },
|
||||
];
|
||||
for (const delta of deltas) {
|
||||
const neighbor = { x: point.x + delta.x, y: point.y + delta.y };
|
||||
const inside = neighbor.x >= 0 && neighbor.x < size && neighbor.y >= 0 && neighbor.y < size;
|
||||
if (inside) result.push(neighbor);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Эвристика «свой глаз» (простая, по контракту): все ортогональные соседи
|
||||
* точки — свои камни, и ход ничего не захватывает. Захват не может быть
|
||||
* заполнением глаза, поэтому ходы с capturedCount > 0 не отсеиваются.
|
||||
*/
|
||||
function isOwnEyeFill(state: BoardState, point: Point, capturedCount: number): boolean {
|
||||
if (capturedCount > 0) return false;
|
||||
return orthogonalNeighbors(state.size, point).every(
|
||||
(neighbor) => cellAt(state, neighbor) === state.toPlay,
|
||||
);
|
||||
}
|
||||
|
||||
/** Все легальные ходы-постановки, прошедшие фильтр своего глаза. */
|
||||
export function legalCandidates(state: BoardState): Candidate[] {
|
||||
const result: Candidate[] = [];
|
||||
for (let y = 0; y < state.size; y += 1) {
|
||||
for (let x = 0; x < state.size; x += 1) {
|
||||
const point = { x, y };
|
||||
if (cellAt(state, point) !== 'empty') continue;
|
||||
const applied = applyMove(state, { kind: 'play', color: state.toPlay, point });
|
||||
if (!applied.ok) continue;
|
||||
if (isOwnEyeFill(state, point, applied.captured.length)) continue;
|
||||
result.push({ point, next: applied.state });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
210
packages/ai/src/choose-move.test.ts
Normal file
210
packages/ai/src/choose-move.test.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
/**
|
||||
* Тесты приоритетов и фильтров chooseMove на сконструированных позициях:
|
||||
* save-atari (включая контрзахват), capture-atari, фильтр своего глаза,
|
||||
* pass без кандидатов, запрет вызова при over, вероятности уровней.
|
||||
*/
|
||||
import { applyMove, createBoard, groupAt } from '@go-learn/core';
|
||||
import type { BoardState, Rng } from '@go-learn/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { chooseMove, LEVEL_ATARI_PROBABILITY } from './index.js';
|
||||
import { stateFromAscii } from './test-utils.js';
|
||||
|
||||
/** Rng-заглушки: ролл всегда ниже / всегда выше любой вероятности уровня. */
|
||||
const alwaysZero: Rng = { next: () => 0 };
|
||||
const alwaysHigh: Rng = { next: () => 0.99 };
|
||||
|
||||
/** Чёрный камень (4,4) в атари, единственное дамэ — (4,5); ход чёрных. */
|
||||
function ownAtariState(): BoardState {
|
||||
return stateFromAscii(9, 'black', [
|
||||
'.........',
|
||||
'.........',
|
||||
'.........',
|
||||
'....O....',
|
||||
'...OXO...',
|
||||
'.........',
|
||||
'.........',
|
||||
'.........',
|
||||
'.........',
|
||||
]);
|
||||
}
|
||||
|
||||
/** Белый камень (4,4) в атари, последнее дамэ — (4,5); ход чёрных. */
|
||||
function enemyAtariState(): BoardState {
|
||||
return stateFromAscii(9, 'black', [
|
||||
'.........',
|
||||
'.........',
|
||||
'.........',
|
||||
'....X....',
|
||||
'...XOX...',
|
||||
'.........',
|
||||
'.........',
|
||||
'.........',
|
||||
'.........',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Контрзахват: чёрный (4,4) в атари, продление в (5,4) — самоубийство,
|
||||
* единственное спасение — забрать белый (3,4) ходом в (3,5).
|
||||
*/
|
||||
function counterCaptureState(): BoardState {
|
||||
return stateFromAscii(9, 'black', [
|
||||
'.........',
|
||||
'.........',
|
||||
'.........',
|
||||
'...XOO...',
|
||||
'..XOX.O..',
|
||||
'....OO...',
|
||||
'.........',
|
||||
'.........',
|
||||
'.........',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Фильтр глаза: (1,1) — легальный ход, заполняющий свой глаз (все соседи —
|
||||
* свои, захвата нет); единственный ход после фильтра — (8,8) со захватом.
|
||||
*/
|
||||
function eyeFillState(): BoardState {
|
||||
return stateFromAscii(9, 'black', [
|
||||
'XXXXXXXXX',
|
||||
'X.XXXXXXX',
|
||||
'XXXXXXXXX',
|
||||
'XXXXXXXXX',
|
||||
'XXXXXXXXX',
|
||||
'XXXXXXXXX',
|
||||
'XXXXXXXXX',
|
||||
'XXXXXXXXO',
|
||||
'XXXXXXXX.',
|
||||
]);
|
||||
}
|
||||
|
||||
/** Единственная пустая точка — самоубийственный глаз: кандидатов нет. */
|
||||
function onlyEyeState(): BoardState {
|
||||
return stateFromAscii(9, 'black', [
|
||||
'XXXXXXXXX',
|
||||
'X.XXXXXXX',
|
||||
'XXXXXXXXX',
|
||||
'XXXXXXXXX',
|
||||
'XXXXXXXXX',
|
||||
'XXXXXXXXX',
|
||||
'XXXXXXXXX',
|
||||
'XXXXXXXXX',
|
||||
'XXXXXXXXX',
|
||||
]);
|
||||
}
|
||||
|
||||
describe('LEVEL_ATARI_PROBABILITY', () => {
|
||||
it('вероятности по спеке: ур.1 ≈ 0.3, ур.3 ≈ 0.9, монотонны', () => {
|
||||
expect(LEVEL_ATARI_PROBABILITY[1]).toBe(0.3);
|
||||
expect(LEVEL_ATARI_PROBABILITY[2]).toBe(0.6);
|
||||
expect(LEVEL_ATARI_PROBABILITY[3]).toBe(0.9);
|
||||
expect(LEVEL_ATARI_PROBABILITY[1]).toBeLessThan(LEVEL_ATARI_PROBABILITY[2]);
|
||||
expect(LEVEL_ATARI_PROBABILITY[2]).toBeLessThan(LEVEL_ATARI_PROBABILITY[3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('приоритет save-atari', () => {
|
||||
it('rng «всегда 0»: бот спасает свою группу, после хода у неё >1 дамэ', () => {
|
||||
const state = ownAtariState();
|
||||
const result = chooseMove({ state, level: 3, rng: alwaysZero });
|
||||
expect(result.reason).toBe('save-atari');
|
||||
expect(result.move).toEqual({ kind: 'play', color: 'black', point: { x: 4, y: 5 } });
|
||||
const applied = applyMove(state, result.move);
|
||||
expect(applied.ok).toBe(true);
|
||||
if (applied.ok) {
|
||||
expect(groupAt(applied.state, { x: 4, y: 4 })?.liberties).toBeGreaterThan(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('rng «всегда 0»: контрзахват — единственное спасение нападением', () => {
|
||||
const state = counterCaptureState();
|
||||
const result = chooseMove({ state, level: 3, rng: alwaysZero });
|
||||
expect(result.reason).toBe('save-atari');
|
||||
expect(result.move).toEqual({ kind: 'play', color: 'black', point: { x: 3, y: 5 } });
|
||||
const applied = applyMove(state, result.move);
|
||||
expect(applied.ok).toBe(true);
|
||||
if (applied.ok) expect(applied.captured).toEqual([{ x: 3, y: 4 }]);
|
||||
});
|
||||
|
||||
it('rng «всегда 0.99»: приоритет пропущен, reason другой', () => {
|
||||
const state = ownAtariState();
|
||||
const result = chooseMove({
|
||||
state,
|
||||
level: 3,
|
||||
rng: alwaysHigh,
|
||||
lastOpponentMove: { x: 4, y: 3 },
|
||||
});
|
||||
expect(result.reason).not.toBe('save-atari');
|
||||
expect(result.reason).toBe('nearby');
|
||||
});
|
||||
});
|
||||
|
||||
describe('приоритет capture-atari', () => {
|
||||
it('rng «всегда 0»: бот забирает чужую группу в атари', () => {
|
||||
const state = enemyAtariState();
|
||||
const result = chooseMove({ state, level: 3, rng: alwaysZero });
|
||||
expect(result.reason).toBe('capture-atari');
|
||||
expect(result.move).toEqual({ kind: 'play', color: 'black', point: { x: 4, y: 5 } });
|
||||
const applied = applyMove(state, result.move);
|
||||
expect(applied.ok).toBe(true);
|
||||
if (applied.ok) expect(applied.captured).toEqual([{ x: 4, y: 4 }]);
|
||||
});
|
||||
|
||||
it('rng «всегда 0.99»: приоритет пропущен, reason другой', () => {
|
||||
const state = enemyAtariState();
|
||||
const result = chooseMove({
|
||||
state,
|
||||
level: 3,
|
||||
rng: alwaysHigh,
|
||||
lastOpponentMove: { x: 0, y: 0 },
|
||||
});
|
||||
expect(result.reason).not.toBe('capture-atari');
|
||||
});
|
||||
});
|
||||
|
||||
describe('фильтр своего глаза', () => {
|
||||
it('единственный ход после фильтра — не глаз (точка (8,8))', () => {
|
||||
const state = eyeFillState();
|
||||
const result = chooseMove({ state, level: 3, rng: alwaysZero });
|
||||
expect(result.move).toEqual({ kind: 'play', color: 'black', point: { x: 8, y: 8 } });
|
||||
});
|
||||
|
||||
it('глаз (1,1) не выбирается ни на одном из seed’ов', () => {
|
||||
const state = eyeFillState();
|
||||
for (let seed = 1; seed <= 20; seed += 1) {
|
||||
let value = seed;
|
||||
const rng: Rng = {
|
||||
next: () => {
|
||||
value = (value * 1103515245 + 12345) % 2147483648;
|
||||
return value / 2147483648;
|
||||
},
|
||||
};
|
||||
const result = chooseMove({ state, level: 1, rng });
|
||||
expect(result.move).not.toEqual({
|
||||
kind: 'play',
|
||||
color: 'black',
|
||||
point: { x: 1, y: 1 },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('кандидатов нет (единственная точка — самоубийственный глаз) → pass', () => {
|
||||
const result = chooseMove({ state: onlyEyeState(), level: 3, rng: alwaysZero });
|
||||
expect(result).toEqual({ move: { kind: 'pass', color: 'black' }, reason: 'pass' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('вызов при завершённой партии', () => {
|
||||
it('state.over === true → Error', () => {
|
||||
let state = createBoard(9);
|
||||
const first = applyMove(state, { kind: 'pass', color: 'black' });
|
||||
expect(first.ok).toBe(true);
|
||||
if (first.ok) state = first.state;
|
||||
const second = applyMove(state, { kind: 'pass', color: 'white' });
|
||||
expect(second.ok).toBe(true);
|
||||
if (second.ok) state = second.state;
|
||||
expect(state.over).toBe(true);
|
||||
expect(() => chooseMove({ state, level: 3, rng: alwaysZero })).toThrow(Error);
|
||||
});
|
||||
});
|
||||
40
packages/ai/src/game.test.ts
Normal file
40
packages/ai/src/game.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* Партии бот-бот: детерминизм (одинаковый seed → идентичная партия),
|
||||
* легальность каждого хода и завершение за ≤ 200 ходов (property-style
|
||||
* на запиненных seed’ах; проверка легальности — внутри playBotGame через
|
||||
* applyMove, нелегальный ход = ошибка теста).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AiLevel } from './index.js';
|
||||
import { playBotGame } from './test-utils.js';
|
||||
|
||||
const MAX_MOVES_9x9 = 200;
|
||||
|
||||
describe('детерминизм', () => {
|
||||
it('одинаковый seed → идентичные последовательности ходов (два прогона)', () => {
|
||||
for (const seed of [7, 42, 2026]) {
|
||||
const first = playBotGame(seed, 3, 9, MAX_MOVES_9x9);
|
||||
const second = playBotGame(seed, 3, 9, MAX_MOVES_9x9);
|
||||
expect(second.moves).toEqual(first.moves);
|
||||
expect(second.finalState.grid).toEqual(first.finalState.grid);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('легальность и завершение (property, запиненные seed’ы)', () => {
|
||||
const levels: readonly AiLevel[] = [1, 2, 3];
|
||||
const cases = Array.from({ length: 12 }, (_, index) => ({
|
||||
seed: index + 1,
|
||||
level: levels[index % levels.length] ?? 1,
|
||||
}));
|
||||
for (const { seed, level } of cases) {
|
||||
it(`партия seed=${seed} ур.${level}: все ходы легальны, over за ≤ ${MAX_MOVES_9x9}`, () => {
|
||||
const game = playBotGame(seed, level, 9, MAX_MOVES_9x9);
|
||||
expect(game.finalState.over).toBe(true);
|
||||
expect(game.moves.length).toBeLessThanOrEqual(MAX_MOVES_9x9);
|
||||
// Партия завершается двумя пасами подряд.
|
||||
expect(game.moves[game.moves.length - 1]).toBe('pass');
|
||||
expect(game.moves[game.moves.length - 2]).toBe('pass');
|
||||
});
|
||||
}
|
||||
});
|
||||
18
packages/ai/src/index.ts
Normal file
18
packages/ai/src/index.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* Публичный API ИИ ур. 1–6 (контракт — docs/INTERFACES.md).
|
||||
* Чистый TypeScript: без DOM/worker API, без Date.now()/Math.random().
|
||||
*/
|
||||
export type { AiLevel, AiMoveRequest, AiMoveResult, AiReason } from './ai.js';
|
||||
export { chooseMove, LEVEL_ATARI_PROBABILITY } from './ai.js';
|
||||
export type { CandidateScore, MctsLevel, MctsRequest, MctsResult } from './mcts.js';
|
||||
export { chooseMoveMcts, LEVEL_TIME_BUDGET_MS } from './mcts.js';
|
||||
export type {
|
||||
RebuildResult,
|
||||
WorkerCancelRequest,
|
||||
WorkerChooseRequest,
|
||||
WorkerRequest,
|
||||
WorkerResponse,
|
||||
} from './worker-protocol.js';
|
||||
export { rebuildGameState } from './worker-protocol.js';
|
||||
export type { MessagePort, WorkerDeps } from './worker.js';
|
||||
export { createWorkerHandler } from './worker.js';
|
||||
207
packages/ai/src/mcts.test.ts
Normal file
207
packages/ai/src/mcts.test.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
/**
|
||||
* Тесты MCTS-движка (гейт приёмки этапа 4, docs/plans/plan-phase-4.md):
|
||||
* детерминизм, легальность, бюджет, качество-smoke, полная партия, perf-smoke.
|
||||
* Date.now() здесь — только в perf-smoke (замер реального времени в ТЕСТЕ,
|
||||
* движок получает часы через запрос).
|
||||
*/
|
||||
import { applyMove, createBoard, createSeededRng, defaultKomi } from '@go-learn/core';
|
||||
import type { BoardSize, BoardState, Clock, Move, Point } from '@go-learn/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { chooseMove } from './ai.js';
|
||||
import type { MctsRequest } from './mcts.js';
|
||||
import { chooseMoveMcts, LEVEL_TIME_BUDGET_MS } from './mcts.js';
|
||||
import { stateFromAscii } from './test-utils.js';
|
||||
|
||||
/** Часы-заглушка: время не течёт (для прогонов с maxSimulations). */
|
||||
const frozenClock: Clock = { now: () => 0 };
|
||||
|
||||
/** Базовый запрос с переопределениями. */
|
||||
function request(overrides: Partial<MctsRequest> & { state: BoardState }): MctsRequest {
|
||||
return {
|
||||
komi: defaultKomi(overrides.state.size),
|
||||
level: 4,
|
||||
rng: createSeededRng(1),
|
||||
clock: frozenClock,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Позиция после count ходов эвристики ур.2 от пустой доски (запиненный seed). */
|
||||
function positionAfterMoves(size: BoardSize, seed: number, count: number): BoardState {
|
||||
const rng = createSeededRng(seed);
|
||||
let state = createBoard(size);
|
||||
let lastOpponentMove: Point | null = null;
|
||||
for (let index = 0; index < count && !state.over; index += 1) {
|
||||
const result = chooseMove({ state, level: 2, rng, lastOpponentMove });
|
||||
const applied = applyMove(state, result.move);
|
||||
if (!applied.ok) throw new Error(`хелпер: нелегальный ход эвристики: ${applied.error}`);
|
||||
lastOpponentMove = result.move.kind === 'play' ? result.move.point : null;
|
||||
state = applied.state;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Проверка легальности хода MCTS через applyMove. */
|
||||
function expectLegal(state: BoardState, move: Move): void {
|
||||
const applied = applyMove(state, move);
|
||||
expect(applied.ok, applied.ok ? '' : applied.error).toBe(true);
|
||||
}
|
||||
|
||||
describe('chooseMoveMcts: детерминизм', () => {
|
||||
it('maxSimulations=50 + фиксированный seed → идентичные move и topMoves', () => {
|
||||
const state = positionAfterMoves(9, 42, 20);
|
||||
const run = (): { move: Move; topMoves: unknown } => {
|
||||
const result = chooseMoveMcts(
|
||||
request({ state, rng: createSeededRng(777), maxSimulations: 50 }),
|
||||
);
|
||||
return { move: result.move, topMoves: result.topMoves };
|
||||
};
|
||||
const first = run();
|
||||
const second = run();
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
|
||||
it('вызов при state.over === true кидает Error', () => {
|
||||
let state = createBoard(9);
|
||||
for (const move of [
|
||||
{ kind: 'pass', color: 'black' },
|
||||
{ kind: 'pass', color: 'white' },
|
||||
] as const) {
|
||||
const applied = applyMove(state, move);
|
||||
if (!applied.ok) throw new Error('хелпер: пас не применился');
|
||||
state = applied.state;
|
||||
}
|
||||
expect(state.over).toBe(true);
|
||||
expect(() => chooseMoveMcts(request({ state }))).toThrow(/завершена/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('chooseMoveMcts: легальность', () => {
|
||||
const cases: ReadonlyArray<{ size: BoardSize; seed: number; moves: number }> = [
|
||||
{ size: 9, seed: 11, moves: 15 },
|
||||
{ size: 9, seed: 22, moves: 30 },
|
||||
{ size: 9, seed: 33, moves: 50 },
|
||||
{ size: 13, seed: 44, moves: 30 },
|
||||
{ size: 13, seed: 55, moves: 60 },
|
||||
];
|
||||
for (const { size, seed, moves } of cases) {
|
||||
it(`ход легален на случайной позиции ${size}×${size} (seed ${seed}, ${moves} ходов)`, () => {
|
||||
const state = positionAfterMoves(size, seed, moves);
|
||||
const result = chooseMoveMcts(
|
||||
request({ state, rng: createSeededRng(seed * 1000), maxSimulations: 30 }),
|
||||
);
|
||||
expectLegal(state, result.move);
|
||||
for (const candidate of result.topMoves) expectLegal(state, candidate.move);
|
||||
expect(result.simulations).toBe(30);
|
||||
expect(result.topMoves.length).toBeGreaterThan(0);
|
||||
expect(result.topMoves.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('chooseMoveMcts: бюджет', () => {
|
||||
it('остановка по deadline фейк-часов (now() += 50 мс на вызов)', () => {
|
||||
let t = 0;
|
||||
const clock: Clock = { now: () => (t += 50) };
|
||||
const result = chooseMoveMcts(request({ state: createBoard(9), clock }));
|
||||
expect(result.simulations).toBeGreaterThanOrEqual(1);
|
||||
expect(result.simulations).toBeLessThanOrEqual(10);
|
||||
expect(result.elapsedMs).toBeGreaterThanOrEqual(LEVEL_TIME_BUDGET_MS[4]);
|
||||
expectLegal(createBoard(9), result.move);
|
||||
});
|
||||
|
||||
it('shouldStop после 5 симуляций → стоп, ход легален', () => {
|
||||
let calls = 0;
|
||||
const result = chooseMoveMcts(
|
||||
request({ state: createBoard(9), shouldStop: () => (calls += 1) > 5 }),
|
||||
);
|
||||
expect(result.simulations).toBeLessThanOrEqual(6);
|
||||
expect(result.simulations).toBeGreaterThanOrEqual(1);
|
||||
expectLegal(createBoard(9), result.move);
|
||||
});
|
||||
|
||||
it('shouldStop() до первой симуляции → легальный ход, без падения', () => {
|
||||
const result = chooseMoveMcts(request({ state: createBoard(9), shouldStop: () => true }));
|
||||
expect(result.simulations).toBe(0);
|
||||
expectLegal(createBoard(9), result.move);
|
||||
});
|
||||
});
|
||||
|
||||
describe('chooseMoveMcts: качество-smoke', () => {
|
||||
// Белое кольцо из 8 камней в атари: единственное дамэ — (4,5), ход туда
|
||||
// снимает всё кольцо. Баланс подобран так, что захват решает партию даже
|
||||
// в случайных плейаутах: сплошные стены сверху (белые) и снизу (чёрные)
|
||||
// выживают в плейаутах, поэтому живая группа ≈ победа белых, снятая ≈
|
||||
// победа чёрных. Smoke «дерево реально ищет», не эталон силы: seed
|
||||
// запинен (при 200 симуляциях сигнал сильный, но не абсолютный).
|
||||
const state = stateFromAscii(9, 'black', [
|
||||
'OOOOOOOOO',
|
||||
'OOOOOOOOO',
|
||||
'.........',
|
||||
'...XXX...',
|
||||
'..XOOOX..',
|
||||
'..XO.OX..',
|
||||
'..XOOOX..',
|
||||
'...XXX...',
|
||||
'XXXXXXXXX',
|
||||
]);
|
||||
|
||||
it('ур.4 (maxSimulations=200) забирает группу в атари', () => {
|
||||
const result = chooseMoveMcts(request({ state, rng: createSeededRng(1), maxSimulations: 200 }));
|
||||
expect(result.move).toEqual({ kind: 'play', color: 'black', point: { x: 4, y: 5 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('chooseMoveMcts: полная партия 9×9 против эвристики ур.3', () => {
|
||||
it('партия доигрывается до over, все ходы легальны', { timeout: 120_000 }, () => {
|
||||
const mctsRng = createSeededRng(9001);
|
||||
const heuristicRng = createSeededRng(9002);
|
||||
let state = createBoard(9);
|
||||
let lastOpponentMove: Point | null = null;
|
||||
let plies = 0;
|
||||
while (!state.over) {
|
||||
plies += 1;
|
||||
if (plies > 400) throw new Error('партия не завершилась за 400 полуходов');
|
||||
const move: Move =
|
||||
state.toPlay === 'black'
|
||||
? chooseMoveMcts(request({ state, rng: mctsRng, maxSimulations: 100 })).move
|
||||
: chooseMove({ state, level: 3, rng: heuristicRng, lastOpponentMove }).move;
|
||||
const applied = applyMove(state, move);
|
||||
if (!applied.ok) throw new Error(`нелегальный ход на полуходе ${plies}: ${applied.error}`);
|
||||
lastOpponentMove = move.kind === 'play' ? move.point : null;
|
||||
state = applied.state;
|
||||
}
|
||||
expect(state.over).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('chooseMoveMcts: perf-smoke (реальное время, только для отчёта)', () => {
|
||||
it('число симуляций за 300 мс на 9×9 > 10', { timeout: 10_000 }, () => {
|
||||
const result = chooseMoveMcts(
|
||||
request({ state: createBoard(9), clock: { now: () => Date.now() } }),
|
||||
);
|
||||
console.warn(`[perf] MCTS ур.4 9×9: ${result.simulations} симуляций за ${result.elapsedMs} мс`);
|
||||
expect(result.simulations).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it(
|
||||
'ур.6 на 13×13 укладывается в бюджет 1500 мс + overhead (< 2.2 с)',
|
||||
{ timeout: 10_000 },
|
||||
() => {
|
||||
const start = Date.now();
|
||||
const result = chooseMoveMcts(
|
||||
request({
|
||||
state: createBoard(13),
|
||||
level: 6,
|
||||
rng: createSeededRng(2),
|
||||
clock: { now: () => Date.now() },
|
||||
}),
|
||||
);
|
||||
const wall = Date.now() - start;
|
||||
console.warn(
|
||||
`[perf] MCTS ур.6 13×13: фактически ${wall} мс, ${result.simulations} симуляций`,
|
||||
);
|
||||
expect(wall).toBeLessThan(2200);
|
||||
},
|
||||
);
|
||||
});
|
||||
301
packages/ai/src/mcts.ts
Normal file
301
packages/ai/src/mcts.ts
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
/**
|
||||
* MCTS-движок ИИ ур. 4–6 (контракт — docs/INTERFACES.md, раздел «Контракт
|
||||
* MCTS ИИ ур. 4–6»). Чистый синхронный вычислитель: без DOM/worker API, без
|
||||
* Date.now()/Math.random() — случайность и время только через Rng/Clock
|
||||
* из запроса. Легальность ходов — только через applyMove из @go-learn/core.
|
||||
*
|
||||
* Дерево: UCT (c = 1.4), ленивая экспансия легальных детей, pass — легальный
|
||||
* узел. Плейаут — «лёгкая» политика: дешёвая реакция на атари вокруг
|
||||
* последнего хода, иначе случайная точка с фильтром своего глаза и лимитом
|
||||
* попыток; конец — два паса или кап size²×2 ходов. Оценка — scorePosition
|
||||
* с komi запроса.
|
||||
*/
|
||||
import { applyMove, cellAt, groupAt, opposite, scorePosition } from '@go-learn/core';
|
||||
import type { BoardState, Clock, Color, Group, Move, Point, Rng } from '@go-learn/core';
|
||||
import { legalCandidates, orthogonalNeighbors } from './candidates.js';
|
||||
import { pickUniform } from './pick.js';
|
||||
|
||||
export type MctsLevel = 4 | 5 | 6;
|
||||
|
||||
/** Бюджет по времени: ур.4 — 300 мс, ур.5 — 800 мс, ур.6 — 1500 мс. */
|
||||
export const LEVEL_TIME_BUDGET_MS: Readonly<Record<MctsLevel, number>> = {
|
||||
4: 300,
|
||||
5: 800,
|
||||
6: 1500,
|
||||
};
|
||||
|
||||
/** Константа разведки UCT. */
|
||||
const UCT_C = 1.4;
|
||||
/** Лимит попыток случайной постановки в плейауте, дальше — пас. */
|
||||
const PLAYOUT_ATTEMPTS = 10;
|
||||
|
||||
export interface MctsRequest {
|
||||
readonly state: BoardState; // ходит state.toPlay
|
||||
readonly komi: number; // для оценки плейаута (scorePosition)
|
||||
readonly level: MctsLevel;
|
||||
readonly rng: Rng; // seeded снаружи
|
||||
readonly clock: Clock; // бюджет по времени
|
||||
readonly maxSimulations?: number; // override: ровно N симуляций, без тайм-бюджета
|
||||
readonly shouldStop?: () => boolean; // отмена: проверяется между симуляциями
|
||||
}
|
||||
|
||||
export interface CandidateScore {
|
||||
readonly move: Move; // play или pass
|
||||
readonly winRate: number; // 0..1 по визитам
|
||||
readonly visits: number;
|
||||
}
|
||||
|
||||
export interface MctsResult {
|
||||
readonly move: Move; // лучший по визитам (UCT — внутри дерева)
|
||||
readonly topMoves: readonly CandidateScore[]; // до 3 лучших — подсказка этапа 5
|
||||
readonly simulations: number;
|
||||
readonly elapsedMs: number; // по инжектированным часам
|
||||
}
|
||||
|
||||
/**
|
||||
* Узел дерева. wins/visits — с точки зрения игрока player (сделавшего ход
|
||||
* в этот узел); у корня player — формальный (opposite(toPlay)), в UCT корня
|
||||
* не участвует. untried: null — легальные дети ещё не вычислялись (лениво).
|
||||
*/
|
||||
interface MctsNode {
|
||||
readonly move: Move | null; // null только у корня
|
||||
readonly state: BoardState;
|
||||
readonly player: Color;
|
||||
visits: number;
|
||||
wins: number;
|
||||
readonly children: MctsNode[];
|
||||
untried: Move[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Все легальные ходы-постановки (через applyMove) плюс пас. Порядок —
|
||||
* эвристика экспансии: первыми идут ходы со снятием камней (захваты
|
||||
* разворачиваются в дерево раньше остальных), пас — последним.
|
||||
*/
|
||||
function legalMoves(state: BoardState): Move[] {
|
||||
const captures: Move[] = [];
|
||||
const quiet: Move[] = [];
|
||||
for (let y = 0; y < state.size; y += 1) {
|
||||
for (let x = 0; x < state.size; x += 1) {
|
||||
const point = { x, y };
|
||||
if (cellAt(state, point) !== 'empty') continue;
|
||||
const move: Move = { kind: 'play', color: state.toPlay, point };
|
||||
const applied = applyMove(state, move);
|
||||
if (!applied.ok) continue;
|
||||
if (applied.captured.length > 0) captures.push(move);
|
||||
else quiet.push(move);
|
||||
}
|
||||
}
|
||||
return [...captures, ...quiet, { kind: 'pass', color: state.toPlay }];
|
||||
}
|
||||
|
||||
/** UCT-оценка ребёнка; непосещённый узел — бесконечный приоритет. */
|
||||
function uctScore(child: MctsNode, parentVisits: number): number {
|
||||
if (child.visits === 0) return Infinity;
|
||||
const exploitation = child.wins / child.visits;
|
||||
const exploration = UCT_C * Math.sqrt(Math.log(parentVisits) / child.visits);
|
||||
return exploitation + exploration;
|
||||
}
|
||||
|
||||
/** Ребёнок с максимальной UCT-оценкой (при равенстве — первый). */
|
||||
function selectChild(node: MctsNode): MctsNode {
|
||||
let best: MctsNode | null = null;
|
||||
let bestScore = -Infinity;
|
||||
for (const child of node.children) {
|
||||
const score = uctScore(child, node.visits);
|
||||
if (score > bestScore) {
|
||||
best = child;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
return best as MctsNode;
|
||||
}
|
||||
|
||||
/** Ленивая экспансия: первый untried-ход (порядок legalMoves) — новый узел. */
|
||||
function expand(node: MctsNode): MctsNode | null {
|
||||
if (node.untried === null) node.untried = node.state.over ? [] : legalMoves(node.state);
|
||||
if (node.untried.length === 0) return null;
|
||||
const move = node.untried.shift() as Move;
|
||||
const applied = applyMove(node.state, move);
|
||||
if (!applied.ok) return null; // недостижимо: untried собран из легальных
|
||||
const child: MctsNode = {
|
||||
move,
|
||||
state: applied.state,
|
||||
player: opposite(applied.state.toPlay),
|
||||
visits: 0,
|
||||
wins: 0,
|
||||
children: [],
|
||||
untried: null,
|
||||
};
|
||||
node.children.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
/** Первое дамэ группы (у группы в атари оно единственное). */
|
||||
function soleLiberty(state: BoardState, group: Group): Point | null {
|
||||
for (const stone of group.stones) {
|
||||
for (const neighbor of orthogonalNeighbors(state.size, stone)) {
|
||||
if (cellAt(state, neighbor) === 'empty') return neighbor;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Дамэ группы цвета color в атари среди самой точки и её ортососедей. */
|
||||
function adjacentAtariLiberty(state: BoardState, point: Point, color: Color): Point | null {
|
||||
const seen = new Set<string>();
|
||||
for (const candidate of [point, ...orthogonalNeighbors(state.size, point)]) {
|
||||
const key = `${candidate.x},${candidate.y}`;
|
||||
if (seen.has(key) || cellAt(state, candidate) !== color) continue;
|
||||
const group = groupAt(state, candidate);
|
||||
if (group === null) continue;
|
||||
for (const stone of group.stones) seen.add(`${stone.x},${stone.y}`);
|
||||
if (group.liberties === 1) return soleLiberty(state, group);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Дешёвая реакция на атари вокруг последнего хода: добрать чужое / спасти своё. */
|
||||
function atariReaction(state: BoardState, lastPoint: Point): Move | null {
|
||||
const capture = adjacentAtariLiberty(state, lastPoint, opposite(state.toPlay));
|
||||
const liberty = capture ?? adjacentAtariLiberty(state, lastPoint, state.toPlay);
|
||||
if (liberty === null) return null;
|
||||
return { kind: 'play', color: state.toPlay, point: liberty };
|
||||
}
|
||||
|
||||
/** Эвристика «свой глаз»: все ортогональные соседи точки — свои камни. */
|
||||
function fillsOwnEye(state: BoardState, point: Point): boolean {
|
||||
return orthogonalNeighbors(state.size, point).every(
|
||||
(neighbor) => cellAt(state, neighbor) === state.toPlay,
|
||||
);
|
||||
}
|
||||
|
||||
/** Случайная легальная постановка с фильтром своего глаза; после лимита — пас. */
|
||||
function randomPlayoutMove(state: BoardState, rng: Rng): Move {
|
||||
for (let attempt = 0; attempt < PLAYOUT_ATTEMPTS; attempt += 1) {
|
||||
const point = {
|
||||
x: Math.floor(rng.next() * state.size),
|
||||
y: Math.floor(rng.next() * state.size),
|
||||
};
|
||||
if (cellAt(state, point) !== 'empty' || fillsOwnEye(state, point)) continue;
|
||||
const move: Move = { kind: 'play', color: state.toPlay, point };
|
||||
if (applyMove(state, move).ok) return move;
|
||||
}
|
||||
return { kind: 'pass', color: state.toPlay };
|
||||
}
|
||||
|
||||
/** Один ход плейаута: реакция на атари или случайный ход; гарантированно легальный. */
|
||||
function playoutStep(
|
||||
state: BoardState,
|
||||
lastPoint: Point | null,
|
||||
rng: Rng,
|
||||
): { readonly move: Move; readonly state: BoardState } {
|
||||
const move =
|
||||
(lastPoint === null ? null : atariReaction(state, lastPoint)) ?? randomPlayoutMove(state, rng);
|
||||
const applied = applyMove(state, move);
|
||||
if (applied.ok) return { move, state: applied.state };
|
||||
const pass: Move = { kind: 'pass', color: state.toPlay };
|
||||
const passed = applyMove(state, pass);
|
||||
return { move: pass, state: passed.ok ? passed.state : state };
|
||||
}
|
||||
|
||||
/** Плейаут до двух пасов или капа size²×2 ходов; оценка — scorePosition с komi. */
|
||||
function playout(state: BoardState, rng: Rng, komi: number): Color | 'draw' {
|
||||
let current = state;
|
||||
let lastPoint: Point | null = null;
|
||||
const cap = state.size * state.size * 2;
|
||||
let moves = 0;
|
||||
while (!current.over && moves < cap) {
|
||||
const step = playoutStep(current, lastPoint, rng);
|
||||
lastPoint = step.move.kind === 'play' ? step.move.point : null;
|
||||
current = step.state;
|
||||
moves += 1;
|
||||
}
|
||||
return scorePosition(current, { komi, dead: new Set<string>() }).winner;
|
||||
}
|
||||
|
||||
/** Раздача результата плейаута по пути: wins — с точки зрения player узла. */
|
||||
function backpropagate(path: readonly MctsNode[], winner: Color | 'draw'): void {
|
||||
for (const node of path) {
|
||||
node.visits += 1;
|
||||
if (winner === node.player) node.wins += 1;
|
||||
else if (winner === 'draw') node.wins += 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/** Одна симуляция: спуск по UCT → ленивая экспансия → плейаут → backprop. */
|
||||
function simulate(root: MctsNode, rng: Rng, komi: number): void {
|
||||
const path: MctsNode[] = [root];
|
||||
let node = root;
|
||||
while (node.untried !== null && node.untried.length === 0 && node.children.length > 0) {
|
||||
node = selectChild(node);
|
||||
path.push(node);
|
||||
}
|
||||
if (!node.state.over) {
|
||||
const child = expand(node);
|
||||
if (child !== null) {
|
||||
node = child;
|
||||
path.push(node);
|
||||
}
|
||||
}
|
||||
backpropagate(path, playout(node.state, rng, komi));
|
||||
}
|
||||
|
||||
/** Условие продолжения цикла симуляций: бюджет, shouldStop, maxSimulations. */
|
||||
function shouldContinue(request: MctsRequest, simulations: number, deadline: number): boolean {
|
||||
if (request.maxSimulations !== undefined && simulations >= request.maxSimulations) return false;
|
||||
if (request.shouldStop?.() === true) return false;
|
||||
if (request.maxSimulations === undefined && request.clock.now() >= deadline) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Легальный запасной ход для случая shouldStop() до первой симуляции. */
|
||||
function fallbackMove(state: BoardState, rng: Rng): Move {
|
||||
const picked = pickUniform(legalCandidates(state), rng);
|
||||
if (picked !== null) return { kind: 'play', color: state.toPlay, point: picked.point };
|
||||
return { kind: 'pass', color: state.toPlay };
|
||||
}
|
||||
|
||||
/** До 3 лучших детей корня по визитам (pass включается, если в топе). */
|
||||
function topMoves(root: MctsNode): CandidateScore[] {
|
||||
return [...root.children]
|
||||
.sort((a, b) => b.visits - a.visits)
|
||||
.slice(0, 3)
|
||||
.map((child) => ({
|
||||
move: child.move as Move,
|
||||
winRate: child.visits === 0 ? 0 : child.wins / child.visits,
|
||||
visits: child.visits,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Выбор хода MCTS. Бюджет: стоп при clock.now() >= deadline ИЛИ shouldStop();
|
||||
* при maxSimulations — ровно N итераций (тайм-бюджет игнорируется).
|
||||
* При shouldStop() до первой завершённой симуляции всё равно возвращается
|
||||
* легальный ход (случайный легальный или пас).
|
||||
* Вызов при state.over === true — программная ошибка: кидает Error.
|
||||
*/
|
||||
export function chooseMoveMcts(request: MctsRequest): MctsResult {
|
||||
const { state, komi, level, rng, clock } = request;
|
||||
if (state.over) throw new Error('chooseMoveMcts: партия уже завершена (state.over === true)');
|
||||
const root: MctsNode = {
|
||||
move: null,
|
||||
state,
|
||||
player: opposite(state.toPlay),
|
||||
visits: 0,
|
||||
wins: 0,
|
||||
children: [],
|
||||
untried: null,
|
||||
};
|
||||
const start = clock.now();
|
||||
const deadline = start + LEVEL_TIME_BUDGET_MS[level];
|
||||
let simulations = 0;
|
||||
while (shouldContinue(request, simulations, deadline)) {
|
||||
simulate(root, rng, komi);
|
||||
simulations += 1;
|
||||
}
|
||||
const best = [...root.children].sort((a, b) => b.visits - a.visits)[0];
|
||||
const move =
|
||||
best !== undefined && best.visits > 0 ? (best.move as Move) : fallbackMove(state, rng);
|
||||
return { move, topMoves: topMoves(root), simulations, elapsedMs: clock.now() - start };
|
||||
}
|
||||
42
packages/ai/src/perf.test.ts
Normal file
42
packages/ai/src/perf.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Perf-smoke: среднее время chooseMove на позиции 19×19 средней игры
|
||||
* (~100 ходов сыграно) по 20 вызовам — ниже порога с запасом (порог
|
||||
* машинно-зависимый; критерий приёмки «ур.3 < 50 мс» проверяется фактическим
|
||||
* замером, число выводится в лог и уходит в отчёт сессии).
|
||||
*/
|
||||
import { applyMove, createBoard, createSeededRng } from '@go-learn/core';
|
||||
import type { BoardState } from '@go-learn/core';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { chooseMove } from './index.js';
|
||||
|
||||
const PERF_THRESHOLD_MS = 200;
|
||||
const SAMPLES = 20;
|
||||
|
||||
/** Позиция 19×19 после ~100 ходов партии бот-бот (ур.3, запиненный seed). */
|
||||
function midgamePosition(size: 19, movesToPlay: number, seed: number): BoardState {
|
||||
const rng = createSeededRng(seed);
|
||||
let state = createBoard(size);
|
||||
for (let played = 0; played < movesToPlay && !state.over; played += 1) {
|
||||
const result = chooseMove({ state, level: 3, rng });
|
||||
const applied = applyMove(state, result.move);
|
||||
if (!applied.ok) throw new Error(`нелегальный ход при разогреве: ${applied.error}`);
|
||||
state = applied.state;
|
||||
}
|
||||
if (state.over) throw new Error('партия завершилась раньше 100 ходов');
|
||||
return state;
|
||||
}
|
||||
|
||||
describe('perf-smoke chooseMove 19×19', () => {
|
||||
it(`среднее время по ${SAMPLES} вызовам < ${PERF_THRESHOLD_MS} мс`, () => {
|
||||
const state = midgamePosition(19, 100, 99);
|
||||
const rng = createSeededRng(7);
|
||||
const start = performance.now();
|
||||
for (let sample = 0; sample < SAMPLES; sample += 1) {
|
||||
chooseMove({ state, level: 3, rng });
|
||||
}
|
||||
const averageMs = (performance.now() - start) / SAMPLES;
|
||||
console.warn(`[perf] chooseMove 19×19 (~100 ходов), среднее: ${averageMs.toFixed(2)} мс`);
|
||||
expect(averageMs).toBeLessThan(PERF_THRESHOLD_MS);
|
||||
});
|
||||
});
|
||||
53
packages/ai/src/pick.ts
Normal file
53
packages/ai/src/pick.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* Случайный выбор кандидата: равномерный и взвешенный.
|
||||
* Единственный источник случайности — переданный Rng.
|
||||
*/
|
||||
import type { Point, Rng } from '@go-learn/core';
|
||||
import type { Candidate } from './candidates.js';
|
||||
|
||||
/** Кандидат с весом для взвешенного выбора. */
|
||||
export interface WeightedCandidate {
|
||||
readonly candidate: Candidate;
|
||||
readonly weight: number;
|
||||
}
|
||||
|
||||
/** Равномерный выбор; null для пустого списка. */
|
||||
export function pickUniform(candidates: readonly Candidate[], rng: Rng): Candidate | null {
|
||||
if (candidates.length === 0) return null;
|
||||
const index = Math.floor(rng.next() * candidates.length);
|
||||
return candidates[index] ?? null;
|
||||
}
|
||||
|
||||
/** Взвешенный выбор пропорционально весам; null для пустого списка. */
|
||||
export function pickWeighted(items: readonly WeightedCandidate[], rng: Rng): Candidate | null {
|
||||
const total = items.reduce((sum, item) => sum + item.weight, 0);
|
||||
if (total <= 0) return null;
|
||||
let roll = rng.next() * total;
|
||||
for (const item of items) {
|
||||
roll -= item.weight;
|
||||
if (roll < 0) return item.candidate;
|
||||
}
|
||||
return items[items.length - 1]?.candidate ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Кандидаты в радиусе Chebyshev ≤ 2 от последнего хода противника.
|
||||
* Вес тем больше, чем ближе точка: соседи (d = 1) — вес 2, вторая линия
|
||||
* (d = 2) — вес 1.
|
||||
*/
|
||||
export function nearbyCandidates(
|
||||
candidates: readonly Candidate[],
|
||||
lastOpponentMove: Point | null,
|
||||
): WeightedCandidate[] {
|
||||
if (lastOpponentMove === null) return [];
|
||||
const result: WeightedCandidate[] = [];
|
||||
for (const candidate of candidates) {
|
||||
const dx = Math.abs(candidate.point.x - lastOpponentMove.x);
|
||||
const dy = Math.abs(candidate.point.y - lastOpponentMove.y);
|
||||
const distance = Math.max(dx, dy);
|
||||
if (distance >= 1 && distance <= 2) {
|
||||
result.push({ candidate, weight: 3 - distance });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
64
packages/ai/src/tactics.ts
Normal file
64
packages/ai/src/tactics.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* Тактика атари: спасение своих групп с одним дамэ (включая контрзахват)
|
||||
* и захват чужих групп с одним дамэ.
|
||||
*/
|
||||
import { cellAt, groupAt, opposite, pointKey } from '@go-learn/core';
|
||||
import type { BoardState, Color, Group, Point } from '@go-learn/core';
|
||||
import type { Candidate } from './candidates.js';
|
||||
import { orthogonalNeighbors } from './candidates.js';
|
||||
|
||||
/** Группы цвета ровно с одним дамэ (в атари). */
|
||||
function groupsInAtari(state: BoardState, color: Color): Group[] {
|
||||
const groups: Group[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (let y = 0; y < state.size; y += 1) {
|
||||
for (let x = 0; x < state.size; x += 1) {
|
||||
const point = { x, y };
|
||||
if (seen.has(pointKey(point)) || cellAt(state, point) !== color) continue;
|
||||
const group = groupAt(state, point);
|
||||
if (group === null) continue;
|
||||
for (const stone of group.stones) seen.add(pointKey(stone));
|
||||
if (group.liberties === 1) groups.push(group);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ход спасает группу из атари, если после него у группы больше одного дамэ.
|
||||
* Охватывает и продление (ход в последнее дамэ), и контрзахват нападающей
|
||||
* группы: проверка делается по факту — по дамэ группы после хода.
|
||||
*/
|
||||
function savesGroup(candidate: Candidate, group: Group): boolean {
|
||||
const stone = group.stones[0];
|
||||
if (stone === undefined) return false;
|
||||
const after = groupAt(candidate.next, stone);
|
||||
return after !== null && after.liberties > 1;
|
||||
}
|
||||
|
||||
/** Кандидаты, спасающие хотя бы одну свою группу из атари. */
|
||||
export function savingMoves(state: BoardState, candidates: readonly Candidate[]): Candidate[] {
|
||||
const inAtari = groupsInAtari(state, state.toPlay);
|
||||
if (inAtari.length === 0) return [];
|
||||
return candidates.filter((candidate) => inAtari.some((group) => savesGroup(candidate, group)));
|
||||
}
|
||||
|
||||
/** Первое найденное дамэ группы (у группы в атари оно единственное). */
|
||||
function soleLiberty(state: BoardState, group: Group): Point | null {
|
||||
for (const stone of group.stones) {
|
||||
for (const neighbor of orthogonalNeighbors(state.size, stone)) {
|
||||
if (cellAt(state, neighbor) === 'empty') return neighbor;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Кандидаты в последнее дамэ чужой группы в атари (захват). */
|
||||
export function captureMoves(state: BoardState, candidates: readonly Candidate[]): Candidate[] {
|
||||
const targets = new Set<string>();
|
||||
for (const group of groupsInAtari(state, opposite(state.toPlay))) {
|
||||
const liberty = soleLiberty(state, group);
|
||||
if (liberty !== null) targets.add(pointKey(liberty));
|
||||
}
|
||||
return candidates.filter((candidate) => targets.has(pointKey(candidate.point)));
|
||||
}
|
||||
81
packages/ai/src/test-utils.ts
Normal file
81
packages/ai/src/test-utils.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* Тестовые помощники: построение позиции из ASCII и прогон партии бот-бот.
|
||||
* В публичный API (index.ts) не экспортируются.
|
||||
*/
|
||||
import { applyMove, createBoard, createSeededRng } from '@go-learn/core';
|
||||
import type { BoardSize, BoardState, CellState, Color, Move, Point } from '@go-learn/core';
|
||||
import type { AiLevel } from './ai.js';
|
||||
import { chooseMove } from './ai.js';
|
||||
|
||||
/**
|
||||
* Позиция из ASCII-строк: 'X' — чёрные, 'O' — белые, '.' — пусто.
|
||||
* positionHashes пуст: для сконструированных позиций суперко-проверка
|
||||
* против истории не нужна (повторов «пустого» прошлого быть не может).
|
||||
*/
|
||||
export function stateFromAscii(
|
||||
size: BoardSize,
|
||||
toPlay: Color,
|
||||
rows: readonly string[],
|
||||
): BoardState {
|
||||
if (rows.length !== size) throw new Error(`нужно ровно ${size} строк`);
|
||||
const grid: CellState[] = [];
|
||||
for (const row of rows) {
|
||||
if (row.length !== size) throw new Error(`строка должна быть длины ${size}: "${row}"`);
|
||||
for (const char of row) {
|
||||
if (char === 'X') grid.push('black');
|
||||
else if (char === 'O') grid.push('white');
|
||||
else if (char === '.') grid.push('empty');
|
||||
else throw new Error(`неизвестный символ клетки: "${char}"`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
size,
|
||||
grid,
|
||||
toPlay,
|
||||
captures: { black: 0, white: 0 },
|
||||
koPoint: null,
|
||||
positionHashes: [],
|
||||
over: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Запись партии бот-бот: журнал ходов и финальное состояние. */
|
||||
export interface BotGameRecord {
|
||||
readonly moves: readonly string[];
|
||||
readonly finalState: BoardState;
|
||||
}
|
||||
|
||||
/** Компактная запись хода для сравнения партий. */
|
||||
function formatMove(move: Move): string {
|
||||
return move.kind === 'play' ? `${move.point.x},${move.point.y}` : move.kind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Партия бот-бот от пустой доски до over. Каждый ход бота применяется через
|
||||
* applyMove: нелегальный ход или превышение maxMoves — ошибка теста.
|
||||
*/
|
||||
export function playBotGame(
|
||||
seed: number,
|
||||
level: AiLevel,
|
||||
size: BoardSize,
|
||||
maxMoves: number,
|
||||
): BotGameRecord {
|
||||
const rng = createSeededRng(seed);
|
||||
let state = createBoard(size);
|
||||
const moves: string[] = [];
|
||||
let lastOpponentMove: Point | null = null;
|
||||
while (!state.over) {
|
||||
if (moves.length >= maxMoves) {
|
||||
throw new Error(`партия (seed ${seed}) не завершилась за ${maxMoves} ходов`);
|
||||
}
|
||||
const result = chooseMove({ state, level, rng, lastOpponentMove });
|
||||
const applied = applyMove(state, result.move);
|
||||
if (!applied.ok) {
|
||||
throw new Error(`бот сделал нелегальный ход (seed ${seed}): ${applied.error}`);
|
||||
}
|
||||
moves.push(formatMove(result.move));
|
||||
if (result.move.kind === 'play') lastOpponentMove = result.move.point;
|
||||
state = applied.state;
|
||||
}
|
||||
return { moves, finalState: state };
|
||||
}
|
||||
51
packages/ai/src/wiring.test.ts
Normal file
51
packages/ai/src/wiring.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Wiring-тест (gotcha-green-modules-dead-system): orphan-check публичного API
|
||||
* packages/ai — каждая экспортируемая сущность упоминается хотя бы раз вне
|
||||
* своего определения (в другом модуле пакета или в тесте).
|
||||
*/
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const srcDir = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
return readdirSync(srcDir)
|
||||
.filter((name) => name.endsWith('.ts'))
|
||||
.map((name) => join(srcDir, name));
|
||||
}
|
||||
|
||||
/** Имена, экспортируемые из index.ts (типы пропускаем). */
|
||||
function exportedNames(): string[] {
|
||||
const index = readFileSync(join(srcDir, 'index.ts'), 'utf8');
|
||||
const names: string[] = [];
|
||||
const pattern = /export\s+(?!type)\{([^}]*)\}\s*from/g;
|
||||
for (const match of index.matchAll(pattern)) {
|
||||
const body = match[1] ?? '';
|
||||
for (const part of body.split(',')) {
|
||||
const name = part.trim();
|
||||
if (name !== '') names.push(name);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
describe('orphan-check: публичный API ИИ связан с кодом', () => {
|
||||
it('каждый экспорт упоминается вне своего определения', () => {
|
||||
const files = sourceFiles().filter((file) => !file.endsWith('index.ts'));
|
||||
const contents = files.map((file) => readFileSync(file, 'utf8'));
|
||||
const missing: string[] = [];
|
||||
for (const name of exportedNames()) {
|
||||
const usage = new RegExp(`\\b${name}\\b`);
|
||||
const mentions = contents.filter((content) => usage.test(content)).length;
|
||||
// Определение + минимум одно использование — минимум два файла;
|
||||
// упоминание в любом *.test.ts тоже засчитывается.
|
||||
const inTests = contents.some(
|
||||
(content, index) => files[index]?.endsWith('.test.ts') === true && usage.test(content),
|
||||
);
|
||||
if (mentions < 2 && !inTests) missing.push(name);
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
63
packages/ai/src/worker-protocol.ts
Normal file
63
packages/ai/src/worker-protocol.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* Типы протокола postMessage воркера ИИ ур. 4–6 (контракт —
|
||||
* docs/INTERFACES.md, «Протокол postMessage воркера»). Протокол stateless:
|
||||
* каждый choose несёт полный список ходов партии от пустой доски; воркер
|
||||
* пересобирает BoardState через applyMove и валидирует каждый ход.
|
||||
*/
|
||||
import { applyMove, createBoard } from '@go-learn/core';
|
||||
import type { BoardSize, BoardState, Move } from '@go-learn/core';
|
||||
import type { CandidateScore, MctsLevel } from './mcts.js';
|
||||
|
||||
export interface WorkerChooseRequest {
|
||||
readonly type: 'choose';
|
||||
readonly requestId: number;
|
||||
readonly size: BoardSize;
|
||||
readonly komi: number;
|
||||
readonly moves: readonly Move[]; // вся партия от пустой доски
|
||||
readonly level: MctsLevel;
|
||||
readonly seed: number; // seed воркера (партия + requestId — забота UI)
|
||||
readonly timeBudgetMs: number;
|
||||
}
|
||||
|
||||
export interface WorkerCancelRequest {
|
||||
readonly type: 'cancel';
|
||||
readonly requestId: number;
|
||||
}
|
||||
|
||||
export type WorkerRequest = WorkerChooseRequest | WorkerCancelRequest;
|
||||
|
||||
export type WorkerResponse =
|
||||
| {
|
||||
readonly type: 'result';
|
||||
readonly requestId: number;
|
||||
readonly move: Move;
|
||||
readonly topMoves: readonly CandidateScore[];
|
||||
readonly simulations: number;
|
||||
readonly elapsedMs: number;
|
||||
}
|
||||
| { readonly type: 'error'; readonly requestId: number; readonly message: string };
|
||||
|
||||
/** Результат пересборки позиции из списка ходов. */
|
||||
export type RebuildResult =
|
||||
| { readonly ok: true; readonly state: BoardState }
|
||||
| { readonly ok: false; readonly moveIndex: number; readonly message: string };
|
||||
|
||||
/**
|
||||
* Пересборка позиции: createBoard(size) + applyMove по списку ходов.
|
||||
* Нелегальный ход → ok: false с индексом хода (0-based) и сообщением ядра.
|
||||
*/
|
||||
export function rebuildGameState(size: BoardSize, moves: readonly Move[]): RebuildResult {
|
||||
let state = createBoard(size);
|
||||
for (let index = 0; index < moves.length; index += 1) {
|
||||
const applied = applyMove(state, moves[index] as Move);
|
||||
if (!applied.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
moveIndex: index,
|
||||
message: `ход #${index + 1} нелегален: ${applied.error}`,
|
||||
};
|
||||
}
|
||||
state = applied.state;
|
||||
}
|
||||
return { ok: true, state };
|
||||
}
|
||||
130
packages/ai/src/worker.test.ts
Normal file
130
packages/ai/src/worker.test.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* Тесты протокола воркера через фейк-порт (без реального Worker):
|
||||
* choose → result с тем же requestId; нелегальный список → error; cancel
|
||||
* активного → result с лучшим найденным; cancel чужого id → без эффекта.
|
||||
*/
|
||||
import type { Clock } from '@go-learn/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { WorkerChooseRequest, WorkerRequest, WorkerResponse } from './worker-protocol.js';
|
||||
import type { MessagePort } from './worker.js';
|
||||
import { createWorkerHandler } from './worker.js';
|
||||
|
||||
/** Фейк-порт: собирает все ответы воркера. */
|
||||
function fakePort(): { port: MessagePort; messages: WorkerResponse[] } {
|
||||
const messages: WorkerResponse[] = [];
|
||||
return { port: { postMessage: (msg) => messages.push(msg) }, messages };
|
||||
}
|
||||
|
||||
/** Базовый choose-запрос с переопределениями. */
|
||||
function chooseMsg(overrides: Partial<WorkerChooseRequest> = {}): WorkerChooseRequest {
|
||||
return {
|
||||
type: 'choose',
|
||||
requestId: 1,
|
||||
size: 9,
|
||||
komi: 5.5,
|
||||
moves: [],
|
||||
level: 4,
|
||||
seed: 42,
|
||||
timeBudgetMs: 300,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Часы-счётчик: +100 мс на каждый вызов now() — остановка по deadline. */
|
||||
function countingClock(onCall?: (calls: number) => void): Clock {
|
||||
let t = 0;
|
||||
let calls = 0;
|
||||
return {
|
||||
now: (): number => {
|
||||
calls += 1;
|
||||
onCall?.(calls);
|
||||
return (t += 100);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('worker-протокол через фейк-порт', () => {
|
||||
it('choose → result с тем же requestId и легальным ходом', () => {
|
||||
const { port, messages } = fakePort();
|
||||
const handler = createWorkerHandler(port, { clock: countingClock() });
|
||||
handler(
|
||||
chooseMsg({
|
||||
moves: [
|
||||
{ kind: 'play', color: 'black', point: { x: 2, y: 2 } },
|
||||
{ kind: 'play', color: 'white', point: { x: 6, y: 6 } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(messages).toHaveLength(1);
|
||||
const response = messages[0];
|
||||
expect(response?.type).toBe('result');
|
||||
expect(response?.requestId).toBe(1);
|
||||
if (response?.type === 'result') {
|
||||
expect(response.simulations).toBeGreaterThanOrEqual(1);
|
||||
expect(response.topMoves.length).toBeLessThanOrEqual(3);
|
||||
}
|
||||
});
|
||||
|
||||
it('нелегальный ход в списке → error с номером хода', () => {
|
||||
const { port, messages } = fakePort();
|
||||
const handler = createWorkerHandler(port, { clock: countingClock() });
|
||||
handler(
|
||||
chooseMsg({
|
||||
moves: [
|
||||
{ kind: 'play', color: 'black', point: { x: 2, y: 2 } },
|
||||
{ kind: 'play', color: 'white', point: { x: 2, y: 2 } }, // занято
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(messages).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'error',
|
||||
requestId: 1,
|
||||
message: expect.stringContaining('#2'),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('список ходов до конца партии → error (партия завершена)', () => {
|
||||
const { port, messages } = fakePort();
|
||||
const handler = createWorkerHandler(port, { clock: countingClock() });
|
||||
handler(
|
||||
chooseMsg({
|
||||
moves: [
|
||||
{ kind: 'pass', color: 'black' },
|
||||
{ kind: 'pass', color: 'white' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(messages[0]?.type).toBe('error');
|
||||
});
|
||||
|
||||
it('cancel активного requestId → result с лучшим найденным (не error)', () => {
|
||||
const { port, messages } = fakePort();
|
||||
let handler: (msg: WorkerRequest) => void;
|
||||
// Реентерабельная отмена: на 3-м вызове now() (внутри счёта) шлём cancel.
|
||||
const clock = countingClock((calls) => {
|
||||
if (calls === 3) handler({ type: 'cancel', requestId: 7 });
|
||||
});
|
||||
handler = createWorkerHandler(port, { clock });
|
||||
handler(chooseMsg({ requestId: 7, timeBudgetMs: 1_000_000 })); // «бесконечный» бюджет
|
||||
expect(messages).toHaveLength(1);
|
||||
const response = messages[0];
|
||||
expect(response?.type).toBe('result');
|
||||
expect(response?.requestId).toBe(7);
|
||||
if (response?.type === 'result') expect(response.simulations).toBeLessThanOrEqual(5);
|
||||
});
|
||||
|
||||
it('cancel чужого requestId → без эффекта: choose завершается обычным result', () => {
|
||||
const { port, messages } = fakePort();
|
||||
const handler = createWorkerHandler(port, { clock: countingClock() });
|
||||
handler({ type: 'cancel', requestId: 999 });
|
||||
expect(messages).toHaveLength(0); // cancel сам по себе не порождает ответа
|
||||
handler(chooseMsg({ requestId: 2 }));
|
||||
expect(messages).toHaveLength(1);
|
||||
const response = messages[0];
|
||||
expect(response?.type).toBe('result');
|
||||
expect(response?.requestId).toBe(2);
|
||||
if (response?.type === 'result') expect(response.simulations).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
109
packages/ai/src/worker.ts
Normal file
109
packages/ai/src/worker.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* Тонкая воркер-обёртка MCTS (контракт — docs/INTERFACES.md, «Протокол
|
||||
* postMessage воркера»): парсинг → пересборка позиции → chooseMoveMcts →
|
||||
* ответ. Абстракция порта позволяет тестировать протокол фейк-портом без
|
||||
* реального Worker. Точку входа браузера (self.onmessage) подключает UI
|
||||
* этапа 5: `self.onmessage = (e) => createWorkerHandler(self)(e.data)`.
|
||||
*
|
||||
* cancel активного requestId → вычисление прерывается через shouldStop и
|
||||
* воркер отвечает result с лучшим найденным (НЕ error, НЕ молчание);
|
||||
* cancel чужого id игнорируется. Замечание о реальном Worker: обработчик
|
||||
* синхронный, поэтому cancel, пришедший во время счёта, будет обработан
|
||||
* event loop'ом после result — фактическая отмена в браузере возможна,
|
||||
* только если UI пришлёт cancel до choose или движок будет крутиться
|
||||
* ломтиками (этап 5+); семантика протокола здесь реализована полностью.
|
||||
*/
|
||||
import { createSeededRng } from '@go-learn/core';
|
||||
import type { Clock } from '@go-learn/core';
|
||||
import { chooseMoveMcts, LEVEL_TIME_BUDGET_MS } from './mcts.js';
|
||||
import type { MctsLevel } from './mcts.js';
|
||||
import type { WorkerChooseRequest, WorkerRequest, WorkerResponse } from './worker-protocol.js';
|
||||
import { rebuildGameState } from './worker-protocol.js';
|
||||
|
||||
/** Минимальный интерфейс порта: ровно то, что нужно обработчику. */
|
||||
export interface MessagePort {
|
||||
postMessage(msg: WorkerResponse): void;
|
||||
}
|
||||
|
||||
/** Инжектируемые зависимости обёртки (в тестах — фейк-часы). */
|
||||
export interface WorkerDeps {
|
||||
readonly clock?: Clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Часы, отображающие timeBudgetMs запроса на бюджет уровня движка: движок
|
||||
* останавливается по LEVEL_TIME_BUDGET_MS[level] своих часов, поэтому шкалу
|
||||
* растягиваем так, чтобы фактический бюджет был timeBudgetMs. При равенстве
|
||||
* бюджетов масштаб 1 — часы проходят без искажений.
|
||||
*/
|
||||
function budgetClock(base: Clock, level: MctsLevel, timeBudgetMs: number): Clock {
|
||||
const levelBudget = LEVEL_TIME_BUDGET_MS[level];
|
||||
if (timeBudgetMs === levelBudget) return base;
|
||||
const scale = timeBudgetMs > 0 ? levelBudget / timeBudgetMs : Infinity;
|
||||
const start = base.now();
|
||||
return { now: () => start + (base.now() - start) * scale };
|
||||
}
|
||||
|
||||
/** Обработка choose: пересборка, вычисление, ответ result/error. */
|
||||
function handleChoose(
|
||||
msg: WorkerChooseRequest,
|
||||
port: MessagePort,
|
||||
baseClock: Clock,
|
||||
isCancelled: () => boolean,
|
||||
): void {
|
||||
const rebuilt = rebuildGameState(msg.size, msg.moves);
|
||||
if (!rebuilt.ok) {
|
||||
port.postMessage({ type: 'error', requestId: msg.requestId, message: rebuilt.message });
|
||||
return;
|
||||
}
|
||||
if (rebuilt.state.over) {
|
||||
port.postMessage({
|
||||
type: 'error',
|
||||
requestId: msg.requestId,
|
||||
message: 'партия уже завершена (state.over === true)',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const clock = budgetClock(baseClock, msg.level, msg.timeBudgetMs);
|
||||
const result = chooseMoveMcts({
|
||||
state: rebuilt.state,
|
||||
komi: msg.komi,
|
||||
level: msg.level,
|
||||
rng: createSeededRng(msg.seed),
|
||||
clock,
|
||||
shouldStop: isCancelled,
|
||||
});
|
||||
port.postMessage({
|
||||
type: 'result',
|
||||
requestId: msg.requestId,
|
||||
move: result.move,
|
||||
topMoves: result.topMoves,
|
||||
simulations: result.simulations,
|
||||
elapsedMs: result.elapsedMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик сообщений воркера: choose → result/error, cancel активного
|
||||
* requestId → флаг отмены (движок остановится и вернёт лучший найденный).
|
||||
*/
|
||||
export function createWorkerHandler(
|
||||
port: MessagePort,
|
||||
deps: WorkerDeps = {},
|
||||
): (msg: WorkerRequest) => void {
|
||||
const baseClock: Clock = deps.clock ?? { now: () => performance.now() };
|
||||
let active: { readonly requestId: number; cancelled: boolean } | null = null;
|
||||
return (msg: WorkerRequest): void => {
|
||||
if (msg.type === 'cancel') {
|
||||
if (active !== null && active.requestId === msg.requestId) active.cancelled = true;
|
||||
return;
|
||||
}
|
||||
const current = { requestId: msg.requestId, cancelled: false };
|
||||
active = current;
|
||||
try {
|
||||
handleChoose(msg, port, baseClock, () => current.cancelled);
|
||||
} finally {
|
||||
active = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
16
packages/ai/tsconfig.json
Normal file
16
packages/ai/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
// Намеренно БЕЗ references на ../core: гейт typecheck — `tsc -b --noEmit`,
|
||||
// а TS 5.9 в build-режиме с --noEmit отклоняет любую цепочку project
|
||||
// references с чистого состояния (TS6310 "may not disable emit" — CLI-флаг
|
||||
// распространяется на referenced-проект). Импорт @go-learn/core резолвится
|
||||
// через workspace-симлинк (main: src/index.ts) — типы видны и так.
|
||||
// Корневой tsconfig.json по-прежнему ссылается на packages/ai.
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue