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
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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue