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

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

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

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

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