/** * Подсчёт по китайским правилам (площадь, 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; } export interface ScoreResult { readonly black: number; readonly white: number; readonly komi: number; readonly margin: number; readonly winner: Color | 'draw'; readonly neutral: ReadonlyArray; } /** Коми по умолчанию: 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; } /** Сетка после снятия мёртвых камней (новый массив). */ function clearedGrid(state: BoardState, dead: ReadonlySet): 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(); 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, ): Region { const points: Point[] = []; const borders = new Set(); 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; readonly enemyStones: ReadonlySet; } /** Пустая область с камнями обоих цветов на границе (для эвристики мёртвых групп). */ function fillDetailed( state: BoardState, color: Color, start: number, seen: Set, ): DetailedRegion { const points: Point[] = []; const friendlyStones = new Set(); const enemyStones = new Set(); 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, ): DetailedRegion[] { const regions: DetailedRegion[] = []; const seen = new Set(); 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> { const suggested: Point[][] = []; const visited = new Set(); 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; }