go-learn/packages/core/src/score.ts
sab.code.lab 1c85091186 chore: восстановление репозитория из снапшота v0.3.1
Прежняя git-история утрачена при переносе проекта на машину владельца
(снапшот без .git). Хэши коммитов в docs/reports/* относятся к утраченной
истории.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 09:34:02 +02:00

224 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Подсчёт по китайским правилам (площадь, 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;
}