chore: восстановление репозитория из снапшота v0.3.1

Прежняя git-история утрачена при переносе проекта на машину владельца
(снапшот без .git). Хэши коммитов в docs/reports/* относятся к утраченной
истории.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
sab.code.lab 2026-08-07 09:34:02 +02:00
commit 1c85091186
184 changed files with 33303 additions and 0 deletions

14
packages/ai/package.json Normal file
View file

@ -0,0 +1,14 @@
{
"name": "@go-learn/ai",
"version": "0.1.0",
"private": true,
"description": "Эвристический ИИ ур. 13: приоритеты атари (спасение/захват), ходы вблизи хода противника, фильтр своего глаза. Синхронный чистый вычислитель: случайность только через инжектируемый 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
View file

@ -0,0 +1,98 @@
/**
* Эвристический ИИ ур. 13 (контракт 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 на ур. 13 не генерируется
readonly reason: AiReason;
}
/** Результат-постановка камня. */
function playResult(state: BoardState, candidate: Candidate, reason: AiReason): AiMoveResult {
return {
move: { kind: 'play', color: state.toPlay, point: candidate.point },
reason,
};
}
/** Приоритеты 12: тактика атари с роллами вероятности уровня. */
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' };
}

View 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;
}

View 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);
});
});

View 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
View file

@ -0,0 +1,18 @@
/**
* Публичный API ИИ ур. 16 (контракт 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';

View 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
View file

@ -0,0 +1,301 @@
/**
* MCTS-движок ИИ ур. 46 (контракт docs/INTERFACES.md, раздел «Контракт
* MCTS ИИ ур. 46»). Чистый синхронный вычислитель: без 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 };
}

View 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
View 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;
}

View 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)));
}

View 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 };
}

View 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([]);
});
});

View file

@ -0,0 +1,63 @@
/**
* Типы протокола postMessage воркера ИИ ур. 46 (контракт
* 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 };
}

View 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
View 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
View 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"]
}