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