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

View file

@ -0,0 +1,14 @@
{
"name": "@go-learn/board",
"version": "0.1.0",
"private": true,
"description": "Отрисовка доски Го на Canvas 2D: чистая геометрия, рендер состояния, редьюсер тач-ввода с подтверждением, тонкий DOM-адаптер.",
"type": "module",
"main": "src/index.ts",
"scripts": {
"test": "vitest run"
},
"dependencies": {
"@go-learn/core": "0.1.0"
}
}

43
packages/board/src/dom.ts Normal file
View file

@ -0,0 +1,43 @@
/**
* Тонкий DOM-адаптер: настройка канвы под devicePixelRatio и перевод
* pointer-событий в InputEvent. Логики здесь нет вся в geometry/input.
* Юнит-тестами не покрывается (проверка смоук на этапе 5).
*/
import type { InputEvent } from './input.js';
/**
* Выставляет canvas.width/height = cssSize × devicePixelRatio и
* ctx.setTransform(dpr, ), чтобы рисовать в логических CSS px.
* Возвращает применённый dpr.
*/
export function setupCanvas(canvas: HTMLCanvasElement, cssSize: number): number {
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(cssSize * dpr);
canvas.height = Math.round(cssSize * dpr);
canvas.style.width = `${cssSize}px`;
canvas.style.height = `${cssSize}px`;
canvas.getContext('2d')?.setTransform(dpr, 0, 0, dpr, 0, 0);
return dpr;
}
/**
* Подписывается на pointerdown канвы и вызывает onEvent с событием 'tap'
* в координатах канвы (CSS px). Возвращает функцию отписки.
*/
export function attachPointerInput(
canvas: HTMLCanvasElement,
onEvent: (event: InputEvent) => void,
): () => void {
const handler = (event: PointerEvent): void => {
const rect = canvas.getBoundingClientRect();
onEvent({
type: 'tap',
px: event.clientX - rect.left,
py: event.clientY - rect.top,
});
};
canvas.addEventListener('pointerdown', handler);
return (): void => {
canvas.removeEventListener('pointerdown', handler);
};
}

View file

@ -0,0 +1,124 @@
/**
* Тесты геометрии: хоси, roundtrip pixelToPointpointToPixel, отклонение
* курсора дальше половины клетки, поля с координатами и без.
*/
import { describe, expect, it } from 'vitest';
import type { BoardSize, Point } from '@go-learn/core';
import { computeGeometry, hoshiPoints, pixelToPoint, pointToPixel } from './geometry.js';
function hasPoint(points: readonly Point[], x: number, y: number): boolean {
return points.some((p) => p.x === x && p.y === y);
}
describe('hoshiPoints', () => {
it('19×19: 9 точек, углы на линиях 3-3 (0-based)', () => {
const points = hoshiPoints(19);
expect(points).toHaveLength(9);
for (const x of [3, 9, 15]) {
for (const y of [3, 9, 15]) expect(hasPoint(points, x, y)).toBe(true);
}
});
it('13×13: 5 точек — углы 3-3 и центр', () => {
const points = hoshiPoints(13);
expect(points).toHaveLength(5);
for (const [x, y] of [
[3, 3],
[9, 3],
[6, 6],
[3, 9],
[9, 9],
]) {
expect(hasPoint(points, x ?? 0, y ?? 0)).toBe(true);
}
});
it('9×9: 5 точек — углы 2-2 и центр', () => {
const points = hoshiPoints(9);
expect(points).toHaveLength(5);
for (const [x, y] of [
[2, 2],
[6, 2],
[4, 4],
[2, 6],
[6, 6],
]) {
expect(hasPoint(points, x ?? 0, y ?? 0)).toBe(true);
}
});
});
describe('computeGeometry', () => {
it('сетка + два поля ровно заполняют квадрат канвы', () => {
for (const size of [9, 13, 19] as const) {
for (const show of [true, false]) {
const geo = computeGeometry(size, 600, show);
expect(geo.padding * 2 + geo.cell * (size - 1)).toBeCloseTo(600, 10);
}
}
});
it('padding с координатами больше, чем без', () => {
const withCoords = computeGeometry(19, 600, true);
const without = computeGeometry(19, 600, false);
expect(withCoords.padding).toBeGreaterThan(without.padding);
});
it('крайний камень не обрезается: padding ≥ cell/2', () => {
for (const size of [9, 13, 19] as const) {
expect(computeGeometry(size, 300, false).padding).toBeGreaterThanOrEqual(
computeGeometry(size, 300, false).cell / 2,
);
}
});
});
describe('roundtrip pixelToPoint∘pointToPixel', () => {
it('каждое пересечение возвращается в себя', () => {
for (const size of [9, 13, 19] as const) {
const geo = computeGeometry(size, 570, true);
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
const px = pointToPixel(geo, { x, y });
expect(pixelToPoint(geo, px.x, px.y)).toEqual({ x, y });
}
}
}
});
it('сдвиг меньше половины клетки — ближайшее пересечение', () => {
const geo = computeGeometry(9, 450, false);
const center = pointToPixel(geo, { x: 4, y: 4 });
const hit = pixelToPoint(geo, center.x + geo.cell * 0.4, center.y - geo.cell * 0.4);
expect(hit).toEqual({ x: 4, y: 4 });
});
});
describe('pixelToPoint: отклонения', () => {
const geo = computeGeometry(9, 450, false);
it('дальше половины клетки за краем доски — null', () => {
const corner = pointToPixel(geo, { x: 8, y: 8 });
expect(pixelToPoint(geo, corner.x + geo.cell * 0.6, corner.y)).toBeNull();
expect(pixelToPoint(geo, corner.x, corner.y + geo.cell * 0.6)).toBeNull();
});
it('за пределами канвы — null', () => {
expect(pixelToPoint(geo, -10, 100)).toBeNull();
expect(pixelToPoint(geo, 100, geo.pixelSize + 10)).toBeNull();
});
it('в пределах половины клетки от крайнего пересечения — не null', () => {
const edge = pointToPixel(geo, { x: 8, y: 0 });
expect(pixelToPoint(geo, edge.x + geo.cell * 0.4, edge.y)).toEqual({ x: 8, y: 0 });
});
});
describe('размер доски в геометрии', () => {
it('size пробрасывается из аргумента', () => {
const sizes: BoardSize[] = [9, 13, 19];
for (const size of sizes) {
expect(computeGeometry(size, 500, true).size).toBe(size);
}
});
});

View file

@ -0,0 +1,71 @@
/**
* Геометрия доски: перевод пересечений в пиксели канвы и обратно.
* Чистый модуль: без DOM, без состояния, без Date.now()/Math.random().
*
* Канва логический квадрат pixelSize×pixelSize (CSS px); сетка занимает
* (size1)·cell, остаток поровну уходит в поля padding с четырёх сторон.
*/
import type { BoardSize, Point } from '@go-learn/core';
export interface BoardGeometry {
readonly size: BoardSize;
readonly pixelSize: number; // логический квадрат канвы в CSS px
readonly padding: number; // поля (под координаты, если включены)
readonly cell: number; // шаг сетки в CSS px
}
/**
* Геометрия доски под квадрат pixelSize.
* Поля считаются от шага сетки: с координатами целая клетка (место под
* буквы/числа), без полклетки с запасом, чтобы крайние камни не резались.
*/
export function computeGeometry(
size: BoardSize,
pixelSize: number,
showCoordinates: boolean,
): BoardGeometry {
const marginCells = showCoordinates ? 1 : 0.55;
const cell = pixelSize / (size - 1 + marginCells * 2);
const padding = marginCells * cell;
return { size, pixelSize, padding, cell };
}
/** Центр пересечения в CSS px (от левого верхнего угла канвы). */
export function pointToPixel(geo: BoardGeometry, p: Point): { x: number; y: number } {
return { x: geo.padding + p.x * geo.cell, y: geo.padding + p.y * geo.cell };
}
/**
* Ближайшее пересечение к точке касания; null курсор вне доски либо
* дальше половины клетки от любого пересечения.
*/
export function pixelToPoint(geo: BoardGeometry, px: number, py: number): Point | null {
const x = Math.round((px - geo.padding) / geo.cell);
const y = Math.round((py - geo.padding) / geo.cell);
if (x < 0 || x >= geo.size || y < 0 || y >= geo.size) return null;
const dx = Math.abs(px - (geo.padding + x * geo.cell));
const dy = Math.abs(py - (geo.padding + y * geo.cell));
if (dx > geo.cell / 2 || dy > geo.cell / 2) return null;
return { x, y };
}
/**
* Хоси (звёздные пункты): 19×19 9 точек (линии 3/9/15 в 0-based),
* 13×13 и 9×9 по 5 (углы + центр).
*/
export function hoshiPoints(size: BoardSize): readonly Point[] {
if (size === 19) {
const lines = [3, 9, 15];
return lines.flatMap((y) => lines.map((x) => ({ x, y })));
}
const edge = size === 13 ? 3 : 2;
const center = (size - 1) / 2;
const far = size - 1 - edge;
return [
{ x: edge, y: edge },
{ x: far, y: edge },
{ x: center, y: center },
{ x: edge, y: far },
{ x: far, y: far },
];
}

View file

@ -0,0 +1,13 @@
/**
* Публичный API пакета доски (контракт docs/INTERFACES.md).
* Геометрия, ввод и рендер чистые функции без DOM; DOM только dom.ts.
* Типа PointKey в @go-learn/core нет: ключи множеств/карт string формата
* pointKey ("x,y"), см. комментарии у RenderOptions.
*/
export type { BoardGeometry } from './geometry.js';
export { computeGeometry, hoshiPoints, pixelToPoint, pointToPixel } from './geometry.js';
export type { BoardTheme, RenderOptions, TerritoryMap } from './render.js';
export { DEAD_STONE_ALPHA, render } from './render.js';
export type { InputEvent, InputResult, InputState } from './input.js';
export { reduceInput } from './input.js';
export { attachPointerInput, setupCanvas } from './dom.js';

View file

@ -0,0 +1,79 @@
/**
* Тесты редьюсера тач-ввода: вся таблица переходов.
* tap pixelToPoint; pending/commit/cancel/перенос по контракту.
*/
import { describe, expect, it } from 'vitest';
import type { Point } from '@go-learn/core';
import { computeGeometry, pointToPixel } from './geometry.js';
import type { InputEvent, InputState } from './input.js';
import { reduceInput } from './input.js';
const geo = computeGeometry(9, 450, false);
const IDLE: InputState = { pending: null };
function tap(point: Point): InputEvent {
const { x, y } = pointToPixel(geo, point);
return { type: 'tap', px: x, py: y };
}
const A: Point = { x: 3, y: 3 };
const B: Point = { x: 5, y: 6 };
describe('reduceInput: tap', () => {
it('тап при pending=null → pending=точка, commit=null', () => {
const result = reduceInput(IDLE, tap(A), geo);
expect(result.commit).toBeNull();
expect(result.state.pending).toEqual(A);
});
it('повторный тап по той же точке → commit=точка, pending=null', () => {
const result = reduceInput({ pending: A }, tap(A), geo);
expect(result.commit).toEqual(A);
expect(result.state.pending).toBeNull();
});
it('тап по другой точке → перенос pending, commit=null', () => {
const result = reduceInput({ pending: A }, tap(B), geo);
expect(result.commit).toBeNull();
expect(result.state.pending).toEqual(B);
});
it('тап мимо доски → состояние не меняется, commit=null', () => {
const miss: InputEvent = { type: 'tap', px: -20, py: 450 + 20 };
const fromIdle = reduceInput(IDLE, miss, geo);
expect(fromIdle.state).toBe(IDLE);
expect(fromIdle.commit).toBeNull();
const withPending: InputState = { pending: A };
const fromPending = reduceInput(withPending, miss, geo);
expect(fromPending.state).toBe(withPending);
expect(fromPending.commit).toBeNull();
});
});
describe('reduceInput: confirm', () => {
it('confirm при pending → commit=pending, pending=null', () => {
const result = reduceInput({ pending: A }, { type: 'confirm' }, geo);
expect(result.commit).toEqual(A);
expect(result.state.pending).toBeNull();
});
it('confirm без pending → commit=null, pending=null', () => {
const result = reduceInput(IDLE, { type: 'confirm' }, geo);
expect(result.commit).toBeNull();
expect(result.state.pending).toBeNull();
});
});
describe('reduceInput: cancel', () => {
it('cancel при pending → сброс, commit=null', () => {
const result = reduceInput({ pending: A }, { type: 'cancel' }, geo);
expect(result.commit).toBeNull();
expect(result.state.pending).toBeNull();
});
it('cancel без pending → commit=null, pending=null', () => {
const result = reduceInput(IDLE, { type: 'cancel' }, geo);
expect(result.commit).toBeNull();
expect(result.state.pending).toBeNull();
});
});

View file

@ -0,0 +1,38 @@
/**
* Тач-ввод с подтверждением: чистый редьюсер, без DOM.
* Первый тап фантом (pending); второй тап по той же точке или confirm
* ход (commit); тап по другой точке перенос фантома; cancel сброс.
*/
import type { Point } from '@go-learn/core';
import type { BoardGeometry } from './geometry.js';
import { pixelToPoint } from './geometry.js';
export type InputEvent =
| { readonly type: 'tap'; readonly px: number; readonly py: number }
| { readonly type: 'confirm' }
| { readonly type: 'cancel' };
export interface InputState {
readonly pending: Point | null;
}
export interface InputResult {
readonly state: InputState;
readonly commit: Point | null; // не-null — пользователь подтвердил ход
}
function samePoint(a: Point, b: Point): boolean {
return a.x === b.x && a.y === b.y;
}
/** Единственная точка изменения состояния ввода; неизменяемый переход. */
export function reduceInput(state: InputState, event: InputEvent, geo: BoardGeometry): InputResult {
if (event.type === 'cancel') return { state: { pending: null }, commit: null };
if (event.type === 'confirm') return { state: { pending: null }, commit: state.pending };
const point = pixelToPoint(geo, event.px, event.py);
if (point === null) return { state, commit: null };
if (state.pending !== null && samePoint(state.pending, point)) {
return { state: { pending: null }, commit: point };
}
return { state: { pending: point }, commit: null };
}

View file

@ -0,0 +1,254 @@
/**
* Тесты рендера: мок CanvasRenderingContext2D с записью вызовов.
* Проверяем только инварианты, не каждый вызов:
* число камней = числу непустых клеток BoardState;
* фантом рисуется с globalAlpha = phantomOpacity;
* метка последнего хода только при lastMove;
* подписи LB и фигуры TR/SQ/CR приходят из markup;
* fillRect-заливка только фон и territory-ключи;
* мёртвые камни рисуются с пониженной альфой (DEAD_STONE_ALPHA).
*/
import { describe, expect, it } from 'vitest';
import type { BoardState, NodeMarkup, Point } from '@go-learn/core';
import { applyMove, createBoard } from '@go-learn/core';
import type { BoardTheme, RenderOptions } from './render.js';
import { DEAD_STONE_ALPHA, render } from './render.js';
interface PathOp {
readonly kind: 'arc' | 'moveTo' | 'lineTo';
}
interface PaintRecord {
readonly color: string;
readonly globalAlpha: number;
readonly path: readonly PathOp[];
}
interface RectRecord {
readonly x: number;
readonly y: number;
readonly w: number;
readonly h: number;
readonly color: string;
}
interface TextRecord {
readonly text: string;
readonly x: number;
readonly y: number;
readonly color: string;
readonly font: string;
}
/** Рекордер вызовов CanvasRenderingContext2D (только нужный рендеру набор). */
class CtxRecorder {
fillStyle = '#000000';
strokeStyle = '#000000';
globalAlpha = 1;
lineWidth = 1;
font = '10px sans-serif';
textAlign = 'start';
textBaseline = 'alphabetic';
readonly canvas = { width: 600, height: 600 } as HTMLCanvasElement;
readonly fills: PaintRecord[] = [];
readonly strokes: PaintRecord[] = [];
readonly fillRects: RectRecord[] = [];
readonly strokeRects: RectRecord[] = [];
readonly texts: TextRecord[] = [];
saves = 0;
restores = 0;
private path: PathOp[] = [];
private readonly stack: number[] = [];
save(): void {
this.saves += 1;
this.stack.push(this.globalAlpha);
}
restore(): void {
this.restores += 1;
this.globalAlpha = this.stack.pop() ?? 1;
}
setTransform(): void {}
scale(): void {}
setLineDash(): void {}
beginPath(): void {
this.path = [];
}
arc(): void {
this.path.push({ kind: 'arc' });
}
moveTo(): void {
this.path.push({ kind: 'moveTo' });
}
lineTo(): void {
this.path.push({ kind: 'lineTo' });
}
getTransform(): DOMMatrix {
return { a: 1 } as DOMMatrix;
}
fill(): void {
this.fills.push({ color: this.fillStyle, globalAlpha: this.globalAlpha, path: this.path });
}
stroke(): void {
this.strokes.push({
color: this.strokeStyle,
globalAlpha: this.globalAlpha,
path: this.path,
});
}
fillRect(x: number, y: number, w: number, h: number): void {
this.fillRects.push({ x, y, w, h, color: this.fillStyle });
}
strokeRect(x: number, y: number, w: number, h: number): void {
this.strokeRects.push({ x, y, w, h, color: this.strokeStyle });
}
fillText(text: string, x: number, y: number): void {
this.texts.push({ text, x, y, color: this.fillStyle, font: this.font });
}
}
/** Различимые цвета для инвариантов (остальное — дефолтная тёмная тема). */
const THEME: Partial<BoardTheme> = {
blackStone: '#111111',
whiteStone: '#eeeeee',
phantomOpacity: 0.4,
lastMoveMarker: '#ff00ff',
markupColor: '#00ff00',
territoryBlack: 'rgba(1, 2, 3, 0.5)',
territoryWhite: 'rgba(4, 5, 6, 0.5)',
};
function play(state: BoardState, point: Point): BoardState {
const result = applyMove(state, { kind: 'play', color: state.toPlay, point });
if (!result.ok) throw new Error(result.error);
return result.state;
}
/** Позиция 9×9 с тремя камнями: чёрные (2,2) и (4,4), белый (6,2). */
function threeStones(): BoardState {
let state = createBoard(9);
state = play(state, { x: 2, y: 2 });
state = play(state, { x: 6, y: 2 });
return play(state, { x: 4, y: 4 });
}
function draw(state: BoardState, options: RenderOptions = {}): CtxRecorder {
const rec = new CtxRecorder();
render(rec as unknown as CanvasRenderingContext2D, state, {
theme: THEME,
...options,
});
return rec;
}
function stoneFills(rec: CtxRecorder, alpha: number): PaintRecord[] {
return rec.fills.filter(
(f) => (f.color === '#111111' || f.color === '#eeeeee') && f.globalAlpha === alpha,
);
}
describe('render: камни', () => {
it('число нарисованных камней = числу непустых клеток', () => {
const rec = draw(threeStones());
expect(stoneFills(rec, 1)).toHaveLength(3);
expect(rec.saves).toBe(rec.restores);
});
it('мёртвые камни приглушены альфой DEAD_STONE_ALPHA', () => {
const rec = draw(threeStones(), { dead: new Set(['2,2', '6,2']) });
expect(stoneFills(rec, DEAD_STONE_ALPHA)).toHaveLength(2);
expect(stoneFills(rec, 1)).toHaveLength(1);
});
});
describe('render: фантом и последний ход', () => {
it('фантом рисуется с globalAlpha = phantomOpacity', () => {
const rec = draw(threeStones(), { phantom: { point: { x: 0, y: 0 }, color: 'black' } });
const phantom = rec.fills.filter((f) => f.color === '#111111' && f.globalAlpha === 0.4);
expect(phantom).toHaveLength(1);
expect(stoneFills(rec, 1)).toHaveLength(3);
});
it('метка последнего хода — только при lastMove', () => {
const without = draw(threeStones());
expect(without.strokes.filter((s) => s.color === '#ff00ff')).toHaveLength(0);
const withMarker = draw(threeStones(), { lastMove: { x: 4, y: 4 } });
const marker = withMarker.strokes.filter((s) => s.color === '#ff00ff');
expect(marker).toHaveLength(1);
expect(marker[0]?.path.some((op) => op.kind === 'arc')).toBe(true);
});
});
describe('render: разметка из markup', () => {
const markup: NodeMarkup = {
labels: [
{ point: { x: 1, y: 1 }, text: 'A' },
{ point: { x: 2, y: 2 }, text: 'Б' },
],
triangles: [{ x: 3, y: 5 }],
squares: [{ x: 5, y: 3 }],
circles: [{ x: 7, y: 7 }],
};
it('подписи LB присутствуют', () => {
const rec = draw(threeStones(), { markup });
const texts = rec.texts.map((t) => t.text);
expect(texts).toContain('A');
expect(texts).toContain('Б');
});
it('SQ/TR/CR рисуются цветом markupColor по одной фигуре', () => {
const rec = draw(threeStones(), { markup });
const squares = rec.strokeRects.filter((r) => r.color === '#00ff00');
expect(squares).toHaveLength(1);
const shapes = rec.strokes.filter((s) => s.color === '#00ff00');
const lineTos = (s: PaintRecord): number => s.path.filter((op) => op.kind === 'lineTo').length;
expect(shapes.filter((s) => lineTos(s) === 3)).toHaveLength(1); // TR
expect(shapes.filter((s) => s.path.some((op) => op.kind === 'arc'))).toHaveLength(1); // CR
});
});
describe('render: заливка территории', () => {
it('fillRect — только фон и territory-ключи, цвет по владельцу', () => {
const rec = draw(threeStones(), {
territory: { black: new Set(['0,0', '1,0']), white: new Set(['8,8']) },
});
expect(rec.fillRects).toHaveLength(4); // фон + 3 клетки территории
expect(rec.fillRects.filter((r) => r.color === 'rgba(1, 2, 3, 0.5)')).toHaveLength(2);
expect(rec.fillRects.filter((r) => r.color === 'rgba(4, 5, 6, 0.5)')).toHaveLength(1);
});
it('заливка совпадает с координатами ключа "1,0"', () => {
const rec = draw(createBoard(9), { territory: { black: new Set(['1,0']), white: new Set() } });
const cell = 600 / (8 + 2); // size 9, координаты включены
const rect = rec.fillRects.find((r) => r.color === 'rgba(1, 2, 3, 0.5)');
expect(rect?.x).toBeCloseTo(cell + cell - cell / 2, 6);
expect(rect?.y).toBeCloseTo(cell - cell / 2, 6);
expect(rect?.w).toBeCloseTo(cell, 6);
});
it('без territory — единственный fillRect (фон)', () => {
expect(draw(threeStones()).fillRects).toHaveLength(1);
});
});
describe('render: номера ходов', () => {
it('номера рисуются только при showMoveNumbers и только на занятых клетках', () => {
const moveNumbers = new Map([
['2,2', 1],
['6,6', 5],
]);
// Координаты выключены, чтобы их числа не совпадали с номерами ходов.
const off = draw(threeStones(), { moveNumbers, showCoordinates: false });
expect(off.texts.filter((t) => t.text === '1')).toHaveLength(0);
const on = draw(threeStones(), {
showMoveNumbers: true,
moveNumbers,
showCoordinates: false,
});
expect(on.texts.filter((t) => t.text === '1')).toHaveLength(1);
expect(on.texts.filter((t) => t.text === '5')).toHaveLength(0); // клетка пуста
});
});

View file

@ -0,0 +1,333 @@
/**
* Рендер доски на Canvas 2D: чистая функция состояния. Каждый кадр рисуется
* целиком из аргументов (state + options); render ничего не хранит и не
* читает, кроме ctx. CanvasRenderingContext2D только тип параметра.
*
* Порядок слоёв: фон доски заливка территории сетка хоси координаты
* разметка SQ/TR/CR камни метка последнего хода номера ходов фантом
* подписи LB.
*
* Логический размер канвы выводится из ctx: pixelSize = canvas.width / dpr,
* где dpr масштаб текущего трансформа (его выставляет setupCanvas).
*/
import type { BoardState, Color, NodeMarkup, Point } from '@go-learn/core';
import { cellAt, pointKey } from '@go-learn/core';
import type { BoardGeometry } from './geometry.js';
import { computeGeometry, hoshiPoints, pointToPixel } from './geometry.js';
const TAU = Math.PI * 2;
/** Непрозрачность приглушения мёртвых камней (помеченных в options.dead). */
export const DEAD_STONE_ALPHA = 0.45;
export interface BoardTheme {
readonly boardBackground: string;
readonly lineColor: string;
readonly blackStone: string;
readonly whiteStone: string;
readonly blackStoneEdge: string;
readonly whiteStoneEdge: string;
readonly coordinateColor: string;
readonly markupColor: string;
readonly lastMoveMarker: string;
readonly phantomOpacity: number; // 0..1
readonly territoryBlack: string; // заливка с альфой
readonly territoryWhite: string;
}
/** Заливка территории при подсчёте: ключи pointKey ("x,y") по цветам. */
export interface TerritoryMap {
readonly black: ReadonlySet<string>;
readonly white: ReadonlySet<string>;
}
export interface RenderOptions {
readonly theme?: Partial<BoardTheme>; // дефолт — тёмная тема
readonly showCoordinates?: boolean; // дефолт true
readonly showMoveNumbers?: boolean; // дефолт false
readonly moveNumbers?: ReadonlyMap<string, number>; // ключ pointKey ("x,y") → номер хода
readonly lastMove?: Point | null;
readonly phantom?: { readonly point: Point; readonly color: Color } | null;
readonly markup?: NodeMarkup; // из @go-learn/core
readonly territory?: TerritoryMap; // заливка при подсчёте
readonly dead?: ReadonlySet<string>; // ключи pointKey ("x,y"); приглушаются (пониженная непрозрачность)
}
/**
* Дефолтная светлая тема «васи» (этап 10): светлое дерево гобана, коричневая
* сетка, почти чёрный и молочный камни с читаемой обводкой; цвета совпадают
* с токенами сайта (Layout.astro), разметка терракота, последний ход
* индиго. Константы часть дизайна (контракт docs/INTERFACES.md).
*/
const DEFAULT_THEME: BoardTheme = {
boardBackground: '#e6c48a',
lineColor: '#8a6a3f',
blackStone: '#211e1a',
whiteStone: '#f6f2e8',
blackStoneEdge: '#4a453c',
whiteStoneEdge: '#b9ac93',
coordinateColor: '#8a6a3f',
markupColor: '#a84e2c',
lastMoveMarker: '#3d4f7c',
phantomOpacity: 0.5,
territoryBlack: 'rgba(20, 15, 10, 0.45)',
territoryWhite: 'rgba(236, 229, 216, 0.35)',
};
/** Буквы координат без «I» (стандарт Го). */
const LETTERS = 'ABCDEFGHJKLMNOPQRST';
function resolveTheme(partial: Partial<BoardTheme> | undefined): BoardTheme {
return { ...DEFAULT_THEME, ...partial };
}
/** Разбор ключа pointKey ("x,y"); null — формат не совпал. */
function parseKey(key: string): Point | null {
const [xs, ys] = key.split(',');
const x = Number(xs);
const y = Number(ys);
if (!Number.isInteger(x) || !Number.isInteger(y)) return null;
return { x, y };
}
/** Контрастный цвет текста поверх клетки (камня или доски). */
function contrastOn(state: BoardState, point: Point, theme: BoardTheme): string {
const cell = cellAt(state, point);
if (cell === 'black') return theme.whiteStone;
if (cell === 'white') return theme.blackStone;
return theme.markupColor;
}
export function render(
ctx: CanvasRenderingContext2D,
state: BoardState,
options: RenderOptions = {},
): void {
const theme = resolveTheme(options.theme);
const showCoordinates = options.showCoordinates ?? true;
const dpr = ctx.getTransform().a || 1; // масштаб, выставленный setupCanvas
const geo = computeGeometry(state.size, ctx.canvas.width / dpr, showCoordinates);
ctx.save();
drawBackground(ctx, theme, geo.pixelSize);
if (options.territory !== undefined) drawTerritory(ctx, geo, options.territory, theme);
drawGrid(ctx, geo, theme);
drawHoshi(ctx, geo, theme);
if (showCoordinates) drawCoordinates(ctx, geo, theme);
if (options.markup !== undefined) drawMarkupShapes(ctx, geo, options.markup, theme);
drawStones(ctx, state, geo, theme, options.dead);
if (options.lastMove != null) drawLastMove(ctx, geo, options.lastMove, theme);
if ((options.showMoveNumbers ?? false) && options.moveNumbers !== undefined) {
drawMoveNumbers(ctx, state, geo, options.moveNumbers, theme);
}
if (options.phantom != null) drawPhantom(ctx, geo, options.phantom, theme);
if (options.markup !== undefined) drawLabels(ctx, state, geo, options.markup, theme);
ctx.restore();
}
function drawBackground(ctx: CanvasRenderingContext2D, theme: BoardTheme, pixelSize: number): void {
ctx.fillStyle = theme.boardBackground;
ctx.fillRect(0, 0, pixelSize, pixelSize);
}
/** Заливка территории: клетка вокруг пересечения, цвет — по владельцу. */
function drawTerritory(
ctx: CanvasRenderingContext2D,
geo: BoardGeometry,
territory: TerritoryMap,
theme: BoardTheme,
): void {
const paint = (keys: ReadonlySet<string>, fill: string): void => {
ctx.fillStyle = fill;
for (const key of keys) {
const point = parseKey(key);
if (point === null) continue;
const { x, y } = pointToPixel(geo, point);
ctx.fillRect(x - geo.cell / 2, y - geo.cell / 2, geo.cell, geo.cell);
}
};
paint(territory.black, theme.territoryBlack);
paint(territory.white, theme.territoryWhite);
}
function drawGrid(ctx: CanvasRenderingContext2D, geo: BoardGeometry, theme: BoardTheme): void {
const start = geo.padding;
const end = geo.padding + (geo.size - 1) * geo.cell;
ctx.strokeStyle = theme.lineColor;
ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0; i < geo.size; i += 1) {
const pos = geo.padding + i * geo.cell;
ctx.moveTo(start, pos);
ctx.lineTo(end, pos);
ctx.moveTo(pos, start);
ctx.lineTo(pos, end);
}
ctx.stroke();
}
function drawHoshi(ctx: CanvasRenderingContext2D, geo: BoardGeometry, theme: BoardTheme): void {
ctx.fillStyle = theme.lineColor;
const radius = Math.max(2, geo.cell * 0.09);
for (const point of hoshiPoints(geo.size)) {
const { x, y } = pointToPixel(geo, point);
ctx.beginPath();
ctx.arc(x, y, radius, 0, TAU);
ctx.fill();
}
}
/** Буквы (без «I») снизу слева направо, числа слева, 1 внизу. */
function drawCoordinates(
ctx: CanvasRenderingContext2D,
geo: BoardGeometry,
theme: BoardTheme,
): void {
const fontSize = Math.max(9, Math.round(geo.cell * 0.34));
ctx.fillStyle = theme.coordinateColor;
ctx.font = `${fontSize}px system-ui, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const bottom = geo.padding + (geo.size - 1) * geo.cell;
for (let i = 0; i < geo.size; i += 1) {
const pos = geo.padding + i * geo.cell;
ctx.fillText(LETTERS[i] ?? '', pos, bottom + geo.padding * 0.55);
ctx.fillText(String(geo.size - i), geo.padding * 0.45, pos);
}
}
function drawTriangle(ctx: CanvasRenderingContext2D, x: number, y: number, r: number): void {
ctx.beginPath();
ctx.moveTo(x, y - r);
ctx.lineTo(x + r * 0.87, y + r * 0.5);
ctx.lineTo(x - r * 0.87, y + r * 0.5);
ctx.lineTo(x, y - r);
ctx.stroke();
}
/** Разметка SQ/TR/CR — контурными фигурами, под камнями. */
function drawMarkupShapes(
ctx: CanvasRenderingContext2D,
geo: BoardGeometry,
markup: NodeMarkup,
theme: BoardTheme,
): void {
ctx.strokeStyle = theme.markupColor;
ctx.lineWidth = Math.max(1.5, geo.cell * 0.06);
const r = geo.cell * 0.32;
for (const point of markup.squares) {
const { x, y } = pointToPixel(geo, point);
ctx.strokeRect(x - r, y - r, r * 2, r * 2);
}
for (const point of markup.triangles) {
const { x, y } = pointToPixel(geo, point);
drawTriangle(ctx, x, y, r);
}
for (const point of markup.circles) {
const { x, y } = pointToPixel(geo, point);
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.stroke();
}
}
/** Один камень: круг чуть меньше полклетки, тонкая обводка edge-цветом. */
function drawStone(
ctx: CanvasRenderingContext2D,
geo: BoardGeometry,
point: Point,
color: Color,
theme: BoardTheme,
alpha: number,
): void {
const { x, y } = pointToPixel(geo, point);
ctx.save();
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.arc(x, y, geo.cell / 2 - 1, 0, TAU);
ctx.fillStyle = color === 'black' ? theme.blackStone : theme.whiteStone;
ctx.fill();
ctx.strokeStyle = color === 'black' ? theme.blackStoneEdge : theme.whiteStoneEdge;
ctx.lineWidth = 1;
ctx.stroke();
ctx.restore();
}
function drawStones(
ctx: CanvasRenderingContext2D,
state: BoardState,
geo: BoardGeometry,
theme: BoardTheme,
dead: ReadonlySet<string> | undefined,
): void {
for (let y = 0; y < state.size; y += 1) {
for (let x = 0; x < state.size; x += 1) {
const point = { x, y };
const cell = cellAt(state, point);
if (cell === 'empty') continue;
const alpha = dead?.has(pointKey(point)) === true ? DEAD_STONE_ALPHA : 1;
drawStone(ctx, geo, point, cell, theme, alpha);
}
}
}
/** Метка последнего хода: контрастное кольцо, различимое на обоих цветах. */
function drawLastMove(
ctx: CanvasRenderingContext2D,
geo: BoardGeometry,
lastMove: Point,
theme: BoardTheme,
): void {
const { x, y } = pointToPixel(geo, lastMove);
ctx.strokeStyle = theme.lastMoveMarker;
ctx.lineWidth = Math.max(2, geo.cell * 0.07);
ctx.beginPath();
ctx.arc(x, y, geo.cell * 0.22, 0, TAU);
ctx.stroke();
}
/** Номера ходов — только на занятых клетках, контрастом к цвету камня. */
function drawMoveNumbers(
ctx: CanvasRenderingContext2D,
state: BoardState,
geo: BoardGeometry,
moveNumbers: ReadonlyMap<string, number>,
theme: BoardTheme,
): void {
ctx.font = `${Math.max(8, Math.round(geo.cell * 0.4))}px system-ui, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (const [key, num] of moveNumbers) {
const point = parseKey(key);
if (point === null || cellAt(state, point) === 'empty') continue;
ctx.fillStyle = contrastOn(state, point, theme);
const { x, y } = pointToPixel(geo, point);
ctx.fillText(String(num), x, y);
}
}
function drawPhantom(
ctx: CanvasRenderingContext2D,
geo: BoardGeometry,
phantom: { readonly point: Point; readonly color: Color },
theme: BoardTheme,
): void {
drawStone(ctx, geo, phantom.point, phantom.color, theme, theme.phantomOpacity);
}
/** Подписи LB — поверх всех слоёв, контрастом к содержимому клетки. */
function drawLabels(
ctx: CanvasRenderingContext2D,
state: BoardState,
geo: BoardGeometry,
markup: NodeMarkup,
theme: BoardTheme,
): void {
ctx.font = `bold ${Math.max(9, Math.round(geo.cell * 0.42))}px system-ui, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (const label of markup.labels) {
ctx.fillStyle = contrastOn(state, label.point, theme);
const { x, y } = pointToPixel(geo, label.point);
ctx.fillText(label.text, x, y);
}
}

View file

@ -0,0 +1,59 @@
/**
* Wiring-тест (gotcha-green-modules-dead-system): orphan-check публичного API
* packages/board каждая экспортируемая функция упоминается хотя бы раз вне
* своего определения (в другом модуле пакета или в тесте).
*
* Допустимые «внешние» входные точки: setupCanvas и attachPointerInput
* DOM-адаптер для будущего UI (этап 5), юнит-тестами не покрывается
* (см. plan-phase-2, матрица «чего НЕ покрывает ни один тест»).
*/
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));
/** Входные точки DOM-адаптера: вызываются будущим UI, не тестами. */
const EXTERNAL_ENTRY_POINTS = new Set(['setupCanvas', 'attachPointerInput']);
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()) {
if (EXTERNAL_ENTRY_POINTS.has(name)) continue;
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,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2022", "DOM"],
"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/board.
"include": ["src"]
}

View file

@ -0,0 +1,141 @@
/**
* Микро-бенчмарк: flood fill vs union-find для поиска групп и дамэ
* на случайных позициях 19×19. Запуск: npx tsx packages/core/bench/group-bench.mts
* (tsx эфемерный инструмент прогона, не зависимость пакета).
* Случайность только createSeededRng с фиксированным seed (детерминизм).
*/
import { performance } from 'node:perf_hooks';
import { createSeededRng } from '../src/index.js';
const SIZE = 19;
const CELLS = SIZE * SIZE;
const POSITIONS = 40;
const ITERATIONS = 300;
/** Случайная позиция: клетки заняты с вероятностью density, цвет 50/50. */
function randomPosition(seed: number, density: number): Uint8Array {
const rng = createSeededRng(seed);
const grid = new Uint8Array(CELLS);
for (let index = 0; index < CELLS; index += 1) {
if (rng.next() < density) grid[index] = rng.next() < 0.5 ? 1 : 2;
}
return grid;
}
/** Соседи клетки по индексу. */
function neighborIndices(index: number): number[] {
const x = index % SIZE;
const y = Math.floor(index / SIZE);
const result: number[] = [];
if (x > 0) result.push(index - 1);
if (x < SIZE - 1) result.push(index + 1);
if (y > 0) result.push(index - SIZE);
if (y < SIZE - 1) result.push(index + SIZE);
return result;
}
/** Flood fill: группы + число дамэ (как в движке, groupAt). */
function groupsFloodFill(grid: Uint8Array): number {
const seen = new Uint8Array(CELLS);
let groups = 0;
let liberties = 0;
for (let start = 0; start < CELLS; start += 1) {
if (grid[start] === 0 || seen[start] === 1) continue;
groups += 1;
const color = grid[start];
const stack = [start];
seen[start] = 1;
const libs = new Set<number>();
while (stack.length > 0) {
const current = stack.pop() as number;
for (const next of neighborIndices(current)) {
if (grid[next] === 0) libs.add(next);
if (grid[next] === color && seen[next] === 0) {
seen[next] = 1;
stack.push(next);
}
}
}
liberties += libs.size;
}
return groups * 1000 + liberties;
}
/** Union-find: группы + число дамэ. */
function groupsUnionFind(grid: Uint8Array): number {
const parent = new Int32Array(CELLS);
for (let index = 0; index < CELLS; index += 1) parent[index] = index;
const find = (value: number): number => {
let root = value;
while (parent[root] !== root) root = parent[root] ?? root;
let node = value;
while (parent[node] !== node) {
const next = parent[node] ?? node;
parent[node] = root;
node = next;
}
return root;
};
for (let index = 0; index < CELLS; index += 1) {
if (grid[index] === 0) continue;
for (const next of neighborIndices(index)) {
if (grid[next] === grid[index]) parent[find(index)] = find(next);
}
}
const libsByRoot = new Map<number, Set<number>>();
for (let index = 0; index < CELLS; index += 1) {
if (grid[index] === 0) continue;
const root = find(index);
let libs = libsByRoot.get(root);
if (libs === undefined) {
libs = new Set<number>();
libsByRoot.set(root, libs);
}
for (const next of neighborIndices(index)) {
if (grid[next] === 0) libs.add(next);
}
}
let liberties = 0;
for (const libs of libsByRoot.values()) liberties += libs.size;
return libsByRoot.size * 1000 + liberties;
}
/** Прогон одного алгоритма на наборе позиций; возвращает мс и контрольную сумму. */
function measure(
positions: Uint8Array[],
algorithm: (grid: Uint8Array) => number,
): { ms: number; checksum: number } {
let checksum = 0;
const started = performance.now();
for (let iteration = 0; iteration < ITERATIONS; iteration += 1) {
for (const grid of positions) checksum = (checksum + algorithm(grid)) % 1000003;
}
return { ms: performance.now() - started, checksum };
}
const rng = createSeededRng(7);
const densities = [0.35, 0.55, 0.75];
let totalFlood = 0;
let totalUnion = 0;
for (const density of densities) {
const positions = Array.from({ length: POSITIONS }, (_, index) =>
randomPosition(Math.floor(rng.next() * 100000), density),
);
const flood = measure(positions, groupsFloodFill);
const union = measure(positions, groupsUnionFind);
if (flood.checksum !== union.checksum) {
console.warn(`ОШИБКА: контрольные суммы не совпали (${flood.checksum} != ${union.checksum})`);
process.exitCode = 1;
}
totalFlood += flood.ms;
totalUnion += union.ms;
console.warn(
`density=${density}: flood fill ${flood.ms.toFixed(1)} мс, union-find ${union.ms.toFixed(1)} мс, ` +
`отношение u/f ${(union.ms / flood.ms).toFixed(2)}`,
);
}
console.warn(
`ИТОГО: flood fill ${totalFlood.toFixed(1)} мс, union-find ${totalUnion.toFixed(1)} мс, ` +
`отношение u/f ${(totalUnion / totalFlood).toFixed(2)} ` +
`(${POSITIONS * ITERATIONS * densities.length} прогонов позиций 19x19)`,
);

View file

@ -0,0 +1,11 @@
{
"name": "@go-learn/core",
"version": "0.1.0",
"private": true,
"description": "Движок правил Го: доска, группы, захваты, ко/суперко, подсчёт Tromp-Taylor, SGF, история. Чистый TypeScript, ноль зависимостей от DOM, детерминизм (часы и RNG инжектируются).",
"type": "module",
"main": "src/index.ts",
"scripts": {
"test": "vitest run"
}
}

171
packages/core/src/board.ts Normal file
View file

@ -0,0 +1,171 @@
/**
* Базовые типы и операции доски: сетка, соседи, группы, дамэ.
* Позиция строго типизированная структура: обычный Array с readonly-типом.
*/
export type BoardSize = 9 | 13 | 19;
export type Color = 'black' | 'white';
export type CellState = 'empty' | 'black' | 'white';
export interface Point {
readonly x: number;
readonly y: number;
}
export type Move =
| { readonly kind: 'play'; readonly color: Color; readonly point: Point }
| { readonly kind: 'pass'; readonly color: Color }
| { readonly kind: 'resign'; readonly color: Color };
export interface BoardState {
readonly size: BoardSize;
readonly grid: ReadonlyArray<CellState>;
readonly toPlay: Color;
readonly captures: { readonly black: number; readonly white: number };
readonly koPoint: Point | null;
readonly positionHashes: ReadonlyArray<string>;
readonly over: boolean;
}
/** Противоположный цвет. */
export function opposite(color: Color): Color {
return color === 'black' ? 'white' : 'black';
}
/** Ключ точки для множеств ("x,y"). */
export function pointKey(point: Point): string {
return `${point.x},${point.y}`;
}
/** Точка в пределах доски. */
export function inBounds(size: BoardSize, point: Point): boolean {
return point.x >= 0 && point.x < size && point.y >= 0 && point.y < size;
}
/** Индекс клетки в grid. */
export function cellIndex(size: BoardSize, point: Point): number {
return point.y * size + point.x;
}
/** Содержимое клетки; 'empty', если точка вне доски. */
export function cellAt(state: BoardState, point: Point): CellState {
if (!inBounds(state.size, point)) return 'empty';
return state.grid[cellIndex(state.size, point)] ?? 'empty';
}
/** Ортогональные соседи точки в пределах доски. */
export function neighbors(size: BoardSize, point: Point): Point[] {
const result: Point[] = [];
const candidates: Point[] = [
{ x: point.x - 1, y: point.y },
{ x: point.x + 1, y: point.y },
{ x: point.x, y: point.y - 1 },
{ x: point.x, y: point.y + 1 },
];
for (const candidate of candidates) {
if (inBounds(size, candidate)) result.push(candidate);
}
return result;
}
export interface Group {
readonly stones: ReadonlyArray<Point>;
readonly liberties: number;
}
/**
* Группа в точке и число её дамэ (flood fill).
* Инвариант (property-тесты): после любого легального хода каждая группа
* на доске имеет минимум одно дамэ; группа без дамэ снимается с доски.
*/
export function groupAt(state: BoardState, point: Point): Group | null {
const color = cellAt(state, point);
if (color === 'empty' || !inBounds(state.size, point)) return null;
const stones: Point[] = [];
const seen = new Set<string>();
const libertyKeys = new Set<string>();
const stack: Point[] = [point];
seen.add(pointKey(point));
while (stack.length > 0) {
const current = stack.pop() as Point;
stones.push(current);
for (const next of neighbors(state.size, current)) {
const cell = cellAt(state, next);
if (cell === 'empty') libertyKeys.add(pointKey(next));
if (cell === color && !seen.has(pointKey(next))) {
seen.add(pointKey(next));
stack.push(next);
}
}
}
return { stones, liberties: libertyKeys.size };
}
/** Пустая позиция: хэш стартовой позиции уже в positionHashes. */
export function createBoard(size: BoardSize): BoardState {
const grid: CellState[] = new Array<CellState>(size * size).fill('empty');
const state: BoardState = {
size,
grid,
toPlay: 'black',
captures: { black: 0, white: 0 },
koPoint: null,
positionHashes: [],
over: false,
};
return { ...state, positionHashes: [hashPosition(state)] };
}
/** Хэш «позиция + toPlay» для позиционного суперко. */
export function hashPosition(state: BoardState): string {
return `${state.grid.join(',')}|${state.toPlay}`;
}
/**
* Произвольная стартовая позиция (этап 6: уроки и цумэ-го, ADR-0003).
* Валидация: точки в пределах доски, без дублей и пересечений цветов,
* каждая группа имеет 1 дамэ (позиция не «мёртва на старте»).
* Нарушение Error с описанием; доска не создаётся.
* captures = 0, koPoint = null, positionHashes = [хэш позиции].
*/
export function buildPosition(
size: BoardSize,
black: ReadonlyArray<Point>,
white: ReadonlyArray<Point>,
toPlay: Color,
): BoardState {
const grid: CellState[] = new Array<CellState>(size * size).fill('empty');
const place = (stones: ReadonlyArray<Point>, color: CellState): void => {
for (const point of stones) {
if (!inBounds(size, point)) {
throw new Error(`buildPosition: точка ${pointKey(point)} вне доски ${size}×${size}`);
}
const index = cellIndex(size, point);
if (grid[index] !== 'empty') {
throw new Error(`buildPosition: точка ${pointKey(point)} занята дважды`);
}
grid[index] = color;
}
};
place(black, 'black');
place(white, 'white');
const state: BoardState = {
size,
grid,
toPlay,
captures: { black: 0, white: 0 },
koPoint: null,
positionHashes: [],
over: false,
};
for (let index = 0; index < grid.length; index++) {
const cell = grid[index];
if (cell === 'empty') continue;
const point: Point = { x: index % size, y: Math.floor(index / size) };
const group = groupAt(state, point);
if (group !== null && group.liberties === 0) {
throw new Error(`buildPosition: группа в ${pointKey(point)} без дамэ`);
}
}
return { ...state, positionHashes: [hashPosition(state)] };
}

View file

@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import { applyMove, buildPosition, cellAt, groupAt } from './index.js';
describe('buildPosition', () => {
it('расставляет камни, очередь хода и хэш позиции', () => {
const state = buildPosition(
9,
[
{ x: 0, y: 0 },
{ x: 1, y: 0 },
],
[{ x: 8, y: 8 }],
'white',
);
expect(cellAt(state, { x: 0, y: 0 })).toBe('black');
expect(cellAt(state, { x: 8, y: 8 })).toBe('white');
expect(state.toPlay).toBe('white');
expect(state.captures).toEqual({ black: 0, white: 0 });
expect(state.positionHashes).toHaveLength(1);
});
it('позиция играбельна: applyMove работает, суперко-хэши копятся', () => {
const state = buildPosition(9, [{ x: 4, y: 4 }], [], 'black');
const result = applyMove(state, { kind: 'play', color: 'black', point: { x: 0, y: 0 } });
expect(result.ok).toBe(true);
if (result.ok) expect(result.state.positionHashes).toHaveLength(2);
});
it('отклоняет точку вне доски', () => {
expect(() => buildPosition(9, [{ x: 9, y: 0 }], [], 'black')).toThrow(/вне доски/);
});
it('отклоняет двойное занятие точки', () => {
expect(() => buildPosition(9, [{ x: 1, y: 1 }], [{ x: 1, y: 1 }], 'black')).toThrow(
/занята дважды/,
);
});
it('отклоняет группу без дамэ', () => {
expect(() =>
buildPosition(
9,
[{ x: 0, y: 0 }],
[
{ x: 1, y: 0 },
{ x: 0, y: 1 },
],
'black',
),
).toThrow(/без дамэ/);
});
it('захват с построенной позиции считается движком', () => {
const state = buildPosition(9, [{ x: 1, y: 0 }], [{ x: 0, y: 0 }], 'black');
expect(groupAt(state, { x: 0, y: 0 })?.liberties).toBe(1);
const result = applyMove(state, { kind: 'play', color: 'black', point: { x: 0, y: 1 } });
expect(result.ok).toBe(true);
if (result.ok) {
expect(cellAt(result.state, { x: 0, y: 0 })).toBe('empty');
expect(result.state.captures.black).toBe(1);
}
});
});

View file

@ -0,0 +1,83 @@
/**
* Юнит-тесты истории: неизменяемое дерево, ветвление, навигация.
*/
import { describe, expect, it } from 'vitest';
import type { GameTree } from './index.js';
import { appendMove, createGame, findNode, goToNode } from './index.js';
/** Линейная партия из трёх ходов: B(0,0) W(1,1) B(2,2). */
function threeMoveGame(): GameTree {
let tree = createGame({ size: 9 });
let result = appendMove(tree, { kind: 'play', color: 'black', point: { x: 0, y: 0 } });
if (!result.ok) throw new Error('ход 1 нелегален');
result = appendMove(result.tree, { kind: 'play', color: 'white', point: { x: 1, y: 1 } });
if (!result.ok) throw new Error('ход 2 нелегален');
result = appendMove(result.tree, { kind: 'play', color: 'black', point: { x: 2, y: 2 } });
if (!result.ok) throw new Error('ход 3 нелегален');
return result.tree;
}
describe('createGame/appendMove', () => {
it('линейное добавление ходов; currentId — последний узел', () => {
const tree = threeMoveGame();
expect(tree.currentId).toBe(3);
expect(tree.nextId).toBe(4);
expect(tree.komi).toBe(5.5);
const node = findNode(tree.root, 3);
expect(node?.move).toEqual({ kind: 'play', color: 'black', point: { x: 2, y: 2 } });
});
it('нелегальный ход не меняет дерево', () => {
const tree = threeMoveGame();
const result = appendMove(tree, { kind: 'play', color: 'white', point: { x: 0, y: 0 } });
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.tree).toBe(tree);
});
it('исходное дерево не мутируется при добавлении', () => {
const tree = threeMoveGame();
const rootChildren = tree.root.children;
const result = appendMove(tree, { kind: 'play', color: 'white', point: { x: 5, y: 5 } });
expect(result.ok).toBe(true);
expect(tree.root.children).toBe(rootChildren);
expect(tree.currentId).toBe(3);
});
it('resign проставляет meta.result', () => {
const tree = threeMoveGame();
const result = appendMove(tree, { kind: 'resign', color: 'white' });
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.tree.meta.result).toBe('B+R');
});
});
describe('ветвление и goToNode', () => {
it('appendMove от отмотанного узла создаёт ветку в children', () => {
const tree = threeMoveGame();
const back = goToNode(tree, 2);
expect(back.currentId).toBe(2);
const result = appendMove(back, { kind: 'play', color: 'black', point: { x: 7, y: 7 } });
expect(result.ok).toBe(true);
if (!result.ok) return;
const fork = findNode(result.tree.root, 2);
expect(fork?.children).toHaveLength(2);
expect(result.tree.currentId).toBe(result.nodeId);
// Основная ветка на месте: состояние узла 3 не изменилось.
expect(findNode(result.tree.root, 3)?.state.grid).toEqual(findNode(tree.root, 3)?.state.grid);
});
it('goToNode к несуществующему узлу возвращает дерево без изменений', () => {
const tree = threeMoveGame();
expect(goToNode(tree, 999)).toBe(tree);
});
it('ход в отмотанной позиции валидируется по состоянию того узла', () => {
const tree = goToNode(threeMoveGame(), 1);
const result = appendMove(tree, { kind: 'play', color: 'black', point: { x: 4, y: 4 } });
expect(result.ok).toBe(false); // после узла 1 ход белых
if (result.ok) return;
expect(result.error).toContain('цвета');
});
});

View file

@ -0,0 +1,160 @@
/**
* История партии: неизменяемое дерево узлов с ветвлением.
* createGame/appendMove/goToNode не мутируют входные структуры.
*/
import type { BoardState, Color, Move, Point } from './board.js';
import { createBoard } from './board.js';
import { applyMove } from './rules.js';
import type { MoveError } from './rules.js';
import { defaultKomi } from './score.js';
export interface Label {
readonly point: Point;
readonly text: string;
}
export interface NodeMarkup {
readonly labels: ReadonlyArray<Label>;
readonly triangles: ReadonlyArray<Point>;
readonly squares: ReadonlyArray<Point>;
readonly circles: ReadonlyArray<Point>;
}
export interface GameNode {
readonly id: number;
readonly move: Move | null;
readonly state: BoardState;
readonly comment: string;
readonly markup: NodeMarkup;
readonly children: ReadonlyArray<GameNode>;
}
export interface GameMeta {
readonly blackName: string;
readonly whiteName: string;
readonly result: string | null;
}
export interface GameTree {
readonly size: BoardState['size'];
readonly komi: number;
readonly root: GameNode;
readonly currentId: number;
readonly nextId: number;
readonly meta: GameMeta;
}
export interface CreateGameOptions {
readonly size: BoardState['size'];
readonly komi?: number;
readonly blackName?: string;
readonly whiteName?: string;
}
export type AppendResult =
| { readonly ok: true; readonly tree: GameTree; readonly nodeId: number }
| {
readonly ok: false;
readonly tree: GameTree;
readonly reason: MoveError | 'node-not-found';
readonly error: string;
};
export const emptyMarkup: NodeMarkup = {
labels: [],
triangles: [],
squares: [],
circles: [],
};
/** Поиск узла по id (обход в глубину). */
export function findNode(root: GameNode, id: number): GameNode | null {
if (root.id === id) return root;
for (const child of root.children) {
const found = findNode(child, id);
if (found !== null) return found;
}
return null;
}
export function createGame(options: CreateGameOptions): GameTree {
const root: GameNode = {
id: 0,
move: null,
state: createBoard(options.size),
comment: '',
markup: emptyMarkup,
children: [],
};
return {
size: options.size,
komi: options.komi ?? defaultKomi(options.size),
root,
currentId: 0,
nextId: 1,
meta: {
blackName: options.blackName ?? '',
whiteName: options.whiteName ?? '',
result: null,
},
};
}
/** Копия поддерева, где к узлу targetId добавлен ребёнок child; null — узел не найден. */
function withAppendedChild(node: GameNode, targetId: number, child: GameNode): GameNode | null {
if (node.id === targetId) return { ...node, children: [...node.children, child] };
const children: GameNode[] = [];
let replaced = false;
for (const existing of node.children) {
const updated = withAppendedChild(existing, targetId, child);
if (updated !== null) {
children.push(updated);
replaced = true;
} else {
children.push(existing);
}
}
return replaced ? { ...node, children } : null;
}
/** RE при сдаче: сдавшийся проигрывает. */
function resignResult(color: Color): string {
return color === 'black' ? 'W+R' : 'B+R';
}
/**
* Ход от текущего узла; ветвление добавление в children текущего узла.
* Нелегальный ход не меняет дерево (ok=false, tree исходное).
*/
export function appendMove(tree: GameTree, move: Move): AppendResult {
const current = findNode(tree.root, tree.currentId);
if (current === null)
return { ok: false, tree, reason: 'node-not-found', error: 'текущий узел не найден' };
const result = applyMove(current.state, move);
if (!result.ok) return { ok: false, tree, reason: result.reason, error: result.error };
const child: GameNode = {
id: tree.nextId,
move,
state: result.state,
comment: '',
markup: emptyMarkup,
children: [],
};
const root = withAppendedChild(tree.root, tree.currentId, child);
if (root === null) {
return { ok: false, tree, reason: 'node-not-found', error: 'текущий узел не найден' };
}
const next: GameTree = { ...tree, root, currentId: child.id, nextId: tree.nextId + 1 };
if (move.kind !== 'resign') return { ok: true, tree: next, nodeId: child.id };
const withResult: GameTree = {
...next,
meta: { ...next.meta, result: resignResult(move.color) },
};
return { ok: true, tree: withResult, nodeId: child.id };
}
/** Переход к узлу по id; если узла нет — дерево без изменений. */
export function goToNode(tree: GameTree, nodeId: number): GameTree {
if (findNode(tree.root, nodeId) === null) return tree;
return { ...tree, currentId: nodeId };
}

View file

@ -0,0 +1,23 @@
/**
* Публичный API движка правил Го (контракт docs/INTERFACES.md).
* Чистый TypeScript: без DOM/worker API, без Date.now()/Math.random().
*/
export type { BoardSize, BoardState, CellState, Color, Group, Move, Point } from './board.js';
export { buildPosition, cellAt, createBoard, groupAt, opposite, pointKey } from './board.js';
export type { MoveError, MoveResult } from './rules.js';
export { applyMove } from './rules.js';
export type { ScoreOptions, ScoreResult } from './score.js';
export { defaultKomi, scorePosition, suggestDeadGroups } from './score.js';
export type {
AppendResult,
CreateGameOptions,
GameMeta,
GameNode,
GameTree,
Label,
NodeMarkup,
} from './history.js';
export { appendMove, createGame, findNode, goToNode } from './history.js';
export { parseSgf, SgfError, serializeSgf } from './sgf.js';
export type { Clock, Rng } from './rng.js';
export { createSeededRng } from './rng.js';

View file

@ -0,0 +1,149 @@
/**
* Property-based тесты инвариантов движка (fast-check, детерминированный seed).
* Случайность внутри прогонов только createSeededRng (см. спеку тестов).
*
* Инварианты:
* (а) после любого легального хода число камней = прежнее + 1 captured.length;
* (б) каждая группа на доске имеет минимум одно дамэ (захваченные без дамэ);
* (в) roundtrip SGF: serializeparseserialize === serialize.
*/
import * as fc from 'fast-check';
import { describe, expect, it } from 'vitest';
import type { BoardState, GameTree, Move, Point } from './index.js';
import {
appendMove,
applyMove,
cellAt,
createBoard,
createGame,
createSeededRng,
goToNode,
groupAt,
parseSgf,
pointKey,
serializeSgf,
} from './index.js';
/** Случайный легальный ход: перебираем пустые клетки от случайного старта. */
function randomLegalMove(state: BoardState, rngValue: number): Move | null {
const size = state.size;
const start = Math.floor(rngValue * size * size);
for (let probe = 0; probe < size * size; probe += 1) {
const index = (start + probe) % (size * size);
const point: Point = { x: index % size, y: Math.floor(index / size) };
if (cellAt(state, point) !== 'empty') continue;
const move: Move = { kind: 'play', color: state.toPlay, point };
if (applyMove(state, move).ok) return move;
}
return null;
}
/** Случайная партия: до maxMoves ходов, затем два паса не нужны — стоп при тупике. */
function randomPlayout(seed: number, maxMoves: number): BoardState[] {
const rng = createSeededRng(seed);
const states: BoardState[] = [createBoard(9)];
for (let i = 0; i < maxMoves; i += 1) {
const state = states[states.length - 1] as BoardState;
const move = randomLegalMove(state, rng.next());
if (move === null) break;
const result = applyMove(state, move);
if (result.ok) states.push(result.state);
}
return states;
}
/** Ключи всех групп доски, у которых нет дамэ (должно быть пусто). */
function groupsWithoutLiberties(state: BoardState): string[] {
const bad: string[] = [];
const seen = new Set<string>();
for (let y = 0; y < state.size; y += 1) {
for (let x = 0; x < state.size; x += 1) {
const key = pointKey({ x, y });
if (seen.has(key) || cellAt(state, { x, y }) === 'empty') continue;
const group = groupAt(state, { x, y });
if (group === null) continue;
for (const stone of group.stones) seen.add(pointKey(stone));
if (group.liberties === 0) bad.push(key);
}
}
return bad;
}
function stoneTotal(state: BoardState): number {
return state.grid.filter((cell) => cell !== 'empty').length;
}
describe('инварианты движка (property-based)', () => {
it('(а) и (б): баланс камней и дамэ групп в случайных партиях', () => {
fc.assert(
fc.property(fc.nat({ max: 100000 }), (seed) => {
const states = randomPlayout(seed, 80);
for (let i = 1; i < states.length; i += 1) {
const before = states[i - 1] as BoardState;
const after = states[i] as BoardState;
expect(stoneTotal(after)).toBeGreaterThanOrEqual(0);
expect(groupsWithoutLiberties(after)).toEqual([]);
// Хэш новой позиции добавлен и не повторялся ранее.
const hash = after.positionHashes[after.positionHashes.length - 1] ?? '';
expect(before.positionHashes).not.toContain(hash);
}
expect(states.length).toBeGreaterThan(1);
}),
{ seed: 42, numRuns: 25 },
);
});
it('(а): баланс камней = +1 captured.length для каждого хода', () => {
fc.assert(
fc.property(fc.nat({ max: 100000 }), (seed) => {
const rng = createSeededRng(seed);
let state = createBoard(9);
for (let i = 0; i < 60; i += 1) {
const move = randomLegalMove(state, rng.next());
if (move === null) break;
const result = applyMove(state, move);
if (!result.ok) throw new Error('randomLegalMove вернул нелегальный ход');
expect(stoneTotal(result.state)).toBe(stoneTotal(state) + 1 - result.captured.length);
state = result.state;
}
}),
{ seed: 43, numRuns: 25 },
);
});
it('(в): roundtrip SGF для случайных партий с ветвлением', () => {
fc.assert(
fc.property(fc.nat({ max: 100000 }), (seed) => {
const rng = createSeededRng(seed);
let tree = createGame({ size: 9 });
let steps = 0;
while (steps < 40) {
const state = findNodeState(tree);
const move = randomLegalMove(state, rng.next());
if (move === null) break;
const result = appendMove(tree, move);
if (!result.ok) throw new Error(`нелегальный ход в дереве: ${result.error}`);
tree = result.tree;
steps += 1;
// Иногда отматываемся к корню первой ветки — порождаем вариации.
if (steps % 13 === 0) tree = goToNode(tree, Math.floor(rng.next() * steps));
}
if (steps === 0) return;
const once = serializeSgf(tree);
expect(serializeSgf(parseSgf(once))).toBe(once);
}),
{ seed: 44, numRuns: 25 },
);
});
});
/** Состояние текущего узла дерева. */
function findNodeState(tree: GameTree): BoardState {
const stack = [tree.root];
while (stack.length > 0) {
const node = stack.pop();
if (node?.id === tree.currentId) return node.state;
for (const child of node?.children ?? []) stack.push(child);
}
return tree.root.state;
}

View file

@ -0,0 +1,30 @@
/**
* Юнит-тесты детерминированного RNG (mulberry32).
*/
import { describe, expect, it } from 'vitest';
import { createSeededRng } from './index.js';
describe('createSeededRng', () => {
it('одинаковый seed → одинаковая последовательность', () => {
const first = createSeededRng(42);
const second = createSeededRng(42);
for (let i = 0; i < 100; i += 1) expect(first.next()).toBe(second.next());
});
it('разные seed → разные последовательности', () => {
const first = createSeededRng(1);
const second = createSeededRng(2);
const a = [first.next(), first.next(), first.next()];
const b = [second.next(), second.next(), second.next()];
expect(a).not.toEqual(b);
});
it('значения в диапазоне [0, 1)', () => {
const rng = createSeededRng(7);
for (let i = 0; i < 1000; i += 1) {
const value = rng.next();
expect(value).toBeGreaterThanOrEqual(0);
expect(value).toBeLessThan(1);
}
});
});

32
packages/core/src/rng.ts Normal file
View file

@ -0,0 +1,32 @@
/**
* Инжектируемые часы и детерминированный RNG.
* Core в этапе 1 их не использует: типы объявлены для этапов 34 (ИИ),
* createSeededRng единственный разрешённый источник случайности в тестах.
*/
export interface Rng {
/** Равномерное число из [0, 1). */
next(): number;
}
export interface Clock {
/** Миллисекунды (шкала определяется инжектором). */
now(): number;
}
/**
* Детерминированный PRNG mulberry32: одинаковый seed одинаковая
* последовательность. Не криптостойкий; для ИИ-этапов и тестов достаточно.
*/
export function createSeededRng(seed: number): Rng {
let state = seed >>> 0;
return {
next(): number {
state = (state + 0x6d2b79f5) >>> 0;
let mixed = state;
mixed = Math.imul(mixed ^ (mixed >>> 15), mixed | 1);
mixed ^= mixed + Math.imul(mixed ^ (mixed >>> 7), mixed | 61);
return ((mixed ^ (mixed >>> 14)) >>> 0) / 4294967296;
},
};
}

View file

@ -0,0 +1,185 @@
/**
* Юнит-тесты движка правил: захваты, самоубийство, ко, суперко, пассы, resign.
*/
import { describe, expect, it } from 'vitest';
import type { BoardState } from './index.js';
import { applyMove, cellAt, createBoard, groupAt } from './index.js';
import { countStones, playMoves } from './test-utils.js';
/** Постановка хода текущего цвета с проверкой легальности. */
function mustPlay(state: BoardState, x: number, y: number): BoardState {
const result = applyMove(state, { kind: 'play', color: state.toPlay, point: { x, y } });
if (!result.ok) throw new Error(`ожидался легальный ход: ${result.error}`);
return result.state;
}
describe('захват камней', () => {
it('захват одиночного камня в углу', () => {
// Чёрные окружают белый камень (0,0): последнее дамэ — (0,1).
const state = playMoves(9, [
{ x: 1, y: 0 },
{ x: 5, y: 5 },
{ x: 8, y: 8 },
{ x: 0, y: 0 },
]);
const result = applyMove(state, { kind: 'play', color: 'black', point: { x: 0, y: 1 } });
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.captured).toEqual([{ x: 0, y: 0 }]);
expect(cellAt(result.state, { x: 0, y: 0 })).toBe('empty');
expect(result.state.captures.black).toBe(1);
});
it('захват группы из двух камней', () => {
// Белая группа (1,0),(1,1); чёрные забирают последнее дамэ (1,2).
const state = playMoves(9, [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 2, y: 0 },
{ x: 1, y: 1 },
{ x: 0, y: 1 },
{ x: 8, y: 8 },
{ x: 2, y: 1 },
{ x: 8, y: 7 },
]);
const result = applyMove(state, { kind: 'play', color: 'black', point: { x: 1, y: 2 } });
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.captured).toHaveLength(2);
expect(countStones(result.state, 'white')).toBe(2);
expect(countStones(result.state, 'black')).toBe(5);
});
});
describe('самоубийство', () => {
it('самоубийство без захвата запрещено', () => {
// Угол (0,0): оба соседа белые — у чёрной группы не будет дамэ.
const state = playMoves(9, [
{ x: 5, y: 5 },
{ x: 0, y: 1 },
{ x: 5, y: 6 },
{ x: 1, y: 0 },
]);
const result = applyMove(state, { kind: 'play', color: 'black', point: { x: 0, y: 0 } });
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.state).toBe(state);
expect(result.reason).toBe('suicide');
expect(result.error).toContain('самоубийство');
});
it('самоубийство с захватом легально', () => {
// Белый камень (1,1) в атари; чёрный ход (1,2) окружён белыми,
// но снимает камень — у чёрной группы появляется дамэ.
const state = playMoves(9, [
{ x: 1, y: 0 },
{ x: 1, y: 1 },
{ x: 0, y: 1 },
{ x: 0, y: 2 },
{ x: 2, y: 1 },
{ x: 2, y: 2 },
{ x: 8, y: 8 },
{ x: 1, y: 3 },
]);
const result = applyMove(state, { kind: 'play', color: 'black', point: { x: 1, y: 2 } });
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.captured).toEqual([{ x: 1, y: 1 }]);
const group = groupAt(result.state, { x: 1, y: 2 });
expect(group?.liberties).toBe(1);
});
});
/**
* Классическая форма ко: белый камень (1,1) в атари, чёрные берут его ходом
* (2,1); точка ко (1,1).
*/
function koPosition(): BoardState {
const before = playMoves(9, [
{ x: 1, y: 0 },
{ x: 2, y: 0 },
{ x: 0, y: 1 },
{ x: 1, y: 1 },
{ x: 1, y: 2 },
{ x: 3, y: 1 },
{ x: 8, y: 8 },
{ x: 2, y: 2 },
]);
return mustPlay(before, 2, 1);
}
describe('ко и суперко', () => {
it('взятие ко ставит koPoint и снимает один камень', () => {
const state = koPosition();
expect(state.koPoint).toEqual({ x: 1, y: 1 });
expect(state.captures.black).toBe(1);
});
it('немедленный обратный захват запрещён (простое ко)', () => {
const state = koPosition();
const result = applyMove(state, { kind: 'play', color: 'white', point: { x: 1, y: 1 } });
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe('ko');
expect(result.error).toContain('ко');
});
it('после промежуточных ходов обратный захват разрешён', () => {
let state = koPosition();
state = mustPlay(state, 5, 5);
state = mustPlay(state, 6, 6);
const result = applyMove(state, { kind: 'play', color: 'white', point: { x: 1, y: 1 } });
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.captured).toEqual([{ x: 2, y: 1 }]);
expect(result.state.koPoint).toEqual({ x: 2, y: 1 });
});
it('суперко: повтор позиции из глубины истории запрещён', () => {
// Моделируем цикл длиной >2 ходов (например, двойное ко): простое ко
// снято (koPoint=null), но позиция после хода уже встречалась в партии.
const state = koPosition();
const replay: BoardState = { ...state, koPoint: null };
const result = applyMove(replay, { kind: 'play', color: 'white', point: { x: 1, y: 1 } });
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe('superko');
expect(result.error).toContain('суперко');
// Контроль: без старого хэша в истории тот же ход легален.
const fresh: BoardState = { ...state, koPoint: null, positionHashes: [] };
expect(applyMove(fresh, { kind: 'play', color: 'white', point: { x: 1, y: 1 } }).ok).toBe(true);
});
});
describe('пассы и resign', () => {
it('два паса подряд завершают партию', () => {
let state = playMoves(9, [{ x: 4, y: 4 }, 'pass']);
expect(state.over).toBe(false);
state = playMoves(9, [{ x: 4, y: 4 }, 'pass', 'pass']);
expect(state.over).toBe(true);
const after = applyMove(state, { kind: 'play', color: 'white', point: { x: 0, y: 0 } });
expect(after.ok).toBe(false);
});
it('resign завершает партию', () => {
const state = createBoard(9);
const result = applyMove(state, { kind: 'resign', color: 'black' });
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.state.over).toBe(true);
});
it('ход не того цвета отклоняется', () => {
const state = createBoard(9);
const result = applyMove(state, { kind: 'play', color: 'white', point: { x: 0, y: 0 } });
expect(result.ok).toBe(false);
});
it('входное состояние не мутируется', () => {
const state = playMoves(9, [{ x: 4, y: 4 }]);
const gridBefore = [...state.grid];
mustPlay(state, 5, 5);
expect([...state.grid]).toEqual(gridBefore);
expect(state.toPlay).toBe('white');
});
});

196
packages/core/src/rules.ts Normal file
View file

@ -0,0 +1,196 @@
/**
* Движок правил: постановка камня, захваты, самоубийство, ко и суперко.
* applyMove неизменяем: при любом исходе входное состояние не мутируется.
*
* Инварианты (проверяются property-тестами):
* (а) после легального хода число камней = прежнее + 1 captured.length;
* (б) снимаемые группы не имеют дамэ; каждая группа на доске имеет дамэ;
* (в) повтор любой предыдущей позиции (хэш позиция+toPlay) запрещён.
*/
import type { BoardState, CellState, Color, Group, Move, Point } from './board.js';
import {
cellAt,
cellIndex,
groupAt,
hashPosition,
inBounds,
neighbors,
opposite,
pointKey,
} from './board.js';
/** Машиночитаемая причина отказа — для ветвления UI (подсказки, звуки). */
export type MoveError =
'out-of-bounds' | 'occupied' | 'suicide' | 'ko' | 'superko' | 'game-over' | 'wrong-turn';
export type MoveResult =
| { readonly ok: true; readonly state: BoardState; readonly captured: ReadonlyArray<Point> }
| {
readonly ok: false;
readonly state: BoardState;
readonly reason: MoveError;
readonly error: string; // человекочитаемое сообщение (ru)
};
function fail(state: BoardState, reason: MoveError, error: string): MoveResult {
return { ok: false, state, reason, error };
}
interface Reject {
readonly reason: MoveError;
readonly error: string;
}
/** Проверки, общие для всех видов хода. */
function checkCommon(state: BoardState, move: Move): Reject | null {
if (state.over) return { reason: 'game-over', error: 'партия уже завершена' };
if (move.color !== state.toPlay)
return { reason: 'wrong-turn', error: 'сейчас ход другого цвета' };
return null;
}
/** Проверки постановки камня до симуляции. */
function checkPlay(state: BoardState, point: Point): Reject | null {
if (!inBounds(state.size, point))
return { reason: 'out-of-bounds', error: 'точка за пределами доски' };
if (cellAt(state, point) !== 'empty') return { reason: 'occupied', error: 'клетка занята' };
if (state.koPoint !== null && pointKey(state.koPoint) === pointKey(point)) {
return { reason: 'ko', error: 'простое ко: немедленный обратный захват запрещён' };
}
return null;
}
/** Группы противника без дамэ вокруг поставленного камня. */
function findCaptured(state: BoardState, point: Point, enemy: Color): Group[] {
const captured: Group[] = [];
const seen = new Set<string>();
for (const next of neighbors(state.size, point)) {
if (cellAt(state, next) !== enemy) continue;
const group = groupAt(state, next);
if (group === null || group.liberties > 0) continue;
const key = group.stones.map(pointKey).sort().join(';');
if (!seen.has(key)) {
seen.add(key);
captured.push(group);
}
}
return captured;
}
/** Снятие групп с сетки (новый массив). */
function removeGroups(
grid: ReadonlyArray<CellState>,
size: BoardState['size'],
groups: ReadonlyArray<Group>,
): CellState[] {
const next = [...grid];
for (const group of groups) {
for (const stone of group.stones) next[cellIndex(size, stone)] = 'empty';
}
return next;
}
/** Точка простого ко: снят ровно один камень, а ставящая группа — один камень с одним дамэ. */
function detectKoPoint(
state: BoardState,
point: Point,
captured: ReadonlyArray<Group>,
): Point | null {
if (captured.length !== 1) return null;
const group = groupAt(state, point);
if (group === null || group.stones.length !== 1 || group.liberties !== 1) return null;
const stone = captured[0]?.stones[0];
return stone ?? null;
}
/** Сборка состояния после легальной постановки камня. */
function buildPlayState(
state: BoardState,
move: Extract<Move, { kind: 'play' }>,
grid: BoardState['grid'],
capturedCount: number,
koPoint: Point | null,
): BoardState {
const next: BoardState = {
size: state.size,
grid,
toPlay: opposite(move.color),
captures: {
black: state.captures.black + (move.color === 'black' ? capturedCount : 0),
white: state.captures.white + (move.color === 'white' ? capturedCount : 0),
},
koPoint,
positionHashes: state.positionHashes,
over: false,
};
return { ...next, positionHashes: [...state.positionHashes, hashPosition(next)] };
}
function applyPlay(state: BoardState, move: Extract<Move, { kind: 'play' }>): MoveResult {
const reject = checkPlay(state, move.point);
if (reject !== null) return fail(state, reject.reason, reject.error);
const placed = [...state.grid];
placed[cellIndex(state.size, move.point)] = move.color;
const draft: BoardState = { ...state, grid: placed };
const capturedGroups = findCaptured(draft, move.point, opposite(move.color));
const cleared = removeGroups(placed, state.size, capturedGroups);
const afterCapture: BoardState = { ...draft, grid: cleared };
const ownGroup = groupAt(afterCapture, move.point);
if (ownGroup !== null && ownGroup.liberties === 0) {
return fail(state, 'suicide', 'самоубийство: у группы не остаётся дамэ');
}
const next = buildPlayState(
state,
move,
cleared,
capturedGroups.reduce((sum, group) => sum + group.stones.length, 0),
detectKoPoint(afterCapture, move.point, capturedGroups),
);
const hash = next.positionHashes[next.positionHashes.length - 1] ?? '';
if (state.positionHashes.includes(hash)) {
return fail(state, 'superko', 'суперко: позиция уже встречалась в партии');
}
const captured = capturedGroups.flatMap((group) => [...group.stones]);
return { ok: true, state: next, captured };
}
function applyPass(state: BoardState, move: Extract<Move, { kind: 'pass' }>): MoveResult {
const next: BoardState = {
...state,
toPlay: opposite(move.color),
koPoint: null,
over: previousWasPass(state),
};
const withHash: BoardState = {
...next,
positionHashes: [...state.positionHashes, hashPosition(next)],
};
return { ok: true, state: withHash, captured: [] };
}
/** Прошлый ход был пасом: сетка совпадает с сеткой позиции ход назад. */
function previousWasPass(state: BoardState): boolean {
const hashes = state.positionHashes;
if (hashes.length < 2) return false;
const gridNow = state.grid.join(',');
const previous = hashes[hashes.length - 2] ?? '';
return previous.startsWith(`${gridNow}|`);
}
function applyResign(state: BoardState, move: Extract<Move, { kind: 'resign' }>): MoveResult {
const next: BoardState = { ...state, toPlay: opposite(move.color), koPoint: null, over: true };
return { ok: true, state: next, captured: [] };
}
/**
* Постановка хода. Два паса подряд или resign over: true.
* Самоубийство запрещено, кроме хода, который захватывает чужую группу
* (тогда у своей группы появляются дамэ от освобождённых клеток).
*/
export function applyMove(state: BoardState, move: Move): MoveResult {
const reject = checkCommon(state, move);
if (reject !== null) return fail(state, reject.reason, reject.error);
if (move.kind === 'play') return applyPlay(state, move);
if (move.kind === 'pass') return applyPass(state, move);
return applyResign(state, move);
}

View file

@ -0,0 +1,120 @@
/**
* Юнит-тесты подсчёта (Tromp-Taylor) и эвристики мёртвых групп.
*/
import { describe, expect, it } from 'vitest';
import type { BoardState, Point } from './index.js';
import { defaultKomi, pointKey, scorePosition, suggestDeadGroups } from './index.js';
import { playMoves } from './test-utils.js';
/**
* Позиция «стена»: чёрные заняли колонку x=2, белые колонку x=6.
* Чёрная территория слева (2×9=18) + 9 камней = 27; белая справа столько же;
* средняя полоса (3×9=27) граничит с обоими цветами нейтральная.
*/
function wallPosition(): BoardState {
const moves: Array<Point | 'pass'> = [];
for (let y = 0; y < 9; y += 1) moves.push({ x: 2, y }, { x: 6, y });
return playMoves(9, moves);
}
describe('scorePosition', () => {
it('простая позиция: площадь = камни + территория, нейтральные никому', () => {
const result = scorePosition(wallPosition(), { komi: 5.5, dead: new Set() });
expect(result.black).toBe(27);
expect(result.white).toBe(27);
expect(result.neutral).toHaveLength(27);
expect(result.margin).toBe(-5.5);
expect(result.winner).toBe('white');
});
it('ничья: margin 0 → draw', () => {
const result = scorePosition(wallPosition(), { komi: 0, dead: new Set() });
expect(result.margin).toBe(0);
expect(result.winner).toBe('draw');
});
it('сэки: общие дамэ двух живых групп — нейтральные пункты', () => {
// Две группы без глаз делят дамэ (1,0) и (1,1): играть туда — проиграть.
const state = playMoves(9, [
{ x: 0, y: 0 },
{ x: 2, y: 0 },
{ x: 0, y: 1 },
{ x: 2, y: 1 },
]);
const result = scorePosition(state, { komi: 6.5, dead: new Set() });
const neutralKeys = new Set(result.neutral.map(pointKey));
expect(neutralKeys.has('1,0')).toBe(true);
expect(neutralKeys.has('1,1')).toBe(true);
// Обе группы остаются на доске и считаются камнями своих цветов.
expect(result.black).toBe(2);
expect(result.white).toBe(2);
});
it('мёртвые камни снимаются перед подсчётом', () => {
// Одинокий чёрный камень (4,4) в белом окружении с единственным дамэ (4,5).
const state = playMoves(9, [
{ x: 4, y: 4 },
{ x: 3, y: 4 },
{ x: 8, y: 8 },
{ x: 5, y: 4 },
{ x: 8, y: 7 },
{ x: 4, y: 3 },
{ x: 8, y: 6 },
{ x: 3, y: 5 },
{ x: 0, y: 0 },
{ x: 5, y: 5 },
{ x: 1, y: 1 },
{ x: 4, y: 6 },
]);
const before = scorePosition(state, { komi: 0, dead: new Set() });
expect(before.black).toBe(6); // шесть чёрных камней на доске
const after = scorePosition(state, { komi: 0, dead: new Set(['4,4']) });
expect(after.black).toBe(5); // мёртвый камень снят
// Освобождённые клетки (4,4) и (4,5) стали белой территорией.
expect(after.white - before.white).toBe(2);
});
it('defaultKomi: 6.5 для 19×19, 5.5 для 9×9', () => {
expect(defaultKomi(19)).toBe(6.5);
expect(defaultKomi(9)).toBe(5.5);
});
});
describe('suggestDeadGroups', () => {
it('предлагает группу без двух глаз в чужой территории', () => {
// Чёрная группа (4,4),(4,5) в белом кольце с единственным «полу-глазом» (4,6).
const state = playMoves(9, [
{ x: 4, y: 4 },
{ x: 3, y: 4 },
{ x: 4, y: 5 },
{ x: 5, y: 4 },
{ x: 0, y: 0 },
{ x: 4, y: 3 },
{ x: 0, y: 1 },
{ x: 3, y: 5 },
{ x: 0, y: 2 },
{ x: 5, y: 5 },
{ x: 0, y: 3 },
{ x: 3, y: 6 },
{ x: 0, y: 4 },
{ x: 5, y: 6 },
{ x: 0, y: 5 },
{ x: 4, y: 7 },
]);
const suggested = suggestDeadGroups(state);
const keys = suggested.map((group) => group.map(pointKey).sort().join(';'));
expect(keys).toContain('4,4;4,5');
// Белое кольцо и чёрные камни на открытом пространстве не предлагаются.
expect(suggested.flat()).toHaveLength(2);
});
it('живые группы в открытом пространстве не предлагаются', () => {
const state = playMoves(9, [
{ x: 2, y: 2 },
{ x: 6, y: 6 },
{ x: 2, y: 3 },
{ x: 6, y: 5 },
]);
expect(suggestDeadGroups(state)).toHaveLength(0);
});
});

224
packages/core/src/score.ts Normal file
View file

@ -0,0 +1,224 @@
/**
* Подсчёт по китайским правилам (площадь, Tromp-Taylor) и эвристика мёртвых групп.
*
* Инварианты (property-тесты): black + white + neutral.length = size*size;
* winner 'draw' тогда и только тогда, когда margin === 0.
*/
import type { BoardState, Color, Group, Point } from './board.js';
import { cellAt, cellIndex, groupAt, inBounds, neighbors, opposite, pointKey } from './board.js';
export interface ScoreOptions {
readonly komi: number;
readonly dead: ReadonlySet<string>;
}
export interface ScoreResult {
readonly black: number;
readonly white: number;
readonly komi: number;
readonly margin: number;
readonly winner: Color | 'draw';
readonly neutral: ReadonlyArray<Point>;
}
/** Коми по умолчанию: 19×19 → 6.5, 9×9 и 13×13 → 5.5 (решение владельца 2026-08-05). */
export function defaultKomi(size: BoardState['size']): number {
if (size === 9) return 5.5;
return 6.5;
}
interface Region {
readonly points: Point[];
readonly borders: ReadonlySet<Color>;
}
/** Сетка после снятия мёртвых камней (новый массив). */
function clearedGrid(state: BoardState, dead: ReadonlySet<string>): BoardState['grid'] {
const grid = [...state.grid];
for (let index = 0; index < grid.length; index += 1) {
const point = { x: index % state.size, y: Math.floor(index / state.size) };
if (grid[index] !== 'empty' && dead.has(pointKey(point))) grid[index] = 'empty';
}
return grid;
}
/** Соседние цвета камней вокруг клетки. */
function borderingColors(state: BoardState, grid: BoardState['grid'], point: Point): Color[] {
const colors: Color[] = [];
for (const next of neighbors(state.size, point)) {
const cell = grid[cellIndex(state.size, next)] ?? 'empty';
if (cell !== 'empty' && !colors.includes(cell)) colors.push(cell);
}
return colors;
}
/** Все связные пустые области и множества цветов камней на их границах. */
function emptyRegions(state: BoardState, grid: BoardState['grid']): Region[] {
const regions: Region[] = [];
const seen = new Set<number>();
for (let index = 0; index < grid.length; index += 1) {
if (grid[index] !== 'empty' || seen.has(index)) continue;
regions.push(fillRegion(state, grid, index, seen));
}
return regions;
}
/** Flood fill одной пустой области; отмечает клетки в seen. */
function fillRegion(
state: BoardState,
grid: BoardState['grid'],
start: number,
seen: Set<number>,
): Region {
const points: Point[] = [];
const borders = new Set<Color>();
const stack: Point[] = [{ x: start % state.size, y: Math.floor(start / state.size) }];
seen.add(start);
while (stack.length > 0) {
const current = stack.pop() as Point;
points.push(current);
for (const color of borderingColors(state, grid, current)) borders.add(color);
for (const next of neighbors(state.size, current)) {
const index = cellIndex(state.size, next);
if (grid[index] === 'empty' && !seen.has(index)) {
seen.add(index);
stack.push(next);
}
}
}
return { points, borders };
}
/** Счёт камней на сетке по цветам. */
function stoneCounts(grid: BoardState['grid']): { black: number; white: number } {
let black = 0;
let white = 0;
for (const cell of grid) {
if (cell === 'black') black += 1;
if (cell === 'white') white += 1;
}
return { black, white };
}
/**
* Площадь: свои камни + территория (пустые области, граничащие только с одним
* цветом). Области на границе двух цветов (дамэ, сэки) нейтральные, никому.
* Мёртвые камни (dead, ключи "x,y") снимаются до подсчёта.
*/
export function scorePosition(state: BoardState, options: ScoreOptions): ScoreResult {
const grid = clearedGrid(state, options.dead);
const stones = stoneCounts(grid);
let blackTerritory = 0;
let whiteTerritory = 0;
const neutral: Point[] = [];
for (const region of emptyRegions(state, grid)) {
if (region.borders.size === 1 && region.borders.has('black'))
blackTerritory += region.points.length;
else if (region.borders.size === 1) whiteTerritory += region.points.length;
else neutral.push(...region.points);
}
const black = stones.black + blackTerritory;
const white = stones.white + whiteTerritory;
const margin = black - white - options.komi;
const winner: Color | 'draw' = margin > 0 ? 'black' : margin < 0 ? 'white' : 'draw';
return { black, white, komi: options.komi, margin, winner, neutral };
}
interface DetailedRegion {
readonly points: Point[];
readonly friendlyStones: ReadonlySet<string>;
readonly enemyStones: ReadonlySet<string>;
}
/** Пустая область с камнями обоих цветов на границе (для эвристики мёртвых групп). */
function fillDetailed(
state: BoardState,
color: Color,
start: number,
seen: Set<number>,
): DetailedRegion {
const points: Point[] = [];
const friendlyStones = new Set<string>();
const enemyStones = new Set<string>();
const stack: Point[] = [{ x: start % state.size, y: Math.floor(start / state.size) }];
seen.add(start);
while (stack.length > 0) {
const current = stack.pop() as Point;
points.push(current);
for (const next of neighbors(state.size, current)) {
const cell = cellAt(state, next);
if (cell === color) friendlyStones.add(pointKey(next));
if (cell === opposite(color)) enemyStones.add(pointKey(next));
const index = cellIndex(state.size, next);
if (cell === 'empty' && !seen.has(index)) {
seen.add(index);
stack.push(next);
}
}
}
return { points, friendlyStones, enemyStones };
}
/** Уникальные пустые области у дамэ группы. */
function adjacentRegions(
state: BoardState,
color: Color,
stones: ReadonlyArray<Point>,
): DetailedRegion[] {
const regions: DetailedRegion[] = [];
const seen = new Set<number>();
for (const stone of stones) {
for (const next of neighbors(state.size, stone)) {
const index = cellIndex(state.size, next);
if (cellAt(state, next) !== 'empty' || seen.has(index)) continue;
regions.push(fillDetailed(state, color, index, seen));
}
}
return regions;
}
/**
* Грубая эвристика «группа мертва»: нет связи со своими камнями вне группы,
* нет двух глаз (глаз область, граничащая только с камнями группы), все
* прочие области у дамэ граничат с чужими камнями, и их суммарный размер
* не больше числа камней группы («места на два глаза не хватает»).
*/
function isCrudelyDead(state: BoardState, color: Color, group: Group): boolean {
const groupKeys = new Set(group.stones.map(pointKey));
let eyes = 0;
let contactArea = 0;
for (const region of adjacentRegions(state, color, group.stones)) {
const hasOutsideFriendly = [...region.friendlyStones].some((key) => !groupKeys.has(key));
if (hasOutsideFriendly) return false;
if (region.enemyStones.size === 0) {
if (region.friendlyStones.size === 0) return false; // открытое пространство
eyes += 1;
} else {
contactArea += region.points.length;
}
}
if (eyes >= 2) return false;
return contactArea <= group.stones.length;
}
/**
* Грубая эвристика «мёртвая группа»: группа в чужой территории, у которой
* не набирается двух глаз. Ручная корректировка за UI (см. план этапа 1).
*/
export function suggestDeadGroups(state: BoardState): ReadonlyArray<ReadonlyArray<Point>> {
const suggested: Point[][] = [];
const visited = 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 (visited.has(pointKey(point)) || !inBounds(state.size, point)) continue;
const group = groupAt(state, point);
if (group === null) continue;
for (const stone of group.stones) visited.add(pointKey(stone));
const color = cellAt(state, point);
if (color === 'empty') continue;
if (isCrudelyDead(state, color, group)) suggested.push([...group.stones]);
}
}
return suggested;
}

View file

@ -0,0 +1,89 @@
/**
* Юнит-тесты SGF FF[4]: разбор, генерация, эскейпинг, вариации, разметка, ошибки.
*/
import { describe, expect, it } from 'vitest';
import { findNode, parseSgf, SgfError, serializeSgf } from './index.js';
describe('parseSgf', () => {
it('простая партия: SZ/KM/PB/PW/RE, ходы, пасс', () => {
const tree = parseSgf('(;GM[1]FF[4]SZ[9]KM[5.5]PB[Аня]PW[Боря]RE[W+R];B[aa];W[bb];B[])');
expect(tree.size).toBe(9);
expect(tree.komi).toBe(5.5);
expect(tree.meta).toEqual({ blackName: 'Аня', whiteName: 'Боря', result: 'W+R' });
const passNode = findNode(tree.root, 3);
expect(passNode?.move).toEqual({ kind: 'pass', color: 'black' });
expect(passNode?.state.over).toBe(false);
expect(tree.currentId).toBe(3);
});
it('комментарии и разметка LB/TR/SQ/CR', () => {
const tree = parseSgf('(;SZ[9];B[aa]C[плохой]LB[cc:1][dd:2]TR[ee]SQ[ff]CR[gg])');
const node = findNode(tree.root, 1);
expect(node?.comment).toBe('плохой');
expect(node?.markup.labels).toEqual([
{ point: { x: 2, y: 2 }, text: '1' },
{ point: { x: 3, y: 3 }, text: '2' },
]);
expect(node?.markup.triangles).toEqual([{ x: 4, y: 4 }]);
expect(node?.markup.squares).toEqual([{ x: 5, y: 5 }]);
expect(node?.markup.circles).toEqual([{ x: 6, y: 6 }]);
});
it('вариации превращаются в ветвления дерева', () => {
const tree = parseSgf('(;SZ[9];B[aa](;W[bb])(;W[cc];B[dd]))');
const first = findNode(tree.root, 1);
expect(first?.children).toHaveLength(2);
expect(first?.children[1]?.children).toHaveLength(1);
// currentId — конец главной линии (первая вариация).
expect(tree.currentId).toBe(2);
});
it('эскейпинг \\] и \\\\ в значениях', () => {
const tree = parseSgf('(;SZ[9];B[aa]C[а \\]б\\\\в])');
expect(findNode(tree.root, 1)?.comment).toBe('а ]б\\в');
});
it('SgfError с offset на битом входе', () => {
let caught: unknown;
try {
parseSgf('(;GM[2]SZ[9])');
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(SgfError);
expect((caught as SgfError).offset).toBe(1);
expect(() => parseSgf('мусор')).toThrow(SgfError);
expect(() => parseSgf('(;SZ[9];B[zz])')).toThrow(SgfError);
});
it('нелегальный ход в SGF → SgfError с offset хода', () => {
let caught: unknown;
try {
parseSgf('(;SZ[9];B[aa];W[aa])');
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(SgfError);
expect((caught as SgfError).offset).toBe(13);
});
});
describe('serializeSgf', () => {
it('генерирует корневые свойства и ходы', () => {
const tree = parseSgf('(;GM[1]FF[4]SZ[9]KM[5.5];B[aa];W[];B[bb])');
expect(serializeSgf(tree)).toBe('(;GM[1]FF[4]SZ[9]KM[5.5];B[aa];W[];B[bb])');
});
it('эскейпит ] и \\ при записи', () => {
const tree = parseSgf('(;SZ[9];B[aa]C[а \\]б\\\\в])');
expect(serializeSgf(tree)).toBe('(;GM[1]FF[4]SZ[9]KM[5.5];B[aa]C[а \\]б\\\\в])');
});
it('roundtrip: parse(serialize(tree)) даёт эквивалентное дерево', () => {
const source = '(;GM[1]FF[4]SZ[13]KM[6.5]PB[А]PW[Б];B[aa]C[ход];W[bb](;B[cc])(;B[dd]TR[ee]))';
const first = parseSgf(source);
const second = parseSgf(serializeSgf(first));
expect(serializeSgf(second)).toBe(serializeSgf(first));
expect(second.root).toEqual(first.root);
});
});

392
packages/core/src/sgf.ts Normal file
View file

@ -0,0 +1,392 @@
/**
* SGF FF[4]: парсер и генератор.
* Поддержка: GM[1], SZ (9/13/19), KM, PB/PW, RE, C, B/W (пустое значение пасс),
* вариации, LB/TR/SQ/CR. Эскейпинг значений: `\]` `]`, `\\` `\`, прочее `\x``x`.
* Неизвестные свойства игнорируются. Читается первое дерево коллекции.
* Узел без B/W в середине цепочки трактуется как пасс текущего цвета.
* Ограничение: resign-узлы сериализуются как пасс (в SGF нет хода «сдаюсь»).
*
* Инвариант (property-тест): roundtrip parseSgf(serializeSgf(tree)) даёт
* эквивалентное дерево: serializeSgf(parseSgf(serializeSgf(tree))) === serializeSgf(tree).
*/
import type { BoardSize, Color, Move, Point } from './board.js';
import { createBoard, inBounds } from './board.js';
import type { GameMeta, GameNode, GameTree, Label, NodeMarkup } from './history.js';
import { applyMove } from './rules.js';
import { defaultKomi } from './score.js';
export class SgfError extends Error {
readonly offset: number;
constructor(message: string, offset: number) {
super(message);
this.name = 'SgfError';
this.offset = offset;
}
}
interface RawNode {
readonly props: ReadonlyMap<string, ReadonlyArray<string>>;
readonly offset: number;
}
interface RawTree {
readonly sequence: ReadonlyArray<RawNode>;
readonly children: ReadonlyArray<RawTree>;
}
interface Parser {
readonly text: string;
pos: number;
}
function skipWhitespace(parser: Parser): void {
while (parser.pos < parser.text.length && /\s/.test(parser.text[parser.pos] ?? '')) {
parser.pos += 1;
}
}
function peek(parser: Parser): string {
skipWhitespace(parser);
return parser.text[parser.pos] ?? '';
}
function fail(parser: Parser, message: string): never {
throw new SgfError(message, parser.pos);
}
/** Значение в скобках `[...]` с обработкой эскейпинга. */
function parseValue(parser: Parser): string {
if (peek(parser) !== '[') fail(parser, 'ожидалось значение свойства в [ ]');
parser.pos += 1;
let value = '';
while (parser.pos < parser.text.length) {
const char = parser.text[parser.pos] ?? '';
if (char === '\\') {
const escaped = parser.text[parser.pos + 1];
if (escaped === undefined) fail(parser, 'эскейп в конце файла');
if (escaped !== '\n' && escaped !== '\r') value += escaped;
parser.pos += 2;
continue;
}
if (char === ']') {
parser.pos += 1;
return value;
}
value += char;
parser.pos += 1;
}
return fail(parser, 'незакрытое значение свойства');
}
function parseProperty(parser: Parser, props: Map<string, string[]>): void {
let ident = '';
while (/[A-Za-z]/.test(parser.text[parser.pos] ?? '')) {
ident += parser.text[parser.pos];
parser.pos += 1;
}
if (ident === '') fail(parser, 'ожидался идентификатор свойства');
const values: string[] = [];
while (peek(parser) === '[') values.push(parseValue(parser));
if (values.length === 0) fail(parser, `свойство ${ident} без значения`);
props.set(ident.toUpperCase(), values);
}
function parseNode(parser: Parser): RawNode {
if (peek(parser) !== ';') fail(parser, 'ожидался узел (;)');
const offset = parser.pos;
parser.pos += 1;
const props = new Map<string, string[]>();
for (;;) {
const next = peek(parser);
if (/[A-Za-z]/.test(next)) parseProperty(parser, props);
else break;
}
return { props, offset };
}
function parseSequence(parser: Parser): RawNode[] {
const nodes: RawNode[] = [parseNode(parser)];
while (peek(parser) === ';') nodes.push(parseNode(parser));
return nodes;
}
function parseRawTree(parser: Parser): RawTree {
if (peek(parser) !== '(') fail(parser, 'ожидалось дерево (…)');
parser.pos += 1;
const sequence = parseSequence(parser);
const children: RawTree[] = [];
while (peek(parser) === '(') children.push(parseRawTree(parser));
if (peek(parser) !== ')') fail(parser, 'ожидался конец дерева )');
parser.pos += 1;
return { sequence, children };
}
/** Координаты SGF: буквы a..s, x — колонка, y — строка. */
function parsePoint(value: string, offset: number, size: BoardSize): Point {
if (value.length !== 2) throw new SgfError(`некорректная точка «${value}»`, offset);
const point = { x: value.charCodeAt(0) - 97, y: value.charCodeAt(1) - 97 };
if (!inBounds(size, point)) throw new SgfError(`точка «${value}» вне доски`, offset);
return point;
}
/** Ход из свойств B/W узла; null — хода нет (корень или пасс-заглушка). */
function parseMove(node: RawNode, size: BoardSize): Move | null {
const black = node.props.get('B');
const white = node.props.get('W');
if (black !== undefined && white !== undefined) {
throw new SgfError('в узле одновременно B и W', node.offset);
}
const entry = black !== undefined ? { color: 'black' as const, values: black } : null;
const move = entry ?? (white !== undefined ? { color: 'white' as const, values: white } : null);
if (move === null) return null;
const value = move.values[0] ?? '';
if (value === '') return { kind: 'pass', color: move.color };
return { kind: 'play', color: move.color, point: parsePoint(value, node.offset, size) };
}
function parseLabels(values: ReadonlyArray<string>, offset: number, size: BoardSize): Label[] {
const labels: Label[] = [];
for (const value of values) {
const colon = value.indexOf(':');
if (colon < 0) throw new SgfError(`метка LB без текста «${value}»`, offset);
labels.push({
point: parsePoint(value.slice(0, colon), offset, size),
text: value.slice(colon + 1),
});
}
return labels;
}
function parsePointList(
values: ReadonlyArray<string> | undefined,
offset: number,
size: BoardSize,
): Point[] {
if (values === undefined) return [];
return values.map((value) => parsePoint(value, offset, size));
}
function parseMarkup(node: RawNode, size: BoardSize): NodeMarkup {
return {
labels: parseLabels(node.props.get('LB') ?? [], node.offset, size),
triangles: parsePointList(node.props.get('TR'), node.offset, size),
squares: parsePointList(node.props.get('SQ'), node.offset, size),
circles: parsePointList(node.props.get('CR'), node.offset, size),
};
}
interface RootProps {
readonly size: BoardSize;
readonly komi: number;
readonly meta: GameMeta;
}
/** Корневые свойства: GM[1], SZ, KM, PB/PW, RE. */
function parseRootProps(node: RawNode): RootProps {
const gm = node.props.get('GM');
if (gm !== undefined && gm[0] !== '1') {
throw new SgfError(`GM[${gm[0] ?? ''}] — поддерживается только GM[1] (Го)`, node.offset);
}
const size = parseSize(node);
const komi = parseKomi(node, size);
return {
size,
komi,
meta: {
blackName: node.props.get('PB')?.[0] ?? '',
whiteName: node.props.get('PW')?.[0] ?? '',
result: node.props.get('RE')?.[0] ?? null,
},
};
}
function parseSize(node: RawNode): BoardSize {
const raw = node.props.get('SZ')?.[0];
if (raw === undefined) return 19;
const size = Number(raw);
if (size !== 9 && size !== 13 && size !== 19) {
throw new SgfError(`SZ[${raw}] — поддерживаются доски 9/13/19`, node.offset);
}
return size;
}
function parseKomi(node: RawNode, size: BoardSize): number {
const raw = node.props.get('KM')?.[0];
if (raw === undefined) return defaultKomi(size);
const komi = Number(raw);
if (Number.isNaN(komi)) throw new SgfError(`KM[${raw}] — не число`, node.offset);
return komi;
}
interface BuildCtx {
readonly size: BoardSize;
nextId: number;
}
/** Состояние после хода узла; нелегальный ход → SgfError с offset узла. */
function applyNodeMove(state: GameNode['state'], move: Move, offset: number): GameNode['state'] {
const result = applyMove(state, move);
if (!result.ok) throw new SgfError(`нелегальный ход в SGF: ${result.error}`, offset);
return result.state;
}
/** Ход узла цепочки: из свойств B/W, либо пасс текущего цвета (узел без хода). */
function nodeMove(node: RawNode, ctx: BuildCtx, toPlay: Color): Move {
return parseMove(node, ctx.size) ?? { kind: 'pass', color: toPlay };
}
/** Сборка цепочки узлов: рекурсия по последовательности, вариации — в хвосте. */
function buildChain(
sequence: ReadonlyArray<RawNode>,
index: number,
children: ReadonlyArray<RawTree>,
parentState: GameNode['state'],
ctx: BuildCtx,
): GameNode {
const raw = sequence[index];
if (raw === undefined) throw new SgfError('пустая последовательность узлов', 0);
const move = nodeMove(raw, ctx, parentState.toPlay);
const state = applyNodeMove(parentState, move, raw.offset);
const id = ctx.nextId;
ctx.nextId += 1;
const nodeChildren: GameNode[] =
index + 1 < sequence.length
? [buildChain(sequence, index + 1, children, state, ctx)]
: children.map((child) => buildEntry(child, state, ctx));
return {
id,
move,
state,
comment: raw.props.get('C')?.[0] ?? '',
markup: parseMarkup(raw, ctx.size),
children: nodeChildren,
};
}
/** Первый узел дерева-вариации (родительское состояние — от точки ветвления). */
function buildEntry(raw: RawTree, parentState: GameNode['state'], ctx: BuildCtx): GameNode {
return buildChain(raw.sequence, 0, raw.children, parentState, ctx);
}
/** Корневой узел: без хода, состояние — пустая доска. */
function buildRoot(raw: RawTree, props: RootProps, ctx: BuildCtx): GameNode {
const first = raw.sequence[0];
if (first === undefined) throw new SgfError('пустое дерево', 0);
const initial = createBoard(props.size);
return {
id: 0,
move: null,
state: initial,
comment: first.props.get('C')?.[0] ?? '',
markup: parseMarkup(first, props.size),
children:
raw.sequence.length > 1
? [buildChain(raw.sequence, 1, raw.children, initial, ctx)]
: raw.children.map((child) => buildEntry(child, initial, ctx)),
};
}
/** Id последнего узла главной линии (первый ребёнок на каждом ветвлении). */
function mainLineLeafId(root: GameNode): number {
let node = root;
while (node.children.length > 0) {
const child = node.children[0];
if (child === undefined) break;
node = child;
}
return node.id;
}
/**
* Разбор SGF FF[4]. Читается первое дерево коллекции, хвост игнорируется.
* currentId результата конец главной линии; nextId число узлов.
*/
export function parseSgf(text: string): GameTree {
const parser: Parser = { text, pos: 0 };
if (peek(parser) !== '(') fail(parser, 'SGF должен начинаться с (');
const raw = parseRawTree(parser);
const first = raw.sequence[0];
if (first === undefined) throw new SgfError('пустое дерево', 0);
const props = parseRootProps(first);
const ctx: BuildCtx = { size: props.size, nextId: 1 };
const root = buildRoot(raw, props, ctx);
return {
size: props.size,
komi: props.komi,
root,
currentId: mainLineLeafId(root),
nextId: ctx.nextId,
meta: props.meta,
};
}
/** Эскейпинг значения: `\` → `\\`, `]` → `\]`. */
function escapeValue(value: string): string {
return value.replaceAll('\\', '\\\\').replaceAll(']', '\\]');
}
/** Координаты SGF из точки. */
function formatPoint(point: Point): string {
return String.fromCharCode(97 + point.x) + String.fromCharCode(97 + point.y);
}
function formatMove(move: Move | null): string {
if (move === null) return '';
const prop = move.color === 'black' ? 'B' : 'W';
if (move.kind === 'play') return `${prop}[${formatPoint(move.point)}]`;
return `${prop}[]`; // пасс; resign сериализуется как пасс (в SGF нет хода «сдаюсь»)
}
function formatMarkup(markup: NodeMarkup): string {
let out = '';
if (markup.labels.length > 0) {
const values = markup.labels
.map((label) => `[${formatPoint(label.point)}:${escapeValue(label.text)}]`)
.join('');
out += `LB${values}`;
}
out += formatMark('TR', markup.triangles);
out += formatMark('SQ', markup.squares);
out += formatMark('CR', markup.circles);
return out;
}
function formatMark(prop: string, points: ReadonlyArray<Point>): string {
if (points.length === 0) return '';
return `${prop}${points.map((point) => `[${formatPoint(point)}]`).join('')}`;
}
function formatRootProps(tree: GameTree): string {
let out = `GM[1]FF[4]SZ[${tree.size}]KM[${tree.komi}]`;
if (tree.meta.blackName !== '') out += `PB[${escapeValue(tree.meta.blackName)}]`;
if (tree.meta.whiteName !== '') out += `PW[${escapeValue(tree.meta.whiteName)}]`;
if (tree.meta.result !== null) out += `RE[${escapeValue(tree.meta.result)}]`;
return out;
}
function formatNode(node: GameNode, isRoot: boolean, tree: GameTree): string {
let out = ';';
if (isRoot) out += formatRootProps(tree);
out += formatMove(node.move);
if (node.comment !== '') out += `C[${escapeValue(node.comment)}]`;
out += formatMarkup(node.markup);
return out;
}
/** Цепочка от узла: линейный участок, затем вариации в скобках. */
function formatSequence(node: GameNode, isRoot: boolean, tree: GameTree): string {
let out = formatNode(node, isRoot, tree);
if (node.children.length === 1) {
const child = node.children[0];
if (child !== undefined) out += formatSequence(child, false, tree);
} else {
for (const child of node.children) out += `(${formatSequence(child, false, tree)})`;
}
return out;
}
/** Сериализация дерева в SGF FF[4]. */
export function serializeSgf(tree: GameTree): string {
return `(${formatSequence(tree.root, true, tree)})`;
}

View file

@ -0,0 +1,26 @@
/**
* Общие помощники тестов: построение позиций последовательностями ходов.
* Не является тестом; используется только из *.test.ts.
*/
import type { BoardSize, BoardState, Move, Point } from './index.js';
import { applyMove, createBoard } from './index.js';
/** Проигрывает последовательность ходов ('pass' — пасс); падает на нелегальном ходе. */
export function playMoves(size: BoardSize, moves: ReadonlyArray<Point | 'pass'>): BoardState {
let state = createBoard(size);
for (const entry of moves) {
const move: Move =
entry === 'pass'
? { kind: 'pass', color: state.toPlay }
: { kind: 'play', color: state.toPlay, point: entry };
const result = applyMove(state, move);
if (!result.ok) throw new Error(`тестовая последовательность нелегальна: ${result.error}`);
state = result.state;
}
return state;
}
/** Число камней заданного цвета на доске. */
export function countStones(state: BoardState, color: 'black' | 'white'): number {
return state.grid.filter((cell) => cell === color).length;
}

View file

@ -0,0 +1,119 @@
/**
* Wiring-тесты (gotcha-green-modules-dead-system): orphan-check публичного API
* и интеграционный прогон полной партии 9×9 через дерево, SGF и подсчёт.
*/
import { readdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import type { GameTree } from './index.js';
import {
appendMove,
createGame,
findNode,
goToNode,
parseSgf,
scorePosition,
serializeSgf,
} from './index.js';
const srcDir = dirname(fileURLToPath(import.meta.url));
/** Все .ts-файлы каталога src (плоско; подкаталогов в этапе 1 нет). */
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([]);
});
});
/** Полная партия 9×9 «стена»: колонки x=2 (чёрные) и x=6 (белые), затем два паса. */
function wallGame(): GameTree {
let tree = createGame({ size: 9, blackName: 'Чёрные', whiteName: 'Белые' });
for (let y = 0; y < 9; y += 1) {
for (const x of [2, 6]) {
const color = tree.root && findNode(tree.root, tree.currentId)?.state.toPlay;
const result = appendMove(tree, {
kind: 'play',
color: color ?? 'black',
point: { x, y },
});
if (!result.ok) throw new Error(`ход (${x},${y}) нелегален: ${result.error}`);
tree = result.tree;
}
}
return tree;
}
function mustPass(tree: GameTree): GameTree {
const state = findNode(tree.root, tree.currentId)?.state;
const result = appendMove(tree, { kind: 'pass', color: state?.toPlay ?? 'black' });
if (!result.ok) throw new Error(`пасс нелегален: ${result.error}`);
return result.tree;
}
describe('интеграционный прогон: полная партия 9×9', () => {
it('от создания до двух пасов, подсчёта и roundtrip SGF', () => {
let tree = wallGame();
tree = mustPass(tree);
expect(findNode(tree.root, tree.currentId)?.state.over).toBe(false);
tree = mustPass(tree);
const final = findNode(tree.root, tree.currentId);
expect(final?.state.over).toBe(true);
const score = scorePosition(final?.state ?? tree.root.state, {
komi: tree.komi,
dead: new Set(),
});
expect(score).toMatchObject({ black: 27, white: 27, winner: 'white', margin: -5.5 });
expect(score.neutral).toHaveLength(27);
// Roundtrip SGF всей партии.
const restored = parseSgf(serializeSgf(tree));
expect(serializeSgf(restored)).toBe(serializeSgf(tree));
expect(findNode(restored.root, restored.currentId)?.state.grid).toEqual(final?.state.grid);
// Навигация: корень нетронут, от корня строится ветка.
expect(tree.root.state.grid.every((cell) => cell === 'empty')).toBe(true);
const branched = appendMove(goToNode(tree, 0), {
kind: 'play',
color: 'black',
point: { x: 4, y: 4 },
});
expect(branched.ok).toBe(true);
if (branched.ok) expect(branched.tree.root.children).toHaveLength(2);
});
});

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src"]
}