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