diff --git a/.eslintrc.js b/.eslintrc.js index a1c9ab6b..b8a3ca97 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -94,7 +94,7 @@ module.exports = { rules: { "file-header": [ true, - "([Cc]opyright ([(][Cc][)])?\\s*[Oo]nline-[gG]o.com)|(bin/env)", // cspell: disable-line + "([Cc]opyright ([(][Cc][)]))|(bin/env)", // cspell: disable-line ], "import-spacing": true, "whitespace": [ diff --git a/.vscode/cspell.json b/.vscode/cspell.json index 051fea6d..9893224c 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -61,6 +61,7 @@ "goban", "Gobans", "goquest", + "goscorer", "groupify", "Gulpfile", "hane", @@ -89,6 +90,7 @@ "kyus", "Leela", "lerp", + "lightvector", "linebreak", "localstorage", "malk", @@ -170,6 +172,7 @@ "unhighlight", "unitify", "Unranked", + "unscorable", "unstarted", "uservoice", "usgc", @@ -187,7 +190,5 @@ "zoomable" ], "language": "en,en-GB", - "ignorePaths": [ - "test/autoscore_test_files" - ] + "ignorePaths": ["test/autoscore_test_files", "src/goscorer"] } diff --git a/Makefile b/Makefile index 62f5f0c4..cac3c7d2 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,11 @@ SLACK_WEBHOOK=$(shell cat ../ogs/.slack-webhook) all dev: yarn run dev + +build lib types: + yarn run build-debug + yarn run build-production + cp -Rp lib/src/* lib/ lint: yarn run lint @@ -20,9 +25,7 @@ publish push: publish_npm upload_to_cdn notify beta: beta_npm upload_to_cdn -beta_npm: - yarn run build-debug - yarn run build-production +beta_npm: build yarn publish --tag beta ./ notify: @@ -30,9 +33,7 @@ notify: VERSION=`git describe --long`; \ curl -X POST -H 'Content-type: application/json' --data '{"text":"'"[GOBAN] $$VERSION $$MSG"'"}' $(SLACK_WEBHOOK) -publish_npm: - yarn run build-debug - yarn run build-production +publish_npm: build yarn publish ./ upload_to_cdn: @@ -44,4 +45,4 @@ upload_to_cdn: cp lib/engine.min.js* deployment-staging-area gsutil -m rsync -r deployment-staging-area/ gs://ogs-site-files/goban/`node -pe 'JSON.parse(require("fs").readFileSync("package.json")).version'`/ -.PHONY: doc docs test clean all dev typedoc publich push +.PHONY: doc build docs test clean all dev typedoc publich push lib types diff --git a/src/GoEngine.ts b/src/GoEngine.ts index 22bc9d26..32b394ba 100644 --- a/src/GoEngine.ts +++ b/src/GoEngine.ts @@ -28,11 +28,13 @@ import { JGOFMove, JGOFPlayerSummary, JGOFIntersection, + JGOFSealingIntersection, } from "./JGOF"; import { AdHocPackedMove } from "./AdHocFormat"; import { _ } from "./translate"; import { EventEmitter } from "eventemitter3"; import { GameClock, StallingScoreEstimate } from "./protocol"; +import * as goscorer from "./goscorer/goscorer"; declare const CLIENT: boolean; declare const SERVER: boolean; @@ -173,8 +175,15 @@ export interface GoEngineConfig { */ throw_all_errors?: boolean; - /** Removed stones in stone removal phase */ - removed?: string; + /** Removed stones in stone removal phase + * Passing an array of JGOFMove objects is preferred, the string + * format exists for historical backwards compatibility. It is an + * encoded move string, e.g. "aa" for A19 + */ + removed?: string | JGOFMove[]; + + /** Intersections that need to be sealed before scoring should happen */ + needs_sealing?: JGOFSealingIntersection[]; // this is weird, we should migrate away from this ogs?: { @@ -476,6 +485,7 @@ export class GoEngine extends EventEmitter { private loading_sgf: boolean = false; private marks: Array>; private move_before_jump?: MoveTree; + public needs_sealing?: Array; //private mv:Move; public score_prisoners: boolean = false; public score_stones: boolean = false; @@ -682,6 +692,16 @@ export class GoEngine extends EventEmitter { } this.emit("stone-removal.updated"); } + if (config.needs_sealing) { + this.needs_sealing = config.needs_sealing; + if (this.phase === "stone removal") { + for (const intersection of config.needs_sealing) { + this.setNeedsSealing(intersection.x, intersection.y, true); + } + } + + this.emit("stone-removal.needs-sealing", config.needs_sealing); + } function unpackMoveTree(cur: MoveTree, tree: MoveTreeJson): void { cur.loadJsonForThisNode(tree); @@ -1585,50 +1605,118 @@ export class GoEngine extends EventEmitter { }; } + public toggleSingleGroupRemoval( + x: number, + y: number, + force_removal: boolean = false, + ): Array<[0 | 1, Group]> { + try { + if (x >= 0 && y >= 0) { + const removing: 0 | 1 = !this.removal[y][x] ? 1 : 0; + + /* If we're clicking on a group, do a sanity check to see if we think + * there is a very good chance that the group is actually definitely alive. + * If so, refuse to remove it, unless a player has instructed us to forcefully + * remove it. */ + if (removing && !force_removal) { + const scores = goscorer.territoryScoring( + this.board, + this.removal as any, + false, + ); + const groups = new GoStoneGroups(this, this.board); + const selected_group = groups.getGroup(x, y); + let total_territory_adjacency_count = 0; + let total_territory_group_count = 0; + selected_group.foreachNeighborSpaceGroup((gr) => { + let is_territory_group = false; + gr.foreachStone((pt) => { + if ( + scores[pt.y][pt.x].isTerritoryFor === this.board[y][x] && + !scores[pt.y][pt.x].isFalseEye + ) { + is_territory_group = true; + } + }); + + if (is_territory_group) { + total_territory_group_count += 1; + total_territory_adjacency_count += gr.points.length; + } + }); + if (total_territory_adjacency_count >= 5 || total_territory_group_count >= 2) { + console.log("This group is almost assuredly alive, refusing to remove"); + GobanCore.hooks.toast?.("refusing_to_remove_group_is_alive", 4000); + return [[0, []]]; + } + } + + /* Otherwise, toggle the group */ + const group_color = this.board[y][x]; + + if (group_color === JGOFNumericPlayerColor.EMPTY) { + /* Disallow toggling of open area (old dame marking method that we no longer desire) */ + return [[0, []]]; + } + + const removed_stones = this.setGroupForRemoval(x, y, removing, false)[1]; + + this.emit("stone-removal.updated"); + return [[removing, removed_stones]]; + } + } catch (err) { + console.log(err.stack); + } + + return [[0, []]]; + } + public toggleMetaGroupRemoval(x: number, y: number): Array<[0 | 1, Group]> { try { if (x >= 0 && y >= 0) { const removing: 0 | 1 = !this.removal[y][x] ? 1 : 0; + const group_color = this.board[y][x]; + + if (group_color === JGOFNumericPlayerColor.EMPTY) { + /* Disallow toggling of open area (old dame marking method that we no longer desire) */ + return [[0, []]]; + } + const group = this.getGroup(x, y, true); let removed_stones = this.setGroupForRemoval(x, y, removing, false)[1]; const empty_spaces = []; - const group_color = this.board[y][x]; - if (group_color === 0) { - /* just toggle open area */ - } else { - /* for stones though, toggle the selected stone group any any stone - * groups which are adjacent to it through open area */ - const already_done: { [str: string]: boolean } = {}; + //if (group_color === 0) { + /* just toggle open area */ + // This condition is historical and can be removed if we find we really don't need it - anoek 2024-06-08 + //} else { + /* for stones though, toggle the selected stone group any any stone + * groups which are adjacent to it through open area */ + const already_done: { [str: string]: boolean } = {}; - let space = this.getConnectedOpenSpace(group); - for (let i = 0; i < space.length; ++i) { - const pt = space[i]; + let space = this.getConnectedOpenSpace(group); + for (let i = 0; i < space.length; ++i) { + const pt = space[i]; - if (already_done[pt.x + "," + pt.y]) { - continue; - } - already_done[pt.x + "," + pt.y] = true; - - if (this.board[pt.y][pt.x] === 0) { - const far_neighbors = this.getConnectedGroups([space[i]]); - for (let j = 0; j < far_neighbors.length; ++j) { - const fpt = far_neighbors[j][0]; - if (this.board[fpt.y][fpt.x] === group_color) { - const res = this.setGroupForRemoval( - fpt.x, - fpt.y, - removing, - false, - ); - removed_stones = removed_stones.concat(res[1]); - space = space.concat(this.getConnectedOpenSpace(res[1])); - } + if (already_done[pt.x + "," + pt.y]) { + continue; + } + already_done[pt.x + "," + pt.y] = true; + + if (this.board[pt.y][pt.x] === 0) { + const far_neighbors = this.getConnectedGroups([space[i]]); + for (let j = 0; j < far_neighbors.length; ++j) { + const fpt = far_neighbors[j][0]; + if (this.board[fpt.y][fpt.x] === group_color) { + const res = this.setGroupForRemoval(fpt.x, fpt.y, removing, false); + removed_stones = removed_stones.concat(res[1]); + space = space.concat(this.getConnectedOpenSpace(res[1])); } - empty_spaces.push(pt); } + empty_spaces.push(pt); } } + //} this.emit("stone-removal.updated"); @@ -1718,6 +1806,11 @@ export class GoEngine extends EventEmitter { this.emit("stone-removal.updated"); } } + + public setNeedsSealing(x: number, y: number, needs_sealing?: boolean): void { + this.cur_move.getMarks(x, y).needs_sealing = needs_sealing; + } + public getStoneRemovalString(): string { let ret = ""; const arr = []; @@ -1742,9 +1835,140 @@ export class GoEngine extends EventEmitter { return this.last_official_move.move_number; } + /** + * Computes the score of the current board state. + * + * If only_prisoners is true, we return the same data structure for convenience, but only + * the prisoners will be counted, other sources of points will be zero. + */ + public computeScore(only_prisoners?: boolean): Score { + const ret = { + white: { + total: 0, + stones: 0, + territory: 0, + prisoners: 0, + scoring_positions: "", + handicap: this.getHandicapPointAdjustmentForWhite(), + komi: this.komi, + }, + black: { + total: 0, + stones: 0, + territory: 0, + prisoners: 0, + scoring_positions: "", + handicap: 0, + komi: 0, + }, + }; + + // Tally up prisoners when appropriate + if (only_prisoners || this.score_prisoners) { + ret.white.prisoners = this.white_prisoners; + ret.black.prisoners = this.black_prisoners; + + for (let y = 0; y < this.height; ++y) { + for (let x = 0; x < this.width; ++x) { + if (this.removal[y][x]) { + if (this.board[y][x] === JGOFNumericPlayerColor.BLACK) { + ret.white.prisoners += 1; + } + if (this.board[y][x] === JGOFNumericPlayerColor.WHITE) { + ret.black.prisoners += 1; + } + } + } + } + } + + // Tally everything else if we want that information + if (!only_prisoners) { + if (!this.score_territory) { + throw new Error("The score_territory flag should always be set to true"); + } + + if (this.score_stones) { + const scoring = goscorer.areaScoring( + this.board, + this.removal.map((row) => row.map((x) => !!x)), + ); + for (let y = 0; y < this.height; ++y) { + for (let x = 0; x < this.width; ++x) { + if (scoring[y][x] === goscorer.BLACK) { + if (this.board[y][x] === JGOFNumericPlayerColor.BLACK) { + ret.black.stones += 1; + } else { + ret.black.territory += 1; + } + ret.black.scoring_positions += GoMath.encodeMove(x, y); + } else if (scoring[y][x] === goscorer.WHITE) { + if (this.board[y][x] === JGOFNumericPlayerColor.WHITE) { + ret.white.stones += 1; + } else { + ret.white.territory += 1; + } + ret.white.scoring_positions += GoMath.encodeMove(x, y); + } + } + } + } else { + const scoring = goscorer.territoryScoring( + this.board, + this.removal.map((row) => row.map((x) => !!x)), + ); + for (let y = 0; y < this.height; ++y) { + for (let x = 0; x < this.width; ++x) { + if (scoring[y][x].isTerritoryFor === goscorer.BLACK) { + ret.black.territory += 1; + ret.black.scoring_positions += GoMath.encodeMove(x, y); + } else if (scoring[y][x].isTerritoryFor === goscorer.WHITE) { + ret.white.territory += 1; + ret.white.scoring_positions += GoMath.encodeMove(x, y); + } + } + } + } + } + + ret["black"].total = + ret["black"].stones + + ret["black"].territory + + ret["black"].prisoners + + ret["black"].handicap + + ret["black"].komi; + ret["white"].total = + ret["white"].stones + + ret["white"].territory + + ret["white"].prisoners + + ret["white"].handicap + + ret["white"].komi; + + try { + if (this.outcome && this.aga_handicap_scoring) { + /* We used to have an AGA scoring bug where we'd give one point per + * handicap stone instead of per handicap stone - 1, so this check + * is for those games that we incorrectly scored so that our little + * drop down box tallies up to be "correct" for those old games + * - anoek 2015-02-01 + */ + const f = parseFloat(this.outcome); + if (f - 1 === Math.abs(ret.white.total - ret.black.total)) { + ret.white.handicap += 1; + } + } + } catch (e) { + console.log(e); + } + + this.jumpTo(this.cur_move); + + return ret; + } + /* Returns a details object containing the total score and the breakdown of the * scoring details */ - public computeScore(only_prisoners?: boolean): Score { + public computeScoreOld(only_prisoners?: boolean): Score { const ret = { white: { total: 0, diff --git a/src/GoMath.ts b/src/GoMath.ts index 88097fb2..01b3d375 100644 --- a/src/GoMath.ts +++ b/src/GoMath.ts @@ -370,14 +370,28 @@ export function trimJGOFMoves(arr: Array): Array { } /** Returns a sorted move string, this is used in our stone removal logic */ -export function sortMoves(move_string: string, width: number, height: number): string { - const moves = decodeMoves(move_string, width, height); - moves.sort((a, b) => { - const av = (a.edited ? 1 : 0) * 10000 + a.x + a.y * 100; - const bv = (b.edited ? 1 : 0) * 10000 + b.x + b.y * 100; - return av - bv; - }); - return encodeMoves(moves); +export function sortMoves(moves: string, width: number, height: number): string; +export function sortMoves(moves: JGOFMove[], width: number, height: number): JGOFMove[]; +export function sortMoves( + moves: string | JGOFMove[], + width: number, + height: number, +): string | JGOFMove[] { + if (moves instanceof Array) { + return moves.sort((a, b) => { + const av = (a.edited ? 1 : 0) * 10000 + a.x + a.y * 100; + const bv = (b.edited ? 1 : 0) * 10000 + b.x + b.y * 100; + return av - bv; + }); + } else { + const arr = decodeMoves(moves, width, height); + arr.sort((a, b) => { + const av = (a.edited ? 1 : 0) * 10000 + a.x + a.y * 100; + const bv = (b.edited ? 1 : 0) * 10000 + b.x + b.y * 100; + return av - bv; + }); + return encodeMoves(arr); + } } // OJE Sequence format is '.root.K10.Q1' ... diff --git a/src/GoStoneGroup.ts b/src/GoStoneGroup.ts index daad7f42..18460de4 100644 --- a/src/GoStoneGroup.ts +++ b/src/GoStoneGroup.ts @@ -154,13 +154,14 @@ export class GoStoneGroup { } } } - computeIsStrongString(): void { + computeIsStrongString(): boolean { /* A group is considered a strong string if it is adjacent to two strong eyes */ let strong_eye_count = 0; this.foreachNeighborGroup((gr) => { strong_eye_count += gr.is_strong_eye ? 1 : 0; }); this.is_strong_string = strong_eye_count >= 2; + return this.is_strong_string; } computeIsTerritory(): void { /* An empty group is considered territory if all of it's neighbors are of diff --git a/src/GoStoneGroups.ts b/src/GoStoneGroups.ts index 483350dd..3092f245 100644 --- a/src/GoStoneGroups.ts +++ b/src/GoStoneGroups.ts @@ -138,4 +138,8 @@ export class GoStoneGroups { fn(this.groups[i]); } } + + public getGroup(x: number, y: number): GoStoneGroup { + return this.groups[this.group_id_map[y][x]]; + } } diff --git a/src/GobanCanvas.ts b/src/GobanCanvas.ts index 4ed4bcb3..72812ac4 100644 --- a/src/GobanCanvas.ts +++ b/src/GobanCanvas.ts @@ -343,8 +343,10 @@ export class GobanCanvas extends GobanCore implements GobanCanvasInterface { let dragging = false; let last_click_square = this.xy2ij(0, 0); + let pointer_down_timestamp = 0; const pointerUp = (ev: MouseEvent | TouchEvent, double_clicked: boolean): void => { + const press_duration_ms = performance.now() - pointer_down_timestamp; try { if (!dragging) { /* if we didn't start the click in the canvas, don't respond to it */ @@ -405,7 +407,7 @@ export class GobanCanvas extends GobanCore implements GobanCanvasInterface { } } - this.onTap(ev, double_clicked, right_click); + this.onTap(ev, double_clicked, right_click, press_duration_ms); this.onMouseOut(ev); } } catch (e) { @@ -414,6 +416,7 @@ export class GobanCanvas extends GobanCore implements GobanCanvasInterface { }; const pointerDown = (ev: MouseEvent | TouchEvent): void => { + pointer_down_timestamp = performance.now(); try { dragging = true; if (this.mode === "analyze" && this.analyze_tool === "draw") { @@ -728,7 +731,12 @@ export class GobanCanvas extends GobanCore implements GobanCanvasInterface { this.pen_ctx.stroke(); } } - private onTap(event: MouseEvent | TouchEvent, double_tap: boolean, right_click: boolean): void { + private onTap( + event: MouseEvent | TouchEvent, + double_tap: boolean, + right_click: boolean, + press_duration_ms: number, + ): void { if ( !( this.stone_placement_enabled && @@ -835,11 +843,11 @@ export class GobanCanvas extends GobanCore implements GobanCanvasInterface { ) { let removed: 0 | 1; let group: Group; - if (event.shiftKey) { - removed = !this.engine.removal[y][x] ? 1 : 0; - group = [{ x, y }]; + if (event.shiftKey || press_duration_ms > 500) { + //[[removed, group]] = this.engine.toggleMetaGroupRemoval(x, y); + [[removed, group]] = this.engine.toggleSingleGroupRemoval(x, y, true); } else { - [[removed, group]] = this.engine.toggleMetaGroupRemoval(x, y); + [[removed, group]] = this.engine.toggleSingleGroupRemoval(x, y); } if (group.length) { @@ -1551,7 +1559,8 @@ export class GobanCanvas extends GobanCore implements GobanCanvasInterface { transparent = true; } else if ( this.engine && - (this.engine.phase === "stone removal" || + ((this.engine.phase === "stone removal" && + this.engine.last_official_move === this.engine.cur_move) || (this.engine.phase === "finished" && this.mode !== "analyze")) && this.engine.board && this.engine.removal && @@ -1703,6 +1712,32 @@ export class GobanCanvas extends GobanCore implements GobanCanvasInterface { } ctx.restore(); } + + /* Red X if the stone is marked for removal */ + if ( + this.engine && + this.engine.phase === "stone removal" && + this.engine.last_official_move === this.engine.cur_move && + this.engine.board[j][i] && + this.engine.removal[j][i] + ) { + ctx.lineCap = "square"; + ctx.save(); + ctx.beginPath(); + ctx.lineWidth = this.square_size * 0.125; + if (transparent) { + ctx.globalAlpha = 0.6; + } + const r = Math.max(1, this.metrics.mid * 0.65); + ctx.moveTo(cx - r, cy - r); + ctx.lineTo(cx + r, cy + r); + ctx.moveTo(cx + r, cy - r); + ctx.lineTo(cx - r, cy + r); + ctx.strokeStyle = "#ff0000"; + ctx.stroke(); + ctx.restore(); + draw_last_move = false; + } } } @@ -1718,7 +1753,7 @@ export class GobanCanvas extends GobanCore implements GobanCanvasInterface { ((this.engine.phase === "stone removal" || (this.engine.phase === "finished" && this.mode === "play")) && this.engine.board[j][i] === 0 && - this.engine.removal[j][i]) + (this.engine.removal[j][i] || pos.needs_sealing)) ) { ctx.beginPath(); @@ -1746,9 +1781,10 @@ export class GobanCanvas extends GobanCore implements GobanCanvasInterface { this.engine.removal[j][i] ) { color = "dame"; - if (pos.needs_sealing) { - color = "seal"; - } + } + + if (pos.needs_sealing) { + color = "seal"; } if (color === "white") { @@ -2344,7 +2380,7 @@ export class GobanCanvas extends GobanCore implements GobanCanvasInterface { ((this.engine.phase === "stone removal" || (this.engine.phase === "finished" && this.mode === "play")) && this.engine.board[j][i] === 0 && - this.engine.removal[j][i]) + (this.engine.removal[j][i] || pos.needs_sealing)) ) { let color = pos.score; if ( @@ -2370,10 +2406,10 @@ export class GobanCanvas extends GobanCore implements GobanCanvasInterface { this.engine.removal[j][i] ) { color = "dame"; + } - if (pos.needs_sealing) { - color = "seal"; - } + if (pos.needs_sealing) { + color = "seal"; } if ( diff --git a/src/GobanCore.ts b/src/GobanCore.ts index 404c6421..5130e59b 100644 --- a/src/GobanCore.ts +++ b/src/GobanCore.ts @@ -44,6 +44,7 @@ import { JGOFNumericPlayerColor, JGOFPauseState, JGOFPlayerSummary, + JGOFSealingIntersection, } from "./JGOF"; import { AdHocClock, AdHocPlayerClock, AdHocPauseControl } from "./AdHocFormat"; import { MessageID } from "./messages"; @@ -271,7 +272,7 @@ export interface Events extends StateUpdateEvents { "captured-stones": (obj: { removed_stones: Array }) => void; "stone-removal.accepted": () => void; "stone-removal.updated": () => void; - "needs-sealing": (positions: undefined | [number, number][]) => void; + "stone-removal.needs-sealing": (positions: undefined | JGOFSealingIntersection[]) => void; "conditional-moves.updated": () => void; "puzzle-place": (obj: { x: number; @@ -355,6 +356,8 @@ export interface GobanHooks { est_winning_color: "black" | "white", number_of_points: number, ) => void; + + toast?: (message_id: string, duration?: number) => void; } export interface GobanMetrics { @@ -1944,6 +1947,9 @@ export abstract class GobanCore extends EventEmitter { this.redraw(true); } + /* DEPRECATED - this method should no longer be used and will likely be + * removed in the future, all Japanese games will start using strict seki + * scoring in the near future */ public setStrictSekiMode(tf: boolean): void { if (this.engine.phase !== "stone removal") { throw "Not in stone removal phase"; @@ -3192,7 +3198,9 @@ export abstract class GobanCore extends EventEmitter { } this.emit("stone-removal.updated"); - this.emit("needs-sealing", se.autoscored_needs_sealing); + + this.engine.needs_sealing = se.autoscored_needs_sealing; + this.emit("stone-removal.needs-sealing", se.autoscored_needs_sealing); this.updateTitleAndStonePlacement(); this.emit("update"); @@ -3200,11 +3208,13 @@ export abstract class GobanCore extends EventEmitter { this.socket.send("game/removed_stones/set", { game_id: this.game_id, removed: false, + needs_sealing: se.autoscored_needs_sealing, stones: current_removed, }); this.socket.send("game/removed_stones/set", { game_id: this.game_id, removed: true, + needs_sealing: se.autoscored_needs_sealing, stones: new_removed, }); diff --git a/src/GobanSVG.ts b/src/GobanSVG.ts index 3bc52902..d711afd1 100644 --- a/src/GobanSVG.ts +++ b/src/GobanSVG.ts @@ -300,8 +300,10 @@ export class GobanSVG extends GobanCore implements GobanSVGInterface { let dragging = false; let last_click_square = this.xy2ij(0, 0); + let pointer_down_timestamp = 0; const pointerUp = (ev: MouseEvent | TouchEvent, double_clicked: boolean): void => { + const press_duration_ms = performance.now() - pointer_down_timestamp; try { if (!dragging) { return; @@ -361,7 +363,7 @@ export class GobanSVG extends GobanCore implements GobanSVGInterface { } } - this.onTap(ev, double_clicked, right_click); + this.onTap(ev, double_clicked, right_click, press_duration_ms); this.onMouseOut(ev); } } catch (e) { @@ -370,6 +372,7 @@ export class GobanSVG extends GobanCore implements GobanSVGInterface { }; const pointerDown = (ev: MouseEvent | TouchEvent): void => { + pointer_down_timestamp = performance.now(); try { dragging = true; if (this.mode === "analyze" && this.analyze_tool === "draw") { @@ -700,7 +703,12 @@ export class GobanSVG extends GobanCore implements GobanSVGInterface { this.pen_layer?.appendChild(path_element); } } - private onTap(event: MouseEvent | TouchEvent, double_tap: boolean, right_click: boolean): void { + private onTap( + event: MouseEvent | TouchEvent, + double_tap: boolean, + right_click: boolean, + press_duration_ms: number, + ): void { if ( !( this.stone_placement_enabled && @@ -807,11 +815,11 @@ export class GobanSVG extends GobanCore implements GobanSVGInterface { ) { let removed: 0 | 1; let group: Group; - if (event.shiftKey) { - removed = !this.engine.removal[y][x] ? 1 : 0; - group = [{ x, y }]; + if (event.shiftKey || press_duration_ms > 500) { + //[[removed, group]] = this.engine.toggleMetaGroupRemoval(x, y); + [[removed, group]] = this.engine.toggleSingleGroupRemoval(x, y, true); } else { - [[removed, group]] = this.engine.toggleMetaGroupRemoval(x, y); + [[removed, group]] = this.engine.toggleSingleGroupRemoval(x, y); } if (group.length) { @@ -1472,7 +1480,8 @@ export class GobanSVG extends GobanCore implements GobanSVGInterface { transparent = true; } else if ( this.engine && - (this.engine.phase === "stone removal" || + ((this.engine.phase === "stone removal" && + this.engine.last_official_move === this.engine.cur_move) || (this.engine.phase === "finished" && this.mode !== "analyze")) && this.engine.board && this.engine.removal && @@ -1618,6 +1627,37 @@ export class GobanSVG extends GobanCore implements GobanSVGInterface { circ.setAttribute("r", Math.max(0.1, radius - lineWidth / 2).toString()); cell.appendChild(circ); } + + /* Red X if the stone is marked for removal */ + if ( + this.engine && + this.engine.phase === "stone removal" && + this.engine.last_official_move === this.engine.cur_move && + this.engine.board[j][i] && + this.engine.removal[j][i] + ) { + const r = Math.max(1, this.metrics.mid * 0.75); + const cross = document.createElementNS("http://www.w3.org/2000/svg", "path"); + cross.setAttribute("class", "removal-cross"); + cross.setAttribute("stroke", "#ff0000"); + cross.setAttribute("stroke-width", `${this.square_size * 0.125}px`); + cross.setAttribute("fill", "none"); + cross.setAttribute( + "d", + ` + M ${cx - r} ${cy - r} + L ${cx + r} ${cy + r} + M ${cx + r} ${cy - r} + L ${cx - r} ${cy + r} + `, + ); + if (transparent) { + cross.setAttribute("stroke-opacity", "0.6"); + } + console.log("Drawing removal cross"); + + cell.appendChild(cross); + } } } @@ -1633,7 +1673,7 @@ export class GobanSVG extends GobanCore implements GobanSVGInterface { ((this.engine.phase === "stone removal" || (this.engine.phase === "finished" && this.mode === "play")) && this.engine.board[j][i] === 0 && - this.engine.removal[j][i]) + (this.engine.removal[j][i] || pos.needs_sealing)) ) { let color = pos.score; if ( @@ -1659,10 +1699,10 @@ export class GobanSVG extends GobanCore implements GobanSVGInterface { this.engine.removal[j][i] ) { color = "dame"; + } - if (pos.needs_sealing) { - color = "seal"; - } + if (pos.needs_sealing) { + color = "seal"; } const r = this.square_size * 0.15; @@ -2281,7 +2321,7 @@ export class GobanSVG extends GobanCore implements GobanSVGInterface { ((this.engine.phase === "stone removal" || (this.engine.phase === "finished" && this.mode === "play")) && this.engine.board[j][i] === 0 && - this.engine.removal[j][i]) + (this.engine.removal[j][i] || pos.needs_sealing)) ) { let color = pos.score; if ( @@ -2307,10 +2347,10 @@ export class GobanSVG extends GobanCore implements GobanSVGInterface { this.engine.removal[j][i] ) { color = "dame"; + } - if (pos.needs_sealing) { - color = "seal"; - } + if (pos.needs_sealing) { + color = "seal"; } if ( diff --git a/src/JGOF.ts b/src/JGOF.ts index f41885e5..58d721a3 100644 --- a/src/JGOF.ts +++ b/src/JGOF.ts @@ -50,6 +50,12 @@ export interface JGOFIntersection { y: number; } +export interface JGOFSealingIntersection extends JGOFIntersection { + /** Color the intersection is probably presumed to be by the players, but + * is in fact empty. */ + color: JGOFNumericPlayerColor; +} + export interface JGOFPlayer { /** Name or username of the player */ name: string; @@ -90,6 +96,9 @@ export interface JGOFMove extends JGOFIntersection { // while it was their turn to make a move sgf_downloaded_by?: Array; // Array of users who downloaded the // game SGF before this move was made + + /** Stone removal reasoning, primarily for debugging */ + removal_reason?: string; } /*********/ diff --git a/src/ScoreEstimator.ts b/src/ScoreEstimator.ts index bada213d..280ca6a9 100644 --- a/src/ScoreEstimator.ts +++ b/src/ScoreEstimator.ts @@ -21,7 +21,7 @@ import { GoStoneGroup } from "./GoStoneGroup"; import { GoStoneGroups } from "./GoStoneGroups"; import { GobanCore } from "./GobanCore"; import { GoEngine, PlayerScore, GoEngineRules } from "./GoEngine"; -import { JGOFNumericPlayerColor } from "./JGOF"; +import { JGOFMove, JGOFNumericPlayerColor, JGOFSealingIntersection } from "./JGOF"; import { _ } from "./translate"; import { estimateScoreWasm } from "./local_estimators/wasm_estimator"; @@ -64,10 +64,10 @@ export interface ScoreEstimateResponse { autoscored_board_state?: JGOFNumericPlayerColor[][]; /** Intersections that are dead or dame. Only defined if autoscore was true in the request. */ - autoscored_removed?: string; + autoscored_removed?: JGOFMove[]; /** Coordinates that still need sealing */ - autoscored_needs_sealing?: [number, number][]; + autoscored_needs_sealing?: JGOFSealingIntersection[]; } let remote_scorer: ((req: ScoreEstimateRequest) => Promise) | undefined; @@ -137,9 +137,9 @@ export class ScoreEstimator { when_ready: Promise; prefer_remote: boolean; autoscored_state?: JGOFNumericPlayerColor[][]; - autoscored_removed?: string; + autoscored_removed?: JGOFMove[]; autoscore: boolean = false; - public autoscored_needs_sealing?: [number, number][]; + public autoscored_needs_sealing?: JGOFSealingIntersection[]; constructor( goban_callback: GobanCore | undefined, @@ -307,7 +307,7 @@ export class ScoreEstimator { getProbablyDead(): string { if (this.autoscored_removed) { console.info("Returning autoscored_removed for getProbablyDead"); - return this.autoscored_removed; + return this.autoscored_removed.map(encodeMove).join(""); } else { console.warn("Not able to use autoscored_removed for getProbablyDead"); } @@ -429,7 +429,7 @@ export class ScoreEstimator { getStoneRemovalString(): string { if (this.autoscored_removed) { console.info("Returning autoscored_removed for getStoneRemovalString"); - return this.autoscored_removed; + return this.autoscored_removed.map(encodeMove).join(""); } else { console.warn("Not able to use autoscored_removed for getStoneRemovalString"); } diff --git a/src/__tests__/autoscore.test.ts b/src/__tests__/autoscore.test.ts index 4f3e81f1..db233b2e 100644 --- a/src/__tests__/autoscore.test.ts +++ b/src/__tests__/autoscore.test.ts @@ -36,7 +36,12 @@ describe("Auto-score tests ", () => { } // actual test - const [res, _debug_output] = autoscore(data.board, data.black, data.white); + const [res, _debug_output] = autoscore( + data.board, + data.rules ?? "chinese", + data.black, + data.white, + ); let match = true; for (let y = 0; y < res.result.length; ++y) { diff --git a/src/autoscore.ts b/src/autoscore.ts index f74967cc..d22e9cb2 100644 --- a/src/autoscore.ts +++ b/src/autoscore.ts @@ -22,15 +22,15 @@ */ import { GoStoneGroups } from "./GoStoneGroups"; -import { JGOFNumericPlayerColor } from "./JGOF"; +import { JGOFNumericPlayerColor, JGOFSealingIntersection, JGOFMove } from "./JGOF"; import { char2num, makeMatrix, num2char, pretty_coor_num2ch } from "./GoMath"; -import { GoEngine, GoEngineInitialState } from "./GoEngine"; +import { GoEngine, GoEngineInitialState, GoEngineRules } from "./GoEngine"; interface AutoscoreResults { result: JGOFNumericPlayerColor[][]; - removed_string: string; - removed: [number, number, /* reason */ string][]; - needs_sealing: [number, number][]; + sealed_result: JGOFNumericPlayerColor[][]; + removed: JGOFMove[]; + needs_sealing: JGOFSealingIntersection[]; } const REMOVAL_THRESHOLD = 0.7; @@ -50,19 +50,21 @@ function isBlack(ownership: number): boolean { export function autoscore( board: JGOFNumericPlayerColor[][], + rules: GoEngineRules, black_plays_first_ownership: number[][], white_plays_first_ownership: number[][], ): [AutoscoreResults, DebugOutput] { const original_board = board.map((row) => row.slice()); // copy const width = board[0].length; const height = board.length; - const removed: [number, number, string][] = []; + const removed: JGOFMove[] = []; const removal = makeMatrix(width, height); const is_settled = makeMatrix(width, height); const settled = makeMatrix(width, height); const final_ownership = makeMatrix(board[0].length, board.length); + const final_sealed_ownership = makeMatrix(board[0].length, board.length); const sealed = makeMatrix(width, height); - const needs_sealing: [number, number][] = []; + const needs_sealing: JGOFSealingIntersection[] = []; const average_ownership = makeMatrix(width, height); for (let y = 0; y < height; ++y) { @@ -89,17 +91,22 @@ export function autoscore( debug_groups("Groups", groups); // Perform our removal logic + settle_agreed_upon_stones(); settle_agreed_upon_territory(); remove_obviously_dead_stones(); - settle_agreed_upon_territory(); clear_unsettled_stones_from_territory(); seal_territory(); score_positions(); + stage("Final state"); + debug_board_output("Final ownership", final_ownership); + debug_boolean_board("Sealed", sealed, "s"); + debug_board_output("Final sealed ownership", final_sealed_ownership); + return [ { result: final_ownership, - removed_string: removed.map((pt) => `${num2char(pt[0])}${num2char(pt[1])}`).join(""), + sealed_result: final_sealed_ownership, removed, needs_sealing, }, @@ -107,15 +114,15 @@ export function autoscore( ]; /** Marks a position as being removed (either dead stone or dame) */ - function remove(x: number, y: number, reason: string) { + function remove(x: number, y: number, removal_reason: string) { if (removal[y][x]) { return; } - removed.push([x, y, reason]); + removed.push({ x, y, removal_reason }); board[y][x] = JGOFNumericPlayerColor.EMPTY; removal[y][x] = 1; - stage_log(`Removing ${pretty_coor_num2ch(x)}${height - y}: ${reason}`); + stage_log(`Removing ${pretty_coor_num2ch(x)}${height - y}: ${removal_reason}`); } /* @@ -180,6 +187,38 @@ export function autoscore( debug_board_output("Settled ownership", settled); } + /* + * If both players agree on the ownership of certain stones, + * mark them as settled. + */ + function settle_agreed_upon_stones() { + stage("Marking settled stones"); + for (let y = 0; y < height; ++y) { + for (let x = 0; x < width; ++x) { + if ( + board[y][x] === JGOFNumericPlayerColor.WHITE && + isWhite(black_plays_first_ownership[y][x]) && + isWhite(white_plays_first_ownership[y][x]) + ) { + is_settled[y][x] = 1; + settled[y][x] = JGOFNumericPlayerColor.WHITE; + } + + if ( + board[y][x] === JGOFNumericPlayerColor.BLACK && + isBlack(black_plays_first_ownership[y][x]) && + isBlack(white_plays_first_ownership[y][x]) + ) { + is_settled[y][x] = 1; + settled[y][x] = JGOFNumericPlayerColor.BLACK; + } + } + } + + debug_boolean_board("Settled", is_settled); + debug_board_output("Resulting board", board); + } + /* * Remove obviously dead stones * @@ -188,7 +227,7 @@ export function autoscore( * function detects these cases and removes the stones. */ function remove_obviously_dead_stones() { - stage("Removing stones both agree on:"); + stage("Removing stones both estimates agree upon"); for (let y = 0; y < height; ++y) { for (let x = 0; x < width; ++x) { if ( @@ -204,15 +243,6 @@ export function autoscore( ) { remove(x, y, "both players agree this is captured by white"); } - /* - else if ( - board[y][x] === JGOFNumericPlayerColor.EMPTY && - isDameOrUnknown(black_plays_first_ownership[y][x]) && - isDameOrUnknown(white_plays_first_ownership[y][x]) - ) { - remove(x, y, "both players agree this is dame"); - } - */ } } debug_boolean_board("Removed", removal, "x"); @@ -406,6 +436,11 @@ export function autoscore( const avg = total_ownership / group.points.length; if (avg <= WHITE_SEAL_THRESHOLD || avg >= BLACK_SEAL_THRESHOLD) { + const color = + avg <= WHITE_SEAL_THRESHOLD + ? JGOFNumericPlayerColor.WHITE + : JGOFNumericPlayerColor.BLACK; + group.foreachStone((point) => { const x = point.x; const y = point.y; @@ -422,11 +457,11 @@ export function autoscore( board[y][x - 1] === opposing_color; if (adjacent_to_opposing_color) { - remove(x, y, "sealing territory"); + //remove(x, y, "sealing territory"); is_settled[y][x] = 1; settled[y][x] = JGOFNumericPlayerColor.EMPTY; sealed[y][x] = 1; - needs_sealing.push([x, y]); + needs_sealing.push({ x, y, color }); } }); } @@ -448,6 +483,7 @@ export function autoscore( } } + /** Compute our final ownership and scoring positions */ function score_positions() { stage("Score positions"); let black_state = ""; @@ -465,61 +501,124 @@ export function autoscore( } } - const initial_state: GoEngineInitialState = { + const sealed_black_state = + black_state + + needs_sealing + .filter((s) => s.color === JGOFNumericPlayerColor.BLACK) + .map((p) => num2char(p.x) + num2char(p.y)) + .join(""); + const sealed_white_state = + white_state + + needs_sealing + .filter((s) => s.color === JGOFNumericPlayerColor.WHITE) + .map((p) => num2char(p.x) + num2char(p.y)) + .join(""); + + const real_initial_state: GoEngineInitialState = { black: black_state, white: white_state, }; + const sealed_initial_state: GoEngineInitialState = { + black: sealed_black_state, + white: sealed_white_state, + }; - const removed_string = removed.map((pt) => `${num2char(pt[0])}${num2char(pt[1])}`).join(""); + for (const initial_state of [sealed_initial_state, real_initial_state]) { + const cur_ownership = makeMatrix(width, height); - const engine = new GoEngine({ - width: original_board[0].length, - height: original_board.length, - initial_state, - rules: "japanese", // so we can test seki code - removed: removed_string, - }); + const engine = new GoEngine({ + width: original_board[0].length, + height: original_board.length, + initial_state, + rules, + removed, + }); - const score = engine.computeScore(); - const scoring_positions = makeMatrix(width, height); + const board = engine.board.map((row) => row.slice()); + removed.map((pt) => (board[pt.y][pt.x] = 0)); - for (let i = 0; i < score.black.scoring_positions.length; i += 2) { - const x = char2num(score.black.scoring_positions[i]); - const y = char2num(score.black.scoring_positions[i + 1]); - final_ownership[y][x] = JGOFNumericPlayerColor.BLACK; - scoring_positions[y][x] = JGOFNumericPlayerColor.BLACK; - } - for (let i = 0; i < score.white.scoring_positions.length; i += 2) { - const x = char2num(score.white.scoring_positions[i]); - const y = char2num(score.white.scoring_positions[i + 1]); - final_ownership[y][x] = JGOFNumericPlayerColor.WHITE; - scoring_positions[y][x] = JGOFNumericPlayerColor.WHITE; - } + const score = engine.computeScore(); + const scoring_positions = makeMatrix(width, height); + + for (let i = 0; i < score.black.scoring_positions.length; i += 2) { + const x = char2num(score.black.scoring_positions[i]); + const y = char2num(score.black.scoring_positions[i + 1]); + cur_ownership[y][x] = JGOFNumericPlayerColor.BLACK; + scoring_positions[y][x] = JGOFNumericPlayerColor.BLACK; + } + for (let i = 0; i < score.white.scoring_positions.length; i += 2) { + const x = char2num(score.white.scoring_positions[i]); + const y = char2num(score.white.scoring_positions[i + 1]); + cur_ownership[y][x] = JGOFNumericPlayerColor.WHITE; + scoring_positions[y][x] = JGOFNumericPlayerColor.WHITE; + } - for (let y = 0; y < board.length; ++y) { - for (let x = 0; x < board[y].length; ++x) { - if (board[y][x] !== JGOFNumericPlayerColor.EMPTY) { - final_ownership[y][x] = board[y][x]; + for (let y = 0; y < board.length; ++y) { + for (let x = 0; x < board[y].length; ++x) { + if (board[y][x] !== JGOFNumericPlayerColor.EMPTY) { + cur_ownership[y][x] = board[y][x]; + } } } - } - const groups = new GoStoneGroups( - { - width, - height, - board, - removal, - }, - original_board, - ); + const groups = new GoStoneGroups( + { + width, + height, + board, + removal, + }, + engine.board, + ); + + if (initial_state === real_initial_state) { + substage("Unsealed"); + for (let y = 0; y < cur_ownership.length; ++y) { + final_ownership[y] = cur_ownership[y].slice(); + } + } else { + substage("Sealed"); + for (let y = 0; y < cur_ownership.length; ++y) { + final_sealed_ownership[y] = cur_ownership[y].slice(); + } + } + + debug_groups("groups", groups); + debug_board_output(`Scoring positions (${rules})`, scoring_positions); + debug_board_output("Board", board); + debug_board_output("Ownership", cur_ownership); + } + //debug_boolean_board("Sealed", sealed, "s"); + + const print_final_ownership_string = false; + // aid while correcting and forming the test files + if (print_final_ownership_string) { + let ownership_string = '\n "correct_ownership": [\n'; + for (let y = 0; y < final_ownership.length; ++y) { + ownership_string += ' "'; + for (let x = 0; x < final_ownership[y].length; ++x) { + if (sealed[y][x]) { + ownership_string += "s"; + } else { + ownership_string += + final_ownership[y][x] === 1 + ? "B" + : final_ownership[y][x] === 2 + ? "W" + : " "; + } + } + if (y !== final_ownership.length - 1) { + ownership_string += '",\n'; + } else { + ownership_string += '"\n'; + } + } - debug_groups("groups", groups); + ownership_string += " ]\n"; - debug_board_output("Scoring positions (JP)", scoring_positions); - debug_board_output("Board", board); - debug_board_output("Final ownership", final_ownership); - debug_boolean_board("Sealed", sealed, "s"); + stage_log(ownership_string); + } } } @@ -813,6 +912,17 @@ let current_stage_log = ""; function stage(name: string) { end_stage(); current_stage = name; + const title_line = `#### ${current_stage} ####`; + const pounds = "#".repeat(title_line.length); + final_output += `\n\n${pounds}\n${title_line}\n${pounds}\n\n`; +} + +function substage(name: string) { + end_stage(); + + current_stage = name; + const title_line = `==== ${current_stage} ====`; + final_output += `${title_line}\n\n`; } function stage_log(str: string) { @@ -826,55 +936,56 @@ function end_stage() { return; } - const title_line = `#### ${current_stage} ####`; - const pounds = "#".repeat(title_line.length); - final_output += `\n\n${pounds}\n${title_line}\n${pounds}\n\n`; current_stage = ""; - const wide_lines: string[] = []; - const str_grid: string[][] = []; - const segment_length = 30; + const boards_per_line = 5; + + while (board_outputs.length > 0) { + const wide_lines: string[] = []; + const str_grid: string[][] = []; + const segment_length = 30; - for (let x = 0; x < board_outputs.length; ++x) { - const lines = board_outputs[x].split("\n"); - for (let y = 0; y < lines.length; ++y) { - if (!str_grid[y]) { - str_grid[y] = []; + for (let x = 0; x < board_outputs.length && x < boards_per_line; ++x) { + const lines = board_outputs[x].split("\n"); + for (let y = 0; y < lines.length; ++y) { + if (!str_grid[y]) { + str_grid[y] = []; + } + str_grid[y][x] = lines[y]; } - str_grid[y][x] = lines[y]; } - } - for (let y = 0; y < str_grid.length; ++y) { - let line = ""; - for (let x = 0; x < str_grid[y].length; ++x) { - //const segment = str_grid[y][x] ?? ""; - /* + board_outputs = board_outputs.slice(boards_per_line); + + for (let y = 0; y < str_grid.length; ++y) { + let line = ""; + for (let x = 0; x < str_grid[y].length; ++x) { + //const segment = str_grid[y][x] ?? ""; + /* const segment = ((str_grid[y][x] ?? "") + " ".repeat(segment_length)).substr(segment_length) + " "; line += segment; */ - const num_color_code_characters = count_color_code_characters(str_grid[y][x] ?? ""); - const length_without_color_codes = - (str_grid[y][x]?.length ?? 0) - num_color_code_characters; - if (length_without_color_codes < 0) { - throw new Error("length_without_color_codes < 0"); + const num_color_code_characters = count_color_code_characters(str_grid[y][x] ?? ""); + const length_without_color_codes = + (str_grid[y][x]?.length ?? 0) - num_color_code_characters; + if (length_without_color_codes < 0) { + throw new Error("length_without_color_codes < 0"); + } + line += + (str_grid[y][x] ?? "") + + " ".repeat(Math.max(0, segment_length - length_without_color_codes)) + + " "; } - line += - (str_grid[y][x] ?? "") + - " ".repeat(Math.max(0, segment_length - length_without_color_codes)) + - " "; + final_output += line + "\n"; + //wide_lines.push(line); } - final_output += line + "\n"; - //wide_lines.push(line); - } - for (let i = 0; i < wide_lines.length; ++i) { - final_output += wide_lines[i] + "\n"; + for (let i = 0; i < wide_lines.length; ++i) { + final_output += wide_lines[i] + "\n"; + } } - board_outputs = []; - if (current_stage_log) { final_output += "\n\nLog:\n" + current_stage_log + "\n"; current_stage_log = ""; diff --git a/src/goscorer/LICENSE.txt b/src/goscorer/LICENSE.txt new file mode 100644 index 00000000..7c9cb72a --- /dev/null +++ b/src/goscorer/LICENSE.txt @@ -0,0 +1,16 @@ +Copyright 2024 David J Wu ("lightvector"). + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or +substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT +NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/goscorer/README.md b/src/goscorer/README.md new file mode 100644 index 00000000..05db26f9 --- /dev/null +++ b/src/goscorer/README.md @@ -0,0 +1,5 @@ +The contents of this directory are largely come from David J Wu's goscorer library found here: https://github.com/lightvector/goscorer + +goscorer.js was copied from the v1.0.0 tag, the latest release as of 2024-06-05 + +Code found within this directory are covered by David's license found in LICENSE.txt diff --git a/src/goscorer/goscorer.d.ts b/src/goscorer/goscorer.d.ts new file mode 100644 index 00000000..87f5cebd --- /dev/null +++ b/src/goscorer/goscorer.d.ts @@ -0,0 +1,89 @@ +/* + * Copyright (C) David J Wu + * + * An attempt at territory scoring in Go with seki detection. + * See https://github.com/lightvector/goscorer + * Original Author: lightvector + * Released under MIT license (https://github.com/lightvector/goscorer/blob/main/LICENSE.txt) + */ + +export const EMPTY: 0; +export const BLACK: 1; +export const WHITE: 2; + +export type color = typeof EMPTY | typeof BLACK | typeof WHITE; + +/** + * Indicates how a given location on the board should be scored for territory, along with other metadata. + * isTerritoryFor is the primary field, indicating the territory (EMPTY / BLACK / WHITE) at each location. + * See the Python version of this code for more detailed documentation on the fields of this class. + */ +export class LocScore { + isTerritoryFor: color; + belongsToSekiGroup: color; + isFalseEye: boolean; + isUnscorableFalseEye: boolean; + isDame: boolean; + eyeValue: number; +} +/** + * @param {color[][]} stones - BLACK or WHITE or EMPTY indicating the stones on the board. + * @param {bool[][]} markedDead - true if the location has a stone marked as dead, and false otherwise. + * @param {float} blackPointsFromCaptures - points to add to black's score due to captures + * @param {float} whitePointsFromCaptures - points to add to white's score due to captures + * @param {float} komi - points to add to white's score due to komi + * @param {bool} [scoreFalseEyes=false] - defaults to false, if set to true will score territory in false eyes even if + * is_unscorable_false_eye is true. + * @return { {black:finalBlackScore,white:finalWhiteScore} } + */ +export function finalTerritoryScore( + stones: color[][], + markedDead: boolean[][], + blackPointsFromCaptures: number, + whitePointsFromCaptures: number, + komi: number, + scoreFalseEyes?: boolean, +): { + black: number; + white: number; +}; +/** + * @param {color[][]} stones - BLACK or WHITE or EMPTY indicating the stones on the board. + * @param {bool[][]} markedDead - true if the location has a stone marked as dead, and false otherwise. + * @param {float} komi - points to add to white's score due to komi + * @return { {black:finalBlackScore,white:finalWhiteScore} } + */ +export function finalAreaScore( + stones: color[][], + markedDead: boolean[][], + komi: number, +): { + black: number; + white: number; +}; +/** + * @param {color[][]} stones - BLACK or WHITE or EMPTY indicating the stones on the board. + * @param {bool[][]} markedDead - true if the location has a stone marked as dead, and false otherwise. + * @param {bool} [scoreFalseEyes=false] - defaults to false, if set to true + * will score territory in false eyes even if is_unscorable_false_eye is + * true. + * @return {LocScore[][]} + */ +export function territoryScoring( + stones: color[][], + markedDead: boolean[][], + scoreFalseEyes?: boolean, +): LocScore[][]; +/** + * @param {color[][]} stones - BLACK or WHITE or EMPTY indicating the stones on the board. + * @param {bool[][]} markedDead - true if the location has a stone marked as dead, and false otherwise. + * @return {LocScore[][]} + */ +export function areaScoring(stones: color[][], markedDead: boolean[][]): color[][]; +export function getOpp(pla: any): number; +export function isOnBoard(y: any, x: any, ysize: any, xsize: any): boolean; +export function isOnBorder(y: any, x: any, ysize: any, xsize: any): boolean; +export function print2d(board: any, f: any): void; +export function string2d(board: any, f: any): string; +export function string2d2(board1: any, board2: any, f: any): string; +export function colorToStr(color: any): "." | "x" | "o"; diff --git a/src/goscorer/goscorer.js b/src/goscorer/goscorer.js new file mode 100644 index 00000000..c9c0dec3 --- /dev/null +++ b/src/goscorer/goscorer.js @@ -0,0 +1,1508 @@ +/** + * An attempt at territory scoring in Go with seki detection. + * See https://github.com/lightvector/goscorer + * Original Author: lightvector + * Released under MIT license (https://github.com/lightvector/goscorer/blob/main/LICENSE.txt) + */ + +const EMPTY = 0; +const BLACK = 1; +const WHITE = 2; + +/** + * Indicates how a given location on the board should be scored for territory, along with other metadata. + * isTerritoryFor is the primary field, indicating the territory (EMPTY / BLACK / WHITE) at each location. + * See the Python version of this code for more detailed documentation on the fields of this class. + */ +class LocScore { + constructor() { + this.isTerritoryFor = EMPTY; + this.belongsToSekiGroup = EMPTY; + this.isFalseEye = false; + this.isUnscorableFalseEye = false; + this.isDame = false; + this.eyeValue = 0; + } +} + +/** + * @param {color[][]} stones - BLACK or WHITE or EMPTY indicating the stones on the board. + * @param {bool[][]} markedDead - true if the location has a stone marked as dead, and false otherwise. + * @param {float} blackPointsFromCaptures - points to add to black's score due to captures + * @param {float} whitePointsFromCaptures - points to add to white's score due to captures + * @param {float} komi - points to add to white's score due to komi + * @param {bool} [scoreFalseEyes=false] - defaults to false, if set to true will score territory in false eyes even if + is_unscorable_false_eye is true. + * @return { {black:finalBlackScore,white:finalWhiteScore} } + */ +function finalTerritoryScore( + stones, + markedDead, + blackPointsFromCaptures, + whitePointsFromCaptures, + komi, + scoreFalseEyes = false +) { + const scoring = territoryScoring(stones,markedDead,scoreFalseEyes); + + const ysize = stones.length; + const xsize = stones[0].length; + let finalBlackScore = 0; + let finalWhiteScore = 0; + for(let y = 0; y { + if(row.length !== xsize) + throw new Error(`Not all rows in stones are the same length ${xsize}`); + row.forEach(value => { + if(value !== EMPTY && value !== BLACK && value !== WHITE) + throw new Error("Unexpected value in stones " + value); + }); + }); + + if(markedDead.length !== ysize) + throw new Error(`markedDead is not the same length as stones ${ysize}`); + + markedDead.forEach(row => { + if(row.length !== xsize) + throw new Error(`Not all rows in markedDead are the same length as stones ${xsize}`); + }); + + const connectionBlocks = makeArray(ysize, xsize, EMPTY); + markConnectionBlocks(ysize, xsize, stones, markedDead, connectionBlocks); + + // console.log("CONNECTIONBLOCKS"); + // print2d(connectionBlocks, (s) => + // ".xo"[s] + // ); + + const strictReachesBlack = makeArray(ysize, xsize, false); + const strictReachesWhite = makeArray(ysize, xsize, false); + markReachability(ysize, xsize, stones, markedDead, null, strictReachesBlack, strictReachesWhite); + + const reachesBlack = makeArray(ysize, xsize, false); + const reachesWhite = makeArray(ysize, xsize, false); + markReachability(ysize, xsize, stones, markedDead, connectionBlocks, reachesBlack, reachesWhite); + + const regionIds = makeArray(ysize, xsize, -1); + const regionInfosById = {}; + markRegions(ysize, xsize, stones, markedDead, connectionBlocks, reachesBlack, reachesWhite, regionIds, regionInfosById); + + // console.log("REGIONIDS"); + // print2d(regionIds, (s) => + // ".0123456789abcdefghijklmnopqrstuvwxyz"[s+1] + // ); + + const chainIds = makeArray(ysize, xsize, -1); + const chainInfosById = {}; + markChains(ysize, xsize, stones, markedDead, regionIds, chainIds, chainInfosById); + + const macrochainIds = makeArray(ysize, xsize, -1); + const macrochainInfosById = {}; + markMacrochains(ysize, xsize, stones, markedDead, connectionBlocks, regionIds, regionInfosById, chainIds, chainInfosById, macrochainIds, macrochainInfosById); + + // console.log("MACROCHAINS"); + // print2d(macrochainIds, (s) => + // ".0123456789abcdefghijklmnopqrstuvwxyz"[s+1] + // ); + + const eyeIds = makeArray(ysize, xsize, -1); + const eyeInfosById = {}; + markPotentialEyes(ysize, xsize, stones, markedDead, strictReachesBlack, strictReachesWhite, regionIds, regionInfosById, macrochainIds, macrochainInfosById, eyeIds, eyeInfosById); + + // console.log("EYE IDS"); + // print2d(eyeIds, (s) => + // ".0123456789abcdefghijklmnopqrstuvwxyz"[s+1] + // ); + + const isFalseEyePoint = makeArray(ysize, xsize, false); + markFalseEyePoints(ysize, xsize, regionIds, macrochainIds, macrochainInfosById, eyeInfosById, isFalseEyePoint); + + markEyeValues(ysize, xsize, stones, markedDead, regionIds, regionInfosById, chainIds, chainInfosById, isFalseEyePoint, eyeIds, eyeInfosById); + + const isUnscorableFalseEyePoint = makeArray(ysize, xsize, false); + markFalseEyePoints(ysize, xsize, regionIds, macrochainIds, macrochainInfosById, eyeInfosById, isUnscorableFalseEyePoint); + + const scoring = makeArrayFromCallable(ysize, xsize, () => new LocScore()); + markScoring( + ysize, xsize, stones, markedDead, scoreFalseEyes, + strictReachesBlack, strictReachesWhite, regionIds, regionInfosById, + chainIds, chainInfosById, isFalseEyePoint, eyeIds, eyeInfosById, + isUnscorableFalseEyePoint, scoring + ); + return scoring; +} + +/** + * @param {color[][]} stones - BLACK or WHITE or EMPTY indicating the stones on the board. + * @param {bool[][]} markedDead - true if the location has a stone marked as dead, and false otherwise. + * @return {LocScore[][]} + */ +function areaScoring( + stones, + markedDead, +) { + const ysize = stones.length; + const xsize = stones[0].length; + + stones.forEach(row => { + if(row.length !== xsize) + throw new Error(`Not all rows in stones are the same length ${xsize}`); + row.forEach(value => { + if(value !== EMPTY && value !== BLACK && value !== WHITE) + throw new Error("Unexpected value in stones " + value); + }); + }); + + if(markedDead.length !== ysize) + throw new Error(`markedDead is not the same length as stones ${ysize}`); + + markedDead.forEach(row => { + if(row.length !== xsize) + throw new Error(`Not all rows in markedDead are the same length as stones ${xsize}`); + }); + + const strictReachesBlack = makeArray(ysize, xsize, false); + const strictReachesWhite = makeArray(ysize, xsize, false); + markReachability(ysize, xsize, stones, markedDead, null, strictReachesBlack, strictReachesWhite); + + const scoring = makeArray(ysize, xsize, EMPTY); + + for(let y = 0; y < ysize; y++) { + for(let x = 0; x < xsize; x++) { + if(strictReachesWhite[y][x] && !strictReachesBlack[y][x]) + scoring[y][x] = WHITE; + if(strictReachesBlack[y][x] && !strictReachesWhite[y][x]) + scoring[y][x] = BLACK; + } + } + return scoring; +} + + +function getOpp(pla) { + return 3 - pla; +} + +function makeArray(ysize, xsize, initialValue) { + return Array.from({length: ysize}, () => + Array.from({length: xsize}, () => initialValue)); +} + +function makeArrayFromCallable(ysize, xsize, f) { + return Array.from({length: ysize}, () => + Array.from({length: xsize}, () => f())); +} + +function isOnBoard(y, x, ysize, xsize) { + return y >= 0 && x >= 0 && y < ysize && x < xsize; +} + +function isOnBorder(y, x, ysize, xsize) { + return y === 0 || x === 0 || y === ysize-1 || x === xsize-1; +} + +function isAdjacent(y1, x1, y2, x2) { + return (y1 === y2 && (x1 === x2 + 1 || x1 === x2 - 1)) + || (x1 === x2 && (y1 === y2 + 1 || y1 === y2 - 1)); +} + +function print2d(board, f) { + console.log(string2d(board, f)); +} + +function string2d(board, f) { + const ysize = board.length; + const lines = []; + + for(let y = 0; y < ysize; y++) { + const pieces = []; + for(let item of board[y]) + pieces.push(f(item)); + lines.push(pieces.join('')); + } + return lines.join('\n'); +} + +function string2d2(board1, board2, f) { + const ysize = board1.length; + const lines = []; + + for(let y = 0; y < ysize; y++) { + const pieces = []; + for(let x = 0; x < board1[y].length; x++) { + const item1 = board1[y][x]; + const item2 = board2[y][x]; + pieces.push(f(item1, item2)); + } + lines.push(pieces.join('')); + } + return lines.join('\n'); +} + +function colorToStr(color) { + if(color === EMPTY) + return '.'; + if(color === BLACK) + return 'x'; + if(color === WHITE) + return 'o'; + throw new Error("Invalid color: " + color); +} + +function markConnectionBlocks( + ysize, + xsize, + stones, + markedDead, + connectionBlocks // mutated by this function +) { + const patterns = [ + [ + "pp", + "@e", + "pe", + ], + [ + "ep?", + "e@e", + "ep?", + ], + [ + "pee", + "e@p", + "pee", + ], + [ + "?e?", + "p@p", + "xxx", + ], + [ + "pp", + "@e", + "xx", + ], + [ + "ep?", + "e@e", + "xxx", + ], + ]; + + for(const pla of [BLACK, WHITE]) { + const opp = getOpp(pla); + + for(const [pdydy, pdydx, pdxdy, pdxdx] of [ + [1,0,0,1], + [-1,0,0,1], + [1,0,0,-1], + [-1,0,0,-1], + [0,1,1,0], + [0,-1,1,0], + [0,1,-1,0], + [0,-1,-1,0], + ]) { + for(const pattern of patterns) { + let pylen = pattern.length; + const pxlen = pattern[0].length; + const isEdgePattern = pattern[pylen-1].includes('x'); + + if(isEdgePattern) + pylen--; + + let yRange = Array.from({length: ysize}, (_, i) => i); + let xRange = Array.from({length: xsize}, (_, i) => i); + + if(isEdgePattern) { + if(pdydy === -1) + yRange = [pattern.length-2]; + else if(pdydy === 1) + yRange = [ysize - (pattern.length-1)]; + else if(pdxdy === -1) + xRange = [pattern.length-2]; + else if(pdxdy === 1) + xRange = [xsize - (pattern.length-1)]; + } + + for(let y of yRange) { + for(let x of xRange) { + function getTargetYX(pdy, pdx) { + return [ + y + pdydy*pdy + pdxdy*pdx, + x + pdydx*pdy + pdxdx*pdx + ]; + } + + let [ty, tx] = getTargetYX(pylen-1, pxlen-1); + if(!isOnBoard(ty, tx, ysize, xsize)) + continue; + + let atLoc; + let mismatch = false; + for(let pdy = 0; pdy < pylen; pdy++) { + for(let pdx = 0; pdx < pxlen; pdx++) { + const c = pattern[pdy][pdx]; + if(c === "?") + continue; + + [ty, tx] = getTargetYX(pdy, pdx); + if(c === 'p') { + if(!(stones[ty][tx] === pla && !markedDead[ty][tx])) { + mismatch = true; + break; + } + } + else if(c === 'e') { + if(!( + stones[ty][tx] === EMPTY || + stones[ty][tx] === pla && !markedDead[ty][tx] || + stones[ty][tx] === opp && markedDead[ty][tx] + )) { + mismatch = true; + break; + } + } + else if(c === '@') { + if(stones[ty][tx] !== EMPTY) { + mismatch = true; + break; + } + atLoc = [ty, tx]; + } + else { + throw new Error("Invalid char: " + c); + } + } + if(mismatch) + break; + } + + if(!mismatch) { + [ty, tx] = atLoc; + connectionBlocks[ty][tx] = pla; + } + } + } + } + } + } +} + + +function markReachability( + ysize, + xsize, + stones, + markedDead, + connectionBlocks, + reachesBlack, // mutated by this function + reachesWhite // mutated by this function +) { + function fillReach(y, x, reachesPla, pla) { + if(!isOnBoard(y,x,ysize,xsize)) + return; + if(reachesPla[y][x]) + return; + if(stones[y][x] === getOpp(pla) && !markedDead[y][x]) + return; + + reachesPla[y][x] = true; + + if(connectionBlocks && connectionBlocks[y][x] === getOpp(pla)) + return; + + fillReach(y-1, x, reachesPla, pla); + fillReach(y+1, x, reachesPla, pla); + fillReach(y, x-1, reachesPla, pla); + fillReach(y, x+1, reachesPla, pla); + } + + for(let y = 0; y < ysize; y++) { + for(let x = 0; x < xsize; x++) { + if(stones[y][x] === BLACK && !markedDead[y][x]) + fillReach(y, x, reachesBlack, BLACK); + if(stones[y][x] === WHITE && !markedDead[y][x]) + fillReach(y, x, reachesWhite, WHITE); + } + } +} + +class RegionInfo { + constructor(regionId, color, regionAndDame, eyes) { + this.regionId = regionId; + this.color = color; + this.regionAndDame = regionAndDame; + this.eyes = eyes; + } +} + +function markRegions( + ysize, + xsize, + stones, + markedDead, + connectionBlocks, + reachesBlack, + reachesWhite, + regionIds, // mutated by this function + regionInfosById // mutated by this function +) { + function fillRegion(y, x, withId, opp, reachesPla, reachesOpp, visited) { + if(!isOnBoard(y,x,ysize,xsize)) + return; + if(visited[y][x]) + return; + if(regionIds[y][x] !== -1) + return; + if(stones[y][x] === opp && !markedDead[y][x]) + return; + + visited[y][x] = true; + regionInfosById[withId].regionAndDame.add([y, x]); + + if(reachesPla[y][x] && !reachesOpp[y][x]) + regionIds[y][x] = withId; + + if(connectionBlocks[y][x] === opp) + return; + + fillRegion(y-1, x, withId, opp, reachesPla, reachesOpp, visited); + fillRegion(y+1, x, withId, opp, reachesPla, reachesOpp, visited); + fillRegion(y, x-1, withId, opp, reachesPla, reachesOpp, visited); + fillRegion(y, x+1, withId, opp, reachesPla, reachesOpp, visited); + } + + let nextRegionId = 0; + + for(let y = 0; y < ysize; y++) { + for(let x = 0; x < xsize; x++) { + if(reachesBlack[y][x] && !reachesWhite[y][x] && regionIds[y][x] === -1) { + const regionId = nextRegionId++; + regionInfosById[regionId] = new RegionInfo( + regionId, BLACK, new CoordinateSet(), new Set() + ); + + const visited = makeArray(ysize, xsize, false); + fillRegion(y, x, regionId, WHITE, reachesBlack, reachesWhite, visited); + } + if(reachesWhite[y][x] && !reachesBlack[y][x] && regionIds[y][x] === -1) { + const regionId = nextRegionId++; + regionInfosById[regionId] = new RegionInfo( + regionId, WHITE, new CoordinateSet(), new Set() + ); + + const visited = makeArray(ysize, xsize, false); + fillRegion(y, x, regionId, BLACK, reachesWhite, reachesBlack, visited); + } + } + } +} + + +class ChainInfo { + constructor(chainId, regionId, color, points, neighbors, adjacents, liberties, isMarkedDead) { + this.chainId = chainId; + this.regionId = regionId; + this.color = color; + this.points = points; + this.neighbors = neighbors; + this.adjacents = adjacents; + this.liberties = liberties; + this.isMarkedDead = isMarkedDead; + } +} + +function markChains( + ysize, + xsize, + stones, + markedDead, + regionIds, + chainIds, // mutated by this function + chainInfosById // mutated by this function +) { + function fillChain(y, x, withId, color, isMarkedDead) { + + if(!isOnBoard(y,x,ysize,xsize)) + return; + if(chainIds[y][x] === withId) + return; + + if(chainIds[y][x] !== -1) { + const otherId = chainIds[y][x]; + chainInfosById[otherId].neighbors.add(withId); + chainInfosById[withId].neighbors.add(otherId); + chainInfosById[withId].adjacents.add([y, x]); + if(stones[y][x] == EMPTY) + chainInfosById[withId].liberties.add([y, x]); + return; + } + if(stones[y][x] !== color || markedDead[y][x] !== isMarkedDead) { + chainInfosById[withId].adjacents.add([y, x]); + if(stones[y][x] == EMPTY) + chainInfosById[withId].liberties.add([y, x]); + return; + } + + chainIds[y][x] = withId; + chainInfosById[withId].points.push([y, x]); + if(chainInfosById[withId].regionId !== regionIds[y][x]) + chainInfosById[withId].regionId = -1; + + assert(color === EMPTY || regionIds[y][x] === chainInfosById[withId].regionId); + + fillChain(y-1, x, withId, color, isMarkedDead); + fillChain(y+1, x, withId, color, isMarkedDead); + fillChain(y, x-1, withId, color, isMarkedDead); + fillChain(y, x+1, withId, color, isMarkedDead); + } + + let nextChainId = 0; + + for(let y = 0; y < ysize; y++) { + for(let x = 0; x < xsize; x++) { + if(chainIds[y][x] === -1) { + const chainId = nextChainId++; + const color = stones[y][x]; + const isMarkedDead = markedDead[y][x]; + + chainInfosById[chainId] = new ChainInfo( + chainId, + regionIds[y][x], + color, + [], + new Set(), + new CoordinateSet(), + new CoordinateSet(), + isMarkedDead + ); + + assert(isMarkedDead || color === EMPTY || regionIds[y][x] !== -1); + fillChain(y, x, chainId, color, isMarkedDead); + } + } + } +} + + +class MacroChainInfo { + constructor(macrochainId, regionId, color, points, chains, eyeNeighborsFrom) { + this.macrochainId = macrochainId; + this.regionId = regionId; + this.color = color; + this.points = points; + this.chains = chains; + this.eyeNeighborsFrom = eyeNeighborsFrom; + } +} + +function markMacrochains( + ysize, + xsize, + stones, + markedDead, + connectionBlocks, + regionIds, + regionInfosById, + chainIds, + chainInfosById, + macrochainIds, // mutated by this function + macrochainInfosById // mutated by this function +) { + let nextMacrochainId = 0; + + for(const pla of [BLACK, WHITE]) { + const opp = getOpp(pla); + const chainsHandled = new Set(); + const visited = makeArray(ysize, xsize, false); + + for(let chainId in chainInfosById) { + chainId = Number(chainId); + if(chainsHandled.has(chainId)) + continue; + + const chainInfo = chainInfosById[chainId]; + if(!(chainInfo.color === pla && !chainInfo.isMarkedDead)) + continue; + + const regionId = chainInfo.regionId; + assert(regionId !== -1); + + const macrochainId = nextMacrochainId++; + const points = []; + const chains = new Set(); + + function walkAndAccumulate(y, x) { + if(!isOnBoard(y,x,ysize,xsize)) + return; + if(visited[y][x]) + return; + + visited[y][x] = true; + + const chainId2 = chainIds[y][x]; + const chainInfo2 = chainInfosById[chainId2]; + + let shouldRecurse = false; + if(stones[y][x] === pla && !markedDead[y][x]) { + macrochainIds[y][x] = macrochainId; + points.push([y,x]); + if(!chains.has(chainId2)) { + chains.add(chainId2); + chainsHandled.add(chainId2); + } + shouldRecurse = true; + } + else if(regionIds[y][x] === -1 && connectionBlocks[y][x] !== opp) { + shouldRecurse = true; + } + + if(shouldRecurse) { + walkAndAccumulate(y-1, x); + walkAndAccumulate(y+1, x); + walkAndAccumulate(y, x-1); + walkAndAccumulate(y, x+1); + } + } + + const [y, x] = chainInfo.points[0]; + walkAndAccumulate(y, x); + + macrochainInfosById[macrochainId] = new MacroChainInfo( + macrochainId, + regionId, + pla, + points, + chains, + {} // filled in later + ); + + } + + } + +} + + +class EyeInfo { + constructor(pla, regionId, eyeId, potentialPoints, realPoints, macrochainNeighborsFrom, isLoose, eyeValue) { + this.pla = pla; + this.regionId = regionId; + this.eyeId = eyeId; + this.potentialPoints = potentialPoints; + this.realPoints = realPoints; + this.macrochainNeighborsFrom = macrochainNeighborsFrom; + this.isLoose = isLoose; + this.eyeValue = eyeValue; + } +} + +function markPotentialEyes( + ysize, + xsize, + stones, + markedDead, + strictReachesBlack, + strictReachesWhite, + regionIds, + regionInfosById, // mutated by this function + macrochainIds, + macrochainInfosById, // mutated by this function + eyeIds, // mutated by this function + eyeInfosById // mutated by this function +) { + let nextEyeId = 0; + const visited = makeArray(ysize, xsize, false); + for(let y = 0; y < ysize; y++) { + for(let x = 0; x < xsize; x++) { + if(visited[y][x]) + continue; + if(eyeIds[y][x] !== -1) + continue; + if(stones[y][x] !== EMPTY && !markedDead[y][x]) + continue; + + const regionId = regionIds[y][x]; + if(regionId === -1) + continue; + + const regionInfo = regionInfosById[regionId]; + const pla = regionInfo.color; + const isLoose = strictReachesWhite[y][x] && strictReachesBlack[y][x]; + const eyeId = nextEyeId++; + const potentialPoints = new CoordinateSet(); + const macrochainNeighborsFrom = {}; + + function accRegion(y, x, prevY, prevX) { + if(!isOnBoard(y,x,ysize,xsize)) + return; + if(visited[y][x]) + return; + if(regionIds[y][x] !== regionId) + return; + + if(macrochainIds[y][x] !== -1) { + const macrochainId = macrochainIds[y][x]; + + if(!macrochainNeighborsFrom[macrochainId]) + macrochainNeighborsFrom[macrochainId] = new CoordinateSet(); + macrochainNeighborsFrom[macrochainId].add([prevY, prevX]); + if(!macrochainInfosById[macrochainId].eyeNeighborsFrom[eyeId]) + macrochainInfosById[macrochainId].eyeNeighborsFrom[eyeId] = new CoordinateSet(); + + macrochainInfosById[macrochainId].eyeNeighborsFrom[eyeId].add([y, x]); + } + + if(stones[y][x] !== EMPTY && !markedDead[y][x]) + return; + + visited[y][x] = true; + eyeIds[y][x] = eyeId; + potentialPoints.add([y,x]); + + accRegion(y-1, x, y, x); + accRegion(y+1, x, y, x); + accRegion(y, x-1, y, x); + accRegion(y, x+1, y, x); + } + + assert(macrochainIds[y][x] === -1); + accRegion(y, x, 10000, 10000); + + eyeInfosById[eyeId] = new EyeInfo( + pla, + regionId, + eyeId, + potentialPoints, + new CoordinateSet(), // filled in later + macrochainNeighborsFrom, + isLoose, + 0 // filled in later + ); + + regionInfosById[regionId].eyes.add(eyeId); + + } + } + +} + + +function markFalseEyePoints( + ysize, + xsize, + regionIds, + macrochainIds, + macrochainInfosById, + eyeInfosById, + isFalseEyePoint // mutated by this function +) { + for(let origEyeId in eyeInfosById) { + origEyeId = Number(origEyeId); + const origEyeInfo = eyeInfosById[origEyeId]; + + for(let origMacrochainId in origEyeInfo.macrochainNeighborsFrom) { + origMacrochainId = Number(origMacrochainId); + const neighborsFromEyePoints = origEyeInfo.macrochainNeighborsFrom[origMacrochainId]; + + for(let [ey, ex] of neighborsFromEyePoints) { + let sameEyeAdjCount = 0; + for(let [y, x] of [[ey-1,ex], [ey+1,ex], [ey,ex-1], [ey,ex+1]]) { + if(origEyeInfo.potentialPoints.has([y, x])) + sameEyeAdjCount += 1; + } + if(sameEyeAdjCount > 1) + continue; + + const reachingSides = new CoordinateSet(); + const visitedMacro = new Set(); + const visitedOtherEyes = new Set(); + const visitedOrigEyePoints = new CoordinateSet(); + visitedOrigEyePoints.add([ey,ex]); + + let targetSideCount = 0; + for(let [y, x] of [[ey-1,ex], [ey+1,ex], [ey,ex-1], [ey, ex+1]]) { + if(isOnBoard(y, x, ysize, xsize) && regionIds[y][x] === origEyeInfo.regionId) + targetSideCount += 1; + } + // console.log("CHECKING EYE " + origEyeId + " " + [ey,ex]); + function search(macrochainId) { + if(visitedMacro.has(macrochainId)) + return false; + visitedMacro.add(macrochainId); + // console.log("Searching macrochain " + macrochainId + ""); + + const macrochainInfo = macrochainInfosById[macrochainId]; + for(let eyeId in macrochainInfo.eyeNeighborsFrom) { + eyeId = Number(eyeId); + if(visitedOtherEyes.has(eyeId)) + continue; + // console.log("Searching macrochain " + macrochainId + " iterating eyeId " + eyeId + ""); + + if(eyeId === origEyeId) { + // console.log("Orig!"); + const eyeInfo = eyeInfosById[eyeId]; + for(let [y, x] of macrochainInfo.eyeNeighborsFrom[eyeId]) { + if(isAdjacent(y, x, ey, ex)) { + reachingSides.add([y, x]); + if(reachingSides.size >= targetSideCount) + return true; + } + } + + const pointsReached = findRecursivelyAdjacentPoints( + eyeInfo.potentialPoints, + eyeInfo.macrochainNeighborsFrom[macrochainId], + visitedOrigEyePoints + ); + if(pointsReached.size === 0) + continue; + + pointsReached.forEach(item => visitedOrigEyePoints.add(item)); + + if(eyeInfo.eyeValue > 0) { + for(let point of pointsReached) { + if(eyeInfo.realPoints.has(point)) + return true; + } + } + + for(let [y, x] of pointsReached) { + if(isAdjacent(y, x, ey, ex)) { + reachingSides.add([y,x]); + if(reachingSides.size >= targetSideCount) + return true; + } + } + + for(let nextMacrochainId in eyeInfo.macrochainNeighborsFrom) { + if([...eyeInfo.macrochainNeighborsFrom[nextMacrochainId]].some(point => pointsReached.has(point))) { + if(search(Number(nextMacrochainId))) + return true; + } + } + + } + else { + visitedOtherEyes.add(eyeId); + const eyeInfo = eyeInfosById[eyeId]; + if(eyeInfo.eyeValue > 0) + return true; + + for(let nextMacrochainId of Object.keys(eyeInfo.macrochainNeighborsFrom)) { + if(search(Number(nextMacrochainId))) + return true; + } + } + } + return false; + }; + + if(search(origMacrochainId)) { + // pass + } + else { + isFalseEyePoint[ey][ex] = true; + } + } + } + } +} + + + +function findRecursivelyAdjacentPoints( + withinSet, + fromPoints, + excludingPoints +) { + const expanded = new CoordinateSet(); + fromPoints = [...fromPoints]; + + for(let i = 0; i < fromPoints.length; i++) { + const point = fromPoints[i]; + if(excludingPoints.has(point) || expanded.has(point) || !withinSet.has(point)) + continue; + expanded.add(point); + const [y, x] = point; + fromPoints.push([y-1, x]); + fromPoints.push([y+1, x]); + fromPoints.push([y, x-1]); + fromPoints.push([y, x+1]); + } + + return expanded; +} + + +function getPieces(ysize, xsize, points, pointsToDelete) { + const usedPoints = new CoordinateSet(); + function floodfill(point, piece) { + if(usedPoints.has(point) || pointsToDelete.has(point)) + return; + usedPoints.add(point); + piece.add(point); + const [y, x] = point; + const adjacents = [[y-1, x], [y+1, x], [y, x-1], [y, x+1]]; + for(let adjacent of adjacents) { + if(points.has(adjacent)) + floodfill(adjacent, piece); + } + } + + const pieces = []; + for(let point of points) { + if(!usedPoints.has(point)) { + const piece = new CoordinateSet(); + floodfill(point, piece); + if(piece.size > 0) + pieces.push(piece); + } + } + return pieces; +} + +function isPseudoLegal(ysize, xsize, stones, chainIds, chainInfosById, y, x, pla) { + if(stones[y][x] !== EMPTY) + return false; + const adjacents = [[y-1, x], [y+1, x], [y, x-1], [y, x+1]]; + const opp = getOpp(pla); + for(let [ay, ax] of adjacents) { + if(isOnBoard(ay, ax, ysize, xsize)) { + if(stones[ay][ax] !== opp) + return true; + if(chainInfosById[chainIds[ay][ax]].liberties.size <= 1) + return true; + } + } + return false; +} + +function countAdjacentsIn(y, x, points) { + let count = 0; + const adjacents = [[y-1, x], [y+1, x], [y, x-1], [y, x+1]]; + for(let a of adjacents) { + if(points.has(a)) + count += 1; + } + return count; +} + +class EyePointInfo { + constructor( + adjPoints, + adjEyePoints, + numEmptyAdjPoints=0, + numEmptyAdjFalsePoints=0, + numEmptyAdjEyePoints=0, + numOppAdjFalsePoints=0, + isFalseEyePoke=false, + numMovesToBlock=0, + numBlockablesDependingOnThisSpot=0 + ) { + this.adjPoints = adjPoints; + this.adjEyePoints = adjEyePoints; + this.numEmptyAdjPoints = numEmptyAdjPoints; + this.numEmptyAdjFalsePoints = numEmptyAdjFalsePoints; + this.numEmptyAdjEyePoints = numEmptyAdjEyePoints; + this.numOppAdjFalsePoints = numOppAdjFalsePoints; + this.isFalseEyePoke = isFalseEyePoke; + this.numMovesToBlock = numMovesToBlock; + this.numBlockablesDependingOnThisSpot = numBlockablesDependingOnThisSpot; + } +} + + +function count(points, predicate) { + let c = 0; + for(let p of points) + if(predicate(p)) { + c++; + } + return c; +} + +function markEyeValues( + ysize, + xsize, + stones, + markedDead, + regionIds, + regionInfosById, + chainIds, + chainInfosById, + isFalseEyePoint, + eyeIds, + eyeInfosById // mutated by this function +) { + for(let eyeId in eyeInfosById) { + eyeId = Number(eyeId); + + const eyeInfo = eyeInfosById[eyeId]; + const pla = eyeInfo.pla; + const opp = getOpp(pla); + + const infoByPoint = {}; + eyeInfo.realPoints = new CoordinateSet(); + for(let [y, x] of eyeInfo.potentialPoints) { + if(!isFalseEyePoint[y][x]) { + eyeInfo.realPoints.add([y, x]); + + const info = new EyePointInfo([], []); + infoByPoint[[y, x]] = info; + } + } + + for(let [y, x] of eyeInfo.realPoints) { + const info = infoByPoint[[y, x]]; + const adjacents = [[y-1, x], [y+1, x], [y, x-1], [y, x+1]]; + for(let [ay, ax] of adjacents) { + if(!isOnBoard(ay, ax, ysize, xsize)) + continue; + + info.adjPoints.push([ay, ax]); + if(eyeInfo.realPoints.has([ay, ax])) + info.adjEyePoints.push([ay, ax]); + } + } + + for(let [y, x] of eyeInfo.realPoints) { + const info = infoByPoint[[y, x]]; + for(let [ay, ax] of info.adjPoints) { + if(stones[ay][ax] === EMPTY) + info.numEmptyAdjPoints += 1; + if(stones[ay][ax] === EMPTY && eyeInfo.realPoints.has([ay, ax])) + info.numEmptyAdjEyePoints += 1; + if(stones[ay][ax] === EMPTY && isFalseEyePoint[ay][ax]) + info.numEmptyAdjFalsePoints += 1; + if(stones[ay][ax] === opp && isFalseEyePoint[ay][ax]) + info.numOppAdjFalsePoints += 1; + } + + if(info.numOppAdjFalsePoints > 0 && stones[y][x] === opp) + info.isFalseEyePoke = true; + if(info.numEmptyAdjFalsePoints >= 2 && stones[y][x] === opp) + info.isFalseEyePoke = true; + } + + for(let [y, x] of eyeInfo.realPoints) { + const info = infoByPoint[[y, x]]; + info.numMovesToBlock = 0; + info.numMovesToBlockNoOpps = 0; + + for(let [ay, ax] of info.adjPoints) { + let block = 0; + if(stones[ay][ax] === EMPTY && !eyeInfo.realPoints.has([ay, ax])) + block = 1; + if(stones[ay][ax] === EMPTY && [ay, ax] in infoByPoint && infoByPoint[[ay, ax]].numOppAdjFalsePoints >= 1) + block = 1; + if(stones[ay][ax] === opp && [ay, ax] in infoByPoint && infoByPoint[[ay, ax]].numEmptyAdjFalsePoints >= 1) + block = 1; + if(stones[ay][ax] === opp && isFalseEyePoint[ay][ax]) + block = 1000; + if(stones[ay][ax] === opp && [ay, ax] in infoByPoint && infoByPoint[[ay, ax]].isFalseEyePoke) + block = 1000; + + info.numMovesToBlock += block; + } + } + + let eyeValue = 0; + if(count(eyeInfo.realPoints, ([y, x]) => infoByPoint[[y, x]].numMovesToBlock <= 1) >= 1) + eyeValue = 1; + + for(let [dy, dx] of eyeInfo.realPoints) { + + if(!isPseudoLegal(ysize, xsize, stones, chainIds, chainInfosById, dy, dx, pla)) + continue; + + const pieces = getPieces(ysize, xsize, eyeInfo.realPoints, new CoordinateSet([[dy, dx]])); + if(pieces.length < 2) + continue; + + let shouldBonus = infoByPoint[[dy, dx]].numOppAdjFalsePoints === 1; + let numDefiniteEyePieces = 0; + for(let piece of pieces) { + let zeroMovesToBlock = false; + for(let point of piece) { + if(infoByPoint[point].numMovesToBlock <= 0) { + zeroMovesToBlock = true; + break; + } + if(shouldBonus && infoByPoint[point].numMovesToBlock <= 1) { + zeroMovesToBlock = true; + break; + } + } + + if(zeroMovesToBlock) + numDefiniteEyePieces++; + } + eyeValue = Math.max(eyeValue, numDefiniteEyePieces); + } + + let markedDeadCount = count(eyeInfo.realPoints, ([y,x]) => stones[y][x] === opp && markedDead[y][x]); + if(markedDeadCount >= 5) + eyeValue = Math.max(eyeValue, 1); + if(markedDeadCount >= 8) + eyeValue = Math.max(eyeValue, 2); + + if(eyeValue < 2 && ( + eyeInfo.realPoints.size + - count(eyeInfo.realPoints, ([y,x]) => infoByPoint[[y,x]].numMovesToBlock >= 1) + - count(eyeInfo.realPoints, ([y,x]) => infoByPoint[[y,x]].numMovesToBlock >= 2) + - count(eyeInfo.realPoints, ([y,x]) => stones[y][x] === opp && infoByPoint[[y,x]].adjEyePoints.length >= 2) + >= 6 + )) { + eyeValue = Math.max(eyeValue, 2); + } + + if(eyeValue < 2 && ( + count(eyeInfo.realPoints, ([y,x]) => stones[y][x] === EMPTY && infoByPoint[[y,x]].adjEyePoints.length >= 4) + + count(eyeInfo.realPoints, ([y,x]) => stones[y][x] === EMPTY && infoByPoint[[y,x]].adjEyePoints.length >= 3) + >= 6 + )) { + eyeValue = Math.max(eyeValue, 2); + } + + if(eyeValue < 2) { + for(let [dy, dx] of eyeInfo.realPoints) { + if(stones[dy][dx] !== EMPTY) + continue; + if(isOnBorder(dy, dx, ysize, xsize)) + continue; + if(!isPseudoLegal(ysize, xsize, stones, chainIds, chainInfosById, dy, dx, pla)) + continue; + + const info1 = infoByPoint[[dy, dx]]; + if(info1.numMovesToBlock > 1 || info1.adjEyePoints.length < 3) + continue; + + for(let adjacent of info1.adjEyePoints) { + const info2 = infoByPoint[adjacent]; + if(info2.adjEyePoints.length < 3) + continue; + if(info2.numMovesToBlock > 1) + continue; + + const [dy2, dx2] = adjacent; + if(stones[dy2][dx2] !== EMPTY && info2.numEmptyAdjEyePoints <= 1) + continue; + + const pieces = getPieces(ysize, xsize, eyeInfo.realPoints, new CoordinateSet([[dy, dx], adjacent])); + if(pieces.length < 2) + continue; + + let numDefiniteEyePieces = 0; + let numDoubleDefiniteEyePieces = 0; + + for(let piece of pieces) { + let numZeroMovesToBlock = 0; + for(let point of piece) { + if(infoByPoint[point].numMovesToBlock <= 0) { + numZeroMovesToBlock += 1; + if(numZeroMovesToBlock >= 2) + break; + } + } + if(numZeroMovesToBlock >= 1) + numDefiniteEyePieces += 1; + if(numZeroMovesToBlock >= 2) + numDoubleDefiniteEyePieces += 1; + } + + if(numDefiniteEyePieces >= 2 && + numDoubleDefiniteEyePieces >= 1 && + (stones[dy2][dx2] === EMPTY || numDoubleDefiniteEyePieces >= 2) + ) { + eyeValue = Math.max(eyeValue, 2); + break; + } + + } + + if(eyeValue >= 2) + break; + } + } + + if(eyeValue < 2) { + const deadOppsInEye = new CoordinateSet(); + const unplayableInEye = []; + for(let point of eyeInfo.realPoints) { + const [dy, dx] = point; + if(stones[dy][dx] === opp && markedDead[dy][dx]) + deadOppsInEye.add(point); + else if(!isPseudoLegal(ysize, xsize, stones, chainIds, chainInfosById, dy, dx, pla)) + unplayableInEye.push(point); + } + + if(deadOppsInEye.size > 0) { + let numThrowins = 0; + for(let [y,x] of eyeInfo.potentialPoints) { + if(stones[y][x] === opp && isFalseEyePoint[y][x]) + numThrowins += 1; + } + + const possibleOmissions = [...unplayableInEye]; + possibleOmissions.push(null); + let allGoodForDefender = true; + for(let omitted of possibleOmissions) { + const remainingShape = deadOppsInEye.copy(); + for(let point of unplayableInEye) { + if(point !== omitted) + remainingShape.add(point); + } + + const initialPieceCount = getPieces(ysize, xsize, remainingShape, new CoordinateSet()).length; + let numBottlenecks = 0; + let numNonBottlenecksHighDegree = 0; + for(let pointToDelete of remainingShape) { + const [dy,dx] = pointToDelete; + if(getPieces(ysize, xsize, remainingShape, new CoordinateSet([pointToDelete])).length > initialPieceCount) + numBottlenecks += 1; + else if(countAdjacentsIn(dy,dx,remainingShape) >= 3) + numNonBottlenecksHighDegree += 1; + } + + let bonus = 0; + if(remainingShape.size >= 7) + bonus += 1; + + if(initialPieceCount - numThrowins + Math.floor((numBottlenecks + numNonBottlenecksHighDegree + bonus) / 2) < 2) { + allGoodForDefender = false; + break; + } + } + if(allGoodForDefender) + eyeValue = 2; + } + } + + eyeValue = Math.min(eyeValue, 2); + eyeInfo.eyeValue = eyeValue; + } +} + +function markScoring( + ysize, + xsize, + stones, + markedDead, + scoreFalseEyes, + strictReachesBlack, + strictReachesWhite, + regionIds, + regionInfosById, + chainIds, + chainInfosById, + isFalseEyePoint, + eyeIds, + eyeInfosById, + isUnscorableFalseEyePoint, + scoring // mutated by this function +) { + const extraBlackUnscoreablePoints = new CoordinateSet(); + const extraWhiteUnscoreablePoints = new CoordinateSet(); + for(let y = 0; y < ysize; y++) { + for(let x = 0; x < xsize; x++) { + if(isUnscorableFalseEyePoint[y][x] && stones[y][x] != EMPTY && markedDead[y][x]) { + const adjacents = [[y-1, x], [y+1, x], [y, x-1], [y, x+1]]; + if(stones[y][x] == WHITE) { + for(const point of adjacents) + extraBlackUnscoreablePoints.add(point); + } + else { + for(const point of adjacents) + extraWhiteUnscoreablePoints.add(point); + } + } + } + } + + for(let y = 0; y < ysize; y++) { + for(let x = 0; x < xsize; x++) { + const s = scoring[y][x]; + const regionId = regionIds[y][x]; + + if(regionId === -1) { + s.isDame = true; + } + else { + const regionInfo = regionInfosById[regionId]; + const color = regionInfo.color; + const totalEyes = Array.from(regionInfo.eyes) + .reduce((acc, eyeId) => acc + eyeInfosById[eyeId].eyeValue, 0); + + if(totalEyes <= 1) + s.belongsToSekiGroup = regionInfo.color; + if(isFalseEyePoint[y][x]) + s.isFalseEye = true; + if(isUnscorableFalseEyePoint[y][x]) + s.isUnscorableFalseEye = true; + if((stones[y][x] == EMPTY || markedDead[y][x]) && ( + (color == BLACK && extraBlackUnscoreablePoints.has([y,x])) || + (color == WHITE && extraWhiteUnscoreablePoints.has([y,x])) + )) { + s.isUnscorableFalseEye = true; + } + + s.eyeValue = eyeIds[y][x] !== -1 ? eyeInfosById[eyeIds[y][x]].eyeValue : 0; + + if((stones[y][x] !== color || markedDead[y][x]) && + s.belongsToSekiGroup === EMPTY && + (scoreFalseEyes || !s.isUnscorableFalseEye) && + chainInfosById[chainIds[y][x]].regionId === regionId && + !(color === WHITE && strictReachesBlack[y][x]) && + !(color === BLACK && strictReachesWhite[y][x]) + ) { + s.isTerritoryFor = color; + } + } + } + } +} + +function assert(condition, message) { + if(!condition) { + throw new Error(message || "Assertion failed"); + } +} + +class CoordinateSet { + constructor(initialCoords = []) { + this.map = new Map(); + this.size = 0; + + if(initialCoords.length > 0) { + for(let coord of initialCoords) { + this.add(coord); + } + } + } + + add(coord) { + const [y, x] = coord; + if(!this.map.has(y)) { + this.map.set(y, new Set()); + } + if(!this.map.get(y).has(x)) { + this.map.get(y).add(x); + this.size++; + } + } + + has(coord) { + const [y, x] = coord; + return this.map.has(y) && this.map.get(y).has(x); + } + + forEach(callback) { + for(let [y, xSet] of this.map.entries()) { + for(let x of xSet) { + callback([y, x]); + } + } + } + + copy() { + const ret = new CoordinateSet(); + ret.map = new Map(this.map); + ret.size = this.size; + return ret; + } + + [Symbol.iterator]() { + const allCoords = []; + for(let [y, xSet] of this.map.entries()) { + for(let x of xSet) { + allCoords.push([y, x]); + } + } + return allCoords[Symbol.iterator](); + } +} + + +export { + EMPTY, + BLACK, + WHITE, + LocScore, + finalTerritoryScore, + finalAreaScore, + territoryScoring, + areaScoring, + + // Other utils + getOpp, + isOnBoard, + isOnBorder, + print2d, + string2d, + string2d2, + colorToStr, +}; + diff --git a/src/protocol/ClientToServer.ts b/src/protocol/ClientToServer.ts index 18bfe4af..cfe91b2b 100644 --- a/src/protocol/ClientToServer.ts +++ b/src/protocol/ClientToServer.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { JGOFPlayerClock } from "../JGOF"; +import type { JGOFMove, JGOFPlayerClock, JGOFSealingIntersection } from "../JGOF"; import type { ReviewMessage } from "../GoEngine"; import type { ConditionalMoveResponse } from "../GoConditionalMove"; @@ -149,8 +149,15 @@ export interface ClientToServer extends ClientToServerBase { * not removed / open area. */ removed: boolean; - /** String encoded list of intersections */ - stones: string; + /** List of intersections that are to be removed. */ + stones: JGOFMove[] | string; + + /** List of intersections that need to be sealed before the game can be + * correctly scored. Note, if this is undefined, the value will not + * be changed on the server side. To specify there are no more intersections + * that need to be cleared, set it to `[]` specifically. + */ + needs_sealing?: JGOFSealingIntersection[]; /** Japanese rules technically have some special scoring rules about * whether territory in seki should be counted or not. This is supported diff --git a/test/autoscore_test_files/game_33811578.json b/test/autoscore_test_files/game_33811578.json index 30e3018a..c9a14000 100644 --- a/test/autoscore_test_files/game_33811578.json +++ b/test/autoscore_test_files/game_33811578.json @@ -63,7 +63,7 @@ [ 0.9, 0.9, 1.0, 1.0, 1.0, 1.0, 0.5, 0.4, -1.0, -0.9, -0.9, -1.0, -0.9, -1.0, -1.0, -0.9, -1.0, -0.9, -0.9], [ 0.9, 0.9, 0.9, 0.9, 0.9, 0.6, 0.3, 0.3, -1.0, -0.9, -0.9, -1.0, -0.9, -0.9, -0.9, -0.9, -0.9, -0.9, -1.0] ], - "correct_ownership": [ + "sealed_ownership": [ "BBBBBBBBBBBBBWWWWWW", "BBBBBBBBBBBBWWWWWWW", "BBBWBBBBBBBWWWWWWWW", @@ -83,6 +83,26 @@ "BBBBBBBWWWWWWWWWWWW", "BBBBBBBBWWWWWWWWWWW", "BBBBBBBBWWWWWWWWWWW" + ], + "correct_ownership": [ + " BBWWWWWW", + " B BBWWWWWWW", + " BWBB BBWWWWWWWW", + " BWW BsBBWWBBBWWWW", + " BBWWWWWBWs BWWW", + " BBBWWWBWBBB BWWW", + " sWWWWWBBWWBBWWW", + " BBWWWWWBBBWWWWWW", + " BWWWWB BWWWWWW", + " B W B BWWWBBW ", + " B BBWWBsBBW BWWB", + " BBBWWWWBBBBBBB", + " B BBBBWWWWBBBBB", + " B BBBBWBBBBBB", + " B BBWWWWBBWWW", + " B BBWWWWWWWWWW", + " B BBWWWWWWWWWWWW", + " B BWWWWWWWWWWW", + " BWWWWWWWWWWW" ] - } diff --git a/test/autoscore_test_files/game_33921785.json b/test/autoscore_test_files/game_33921785.json index 3a22af6a..5a5a9db7 100644 --- a/test/autoscore_test_files/game_33921785.json +++ b/test/autoscore_test_files/game_33921785.json @@ -33,7 +33,7 @@ [-1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0], [-1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0] ], - "correct_ownership": [ + "sealed_ownership": [ "WWWBBBBBB", "WWWWBBBBB", "WWWBBBBBB", @@ -43,5 +43,16 @@ "WWWWBWWBs", "WWWWWWWWW", "WWWWWWWWW" + ], + "correct_ownership": [ + "WWWBBB ", + "WWWWB ", + "WWWB ", + "WWWB B ", + "WWBB ", + "WWWBBBBB ", + "WWWWBWWBs", + "WWWWWWWWW", + "WWWWWWWWW" ] } diff --git a/test/autoscore_test_files/game_35115094.json b/test/autoscore_test_files/game_35115094.json index a184f805..a4fdf00c 100644 --- a/test/autoscore_test_files/game_35115094.json +++ b/test/autoscore_test_files/game_35115094.json @@ -63,7 +63,7 @@ [-1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0], [-1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0] ], - "correct_ownership": [ + "sealed_ownership": [ "BBBWWWWWWWWBBBBBWWW", "BBBWWWWWWWWBBBBWWWW", "BBBBBBBBBBBWBBBBWWW", @@ -83,5 +83,26 @@ "WWWWWWWWBWBBBBBBBWW", "WWWWWWWBBBBBBBBBBBW", "WWWWWWWWBBBBBBBBBBW" + ], + "correct_ownership": [ + "BBBWWWWWWWWBBBBBWWW", + "BBBWWWWWWWWBBBBWWWW", + "BBBBBBBBBBBWBBBBWWW", + "BBBBBBBBB WWW WWWWW", + "ssBBBBBBBWWWWWWBBWW", + "sBBBBBWBBWWWBBBBBBW", + " WWBWWWWWBBBBBBBBBB", + " WBBBWWBBBBBBBBBBB", + " WWWBWBBBBWWBBBBB", + " WWWWBWWBWB WBBBBB", + " WWBBWWWWB WWBBBB", + " W WBBBWBWWBBBWWBBB", + " WBBBBBBWB WWWBWB", + " WWBBBBWWBWWWWWWW", + " W WWWBBBBWWWBBWWWW", + "WW WWBBWWWBBBBWWW", + "WWW WWWBWBBBBBBBWW", + "WWW WBBBBBBBBBBBW", + "WW WWBBBBBBBBBBW" ] } diff --git a/test/autoscore_test_files/game_35115743.json b/test/autoscore_test_files/game_35115743.json index d585028e..94924c49 100644 --- a/test/autoscore_test_files/game_35115743.json +++ b/test/autoscore_test_files/game_35115743.json @@ -63,7 +63,7 @@ [ 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0, -0.9, -0.9, -0.9, -0.9, -0.9, -0.9, -1.0, -1.0, 1.0, 1.0, 1.0], [ 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0, -1.0, -0.9, -0.9, -0.9, -0.9, -0.9, -0.9, -0.9, -1.0, -1.0, 1.0, 1.0] ], - "correct_ownership": [ + "sealed_ownership": [ "BWWWWWWWWWWWWWWWWWW", "BWWWWWWWWWWWWWWWWWW", "BBWWWWWWWWWWWWWWWWW", @@ -83,5 +83,26 @@ "BBBBBWWWWWWWWWWBBBB", "BBBBBBBWWWWWWWWWBBB", "BBBBBBWWWWWWWWWWWBB" + ], + "correct_ownership": [ + "BWWWWWWWWWWWWWWWWWW", + "BWWWWWWWWWWWWWWWWWW", + "BBWWWWWWWWWWWWWWWWW", + "BBBWWWWWWBBWWWWWWWW", + "BBBBWWWWWBBBWWBBWBW", + "BBBBWWWWBBB BB BBB", + "BBWBBWBBBBB ", + "WWWBWWWWWBB ", + "W WWWWWWWWBBB B ", + "BBBBWWWWWWWWB B ", + "BBB WWWWWWWWWB ", + "BBWWWWWWWWWWWBB ", + "BBBWWWWWWWWWWs ", + "BBBWWWWWWWWWWBB ", + "BBBBWWWWWWWWWWBB ", + "BBBBBBWWWWWWWWWBB ", + "BBBBBWWWWWWWWWWBBB ", + "BBBBBBBWWWWWWWWWB ", + "BBBBBBWWWWWWWWWWWB " ] } diff --git a/test/autoscore_test_files/game_64554594.json b/test/autoscore_test_files/game_64554594.json index ed1aa5fb..86200a09 100644 --- a/test/autoscore_test_files/game_64554594.json +++ b/test/autoscore_test_files/game_64554594.json @@ -63,7 +63,7 @@ [ 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0, -1.0, -1.0, -0.9, -0.9, 0.7], [ 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0, -1.0, -0.9, -0.9, -0.8, -0.0] ], - "correct_ownership": [ + "sealed_ownership": [ "BBBBBBBBBBBBBBBBBBB", "BBBBBBBBBBBBBBBBBBB", "BBBBBBBBBBBBBBBBBBB", @@ -83,5 +83,26 @@ "BBBBBBBBBBBBBW*WWB*", "BBBBBBBBBBBBBW*****", "BBBBBBBBBBBBBW*****" + ], + "correct_ownership": [ + " ", + " ", + " B B ", + " B B B B ", + " B B ", + "BBBBBBB B ", + "BWWWWWWBBBBBB B ", + "WWWWWWWWWWWW B ", + "WWWWWWWWWWWWW B B ", + "WWWWWWWWWWWWWWB ", + "WWWWWWWWWWWWW B ", + "WWWWWWWWWWWWWWB B ", + "BBBWWWWWWWWWWWB ", + "BBBBBWWWWWWWWWWBB ", + "BBBBBBWWB WWWWWWB ", + "BBBBBBBBBBBBWWWWBB ", + "BBBBBBBBBBBBBWsWWB ", + "BBBBBBBBBBBBBWsss ", + "BBBBBBBBBBBBBWs " ] } diff --git a/test/autoscore_test_files/game_dev_51749995.json b/test/autoscore_test_files/game_dev_51749995.json index 9d956b58..07d323a3 100644 --- a/test/autoscore_test_files/game_dev_51749995.json +++ b/test/autoscore_test_files/game_dev_51749995.json @@ -63,18 +63,18 @@ [ 0.9, 0.9, 1.0, 1.0, 1.0, 1.0, 0.5, 0.3, -1.0, -0.9, -0.9, -1.0, -0.9, -1.0, -1.0, -0.9, -1.0, -0.9, -0.9], [ 0.9, 0.9, 0.9, 0.9, 0.9, 0.6, 0.3, 0.2, -1.0, -0.9, -0.9, -1.0, -0.9, -0.9, -0.9, -1.0, -0.9, -0.9, -1.0] ], - "correct_ownership": [ + "sealed_ownership": [ "BBBBBBBBBBBBBWWWWWW", "BBBBBBBBBBBBWWWWWWW", "BBBWBBBBBBBWWWWWWWW", - "BBBWW BsBBWWBBBWWWW", - "BBBBWWWWWBWsBBBBWWW", + "BBBWW*BsBBWWBBBWWWW", + "BBBBWWWWWBWs*BBBWWW", "BBBBBBWWWBWBBBBBWWW", "BBBBsWWWWWBBWWBBWWW", "BBBBBWWWWWBBBWWWWWW", "BBBBBWWWWBBBBWWWWWW", - "BBBBBB W BBBWWWBBW ", - "BBBBBBBWWBsBBW BWWB", + "BBBBBB*W*BBBWWWBBW*", + "BBBBBBBWWBsBBW*BWWB", "BBBBBBBBWWWWBBBBBBB", "BBBBBBBBBBWWWWBBBBB", "BBBBBBBBBBBBWBBBBBB", @@ -83,5 +83,26 @@ "BBBBBBBWWWWWWWWWWWW", "BBBBBBBBWWWWWWWWWWW", "BBBBBBBBWWWWWWWWWWW" + ], + "correct_ownership": [ + " BBWWWWWW", + " B BBWWWWWWW", + " BWBB BBWWWWWWWW", + " BWW BsBBWWBBBWWWW", + " BBWWWWWBWs BWWW", + " BBBWWWBWBBB BWWW", + " sWWWWWBBWWBBWWW", + " BBWWWWWBBBWWWWWW", + " BWWWWB BWWWWWW", + " B W B BWWWBBW ", + " B BBWWBsBBW BWWB", + " BBBWWWWBBBBBBB", + " B BBBBWWWWBBBBB", + " B BBBBWBBBBBB", + " B BBWWWWBBWWW", + " B BBWWWWWWWWWW", + " B BBWWWWWWWWWWWW", + " B BWWWWWWWWWWW", + " BWWWWWWWWWWW" ] } diff --git a/test/autoscore_test_files/game_seki_64848549.json b/test/autoscore_test_files/game_seki_64848549.json new file mode 100644 index 00000000..f82c795c --- /dev/null +++ b/test/autoscore_test_files/game_seki_64848549.json @@ -0,0 +1,88 @@ +{ + "game_id": 64848549, + "rules": "japanese", + "board": [ + " b Wb bWb WWb W b ", + "bWWWb bWbbbbbbWbbb", + "b Wbb bWWWWWWWbbWW", + "WWWb bW WWW ", + "bbbb bWWWWWWW WWW", + "WWWWb b bbWbbWbbbbb", + " W Wb b b bb WWW", + " WWb b bbWWWWb ", + "WWWbWb bW bW WWW", + " WWb b bW bWbbW ", + " Wb bbWbbW Wbb", + "bWb bbbbWW WWbbbbb ", + "WbbbW bW WWW bb", + "W bWW bWWWWW W WWW", + "WWWW Wbb bbW WWWWbb", + "bbbWWb bbb bbbbWb ", + " b bWbbb bbW bWbbb", + "bWbbW bWWWWWWWbWb W", + " W bW bW bbb WbWbW " + ], + "black": [ + [ 1.0, 1.0, -0.1, -1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 0.8, 0.9, 1.0, -0.0, -1.0, -0.0, 1.0, 1.0], + [ 1.0, -1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0], + [ 1.0, -0.1, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, 1.0, 1.0, -1.0, -1.0], + [-1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0], + [ 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -0.4, -1.0, -1.0, -1.0], + [-1.0, -1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0], + [-1.0, -1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.4, 1.0, 1.0, 1.0, 1.0, 0.6, -1.0, -1.0, -1.0], + [-1.0, -1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.6, 1.0, 1.0, -1.0, -1.0, -1.0, -1.0, -0.8, -0.9], + [-1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 0.4, 1.0, -1.0, -0.2, -0.2, -1.0, -1.0, -1.0], + [-1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 0.2, 1.0, -1.0, 1.0, 1.0, -1.0, -0.1, 0.0], + [-1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0, 1.0, -1.0, 0.3, 0.5, -1.0, 1.0, 1.0], + [-1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0, -1.0, -1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.9], + [-1.0, 1.0, 1.0, 1.0, -1.0, -0.3, 0.8, 1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -0.7, 1.0, 1.0], + [-1.0, -0.3, 0.4, 1.0, -1.0, -1.0, 0.1, 1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0], + [-1.0, -1.0, -1.0, -1.0, -1.0, -1.0, 1.0, 1.0, 0.3, 1.0, 1.0, -1.0, -0.5, -1.0, -1.0, -1.0, -1.0, 1.0, 1.0], + [ 1.0, 1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0], + [ 1.0, 1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0, 0.2, 1.0, 1.0, -1.0, -0.3, 0.3, 1.0, -1.0, 1.0, 1.0, 1.0], + [ 1.0, -1.0, 1.0, 1.0, -1.0, 0.3, 1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, 1.0, -1.0, 1.0, -0.0, -1.0], + [-0.0, -1.0, 0.1, 1.0, -1.0, 0.2, 1.0, -1.0, 0.0, 1.0, 1.0, 1.0, 0.0, -1.0, 1.0, -1.0, 1.0, -1.0, -0.9] + ], + "white": [ + [ 1.0, 1.0, -0.1, -1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 0.8, 0.9, 1.0, -0.1, -0.9, -0.0, 1.0, 1.0], + [ 1.0, -1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0], + [ 1.0, -0.1, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, 1.0, 1.0, -1.0, -1.0], + [-1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0], + [ 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -0.5, -1.0, -1.0, -1.0], + [-1.0, -1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0], + [-1.0, -1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.4, 1.0, 1.0, 1.0, 1.0, 0.5, -1.0, -1.0, -1.0], + [-1.0, -1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.6, 1.0, 1.0, -1.0, -1.0, -1.0, -1.0, -0.8, -0.8], + [-1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 0.3, 1.0, -1.0, -0.2, -0.2, -1.0, -1.0, -1.0], + [-1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 0.1, 1.0, -1.0, 1.0, 1.0, -1.0, -0.2, -0.1], + [-1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0, 1.0, -1.0, 0.3, 0.5, -1.0, 1.0, 1.0], + [-1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0, -1.0, -1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.9], + [-1.0, 1.0, 1.0, 1.0, -1.0, -0.3, 0.8, 1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -0.6, 1.0, 1.0], + [-1.0, -0.3, 0.4, 1.0, -1.0, -1.0, -0.0, 1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0], + [-1.0, -1.0, -1.0, -1.0, -1.0, -1.0, 1.0, 1.0, 0.2, 1.0, 1.0, -1.0, -0.5, -1.0, -1.0, -1.0, -1.0, 1.0, 1.0], + [ 1.0, 1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0], + [ 1.0, 1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0, 0.2, 1.0, 1.0, -1.0, -0.3, 0.2, 1.0, -1.0, 1.0, 1.0, 1.0], + [ 1.0, -1.0, 1.0, 1.0, -1.0, 0.3, 1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, 1.0, -1.0, 1.0, -0.0, -1.0], + [-0.0, -1.0, 0.1, 1.0, -1.0, 0.2, 1.0, -1.0, -0.0, 1.0, 1.0, 1.0, 0.0, -1.0, 1.0, -1.0, 1.0, -1.0, -0.9] + ], + "correct_ownership": [ + " B WBBBBWB B W B ", + "BWWWBBBBWBBBBBBWBBB", + "B WBBBBBWWWWWWWBBWW", + "WWWBBBBBWWWWWWWWWWW", + "BBBBBBBBWWWWWWW WWW", + "WWWWBBBBBBWBBWBBBBB", + "WWWWBBBBBB B BB WWW", + "WWWWBBBBBB BBWWWW ", + "WWWBBBBBBBW BW WWW", + "WWWBBBBBBBW BWBBW ", + " WBBBBBBBBWBBW WBB", + " WBBBBBBWW WWBBBBB ", + "WBBBW BWWWWWWWW BB", + "W BWW BWWWWWWWWWWW", + "WWWW WBB BBW WWWWBB", + "BBBWWBBBBB BBBBWB ", + " B BWBBB BBW BWBBB", + "BWBBW BWWWWWWWBWB W", + " W BW BW BBB WBWBW " + ] +} diff --git a/test/test_autoscore.ts b/test/test_autoscore.ts index 90047fd4..1b14a124 100755 --- a/test/test_autoscore.ts +++ b/test/test_autoscore.ts @@ -31,7 +31,7 @@ import { autoscore } from "../src/autoscore"; import * as clc from "cli-color"; import { GoEngine, GoEngineInitialState } from "../src/GoEngine"; import { char2num, makeMatrix, num2char } from "../src/GoMath"; -import { JGOFNumericPlayerColor } from "../src/JGOF"; +import { JGOFMove, JGOFNumericPlayerColor, JGOFSealingIntersection } from "../src/JGOF"; function run_autoscore_tests() { const test_file_directory = "autoscore_test_files"; @@ -109,6 +109,10 @@ function test_file(path: string, quiet: boolean): boolean { if (!data.correct_ownership) { throw new Error(`${path} correct_ownership field is invalid`); } + + const rules = data.rules ?? "chinese"; + + // validate ownership structures look ok for (const row of data.correct_ownership) { for (const cell of row) { const is_w_or_b = @@ -124,174 +128,220 @@ function test_file(path: string, quiet: boolean): boolean { } } } + if (data.sealed_ownership) { + for (const row of data.sealed_ownership) { + for (const cell of row) { + const is_w_or_b = + cell === "W" || // owned by white + cell === "B" || // owned by black + cell === " " || // dame + cell === "*" || // anything + cell === "s"; // marked for needing to seal + if (!is_w_or_b) { + throw new Error( + `${path} correct_ownership field contains "${cell}" which is invalid`, + ); + } + } + } + } // run autoscore - const [res, debug_output] = autoscore(data.board, data.black, data.white); - - let match = true; - const matches: boolean[][] = []; - - for (let y = 0; y < res.result.length; ++y) { - matches[y] = []; - for (let x = 0; x < res.result[0].length; ++x) { - const v = res.result[y][x]; - let m = - data.correct_ownership[y][x] === "*" || - data.correct_ownership[y][x] === "s" || // seal - (v === 0 && data.correct_ownership[y][x] === " ") || - (v === 1 && data.correct_ownership[y][x] === "B") || - (v === 2 && data.correct_ownership[y][x] === "W"); - - if (data.correct_ownership[y][x] === "s") { - const has_needs_sealing = - res.needs_sealing.find(([x2, y2]) => x2 === x && y2 === y) !== undefined; - - m &&= has_needs_sealing; - } + const [res, debug_output] = autoscore(data.board, rules, data.black, data.white); - matches[y][x] = m; - match &&= m; - } + if (!quiet) { + console.log(""); + console.log(debug_output); + console.log(""); } - /* Ensure all needs_sealing are marked as such */ - for (const [x, y] of res.needs_sealing) { - if (data.correct_ownership[y][x] !== "s" && data.correct_ownership[y][x] !== "*") { - console.error( - `Engine thought we needed sealing at ${x},${y} but the that spot wasn't flagged as needing it in the test file`, - ); - match = false; - matches[y][x] = false; - } + let ok = true; + if (data.sealed_ownership) { + ok &&= test_result( + "Sealed ownership", + res.sealed_result, + res.removed, + res.needs_sealing, + true, + data.sealed_ownership, + quiet, + ); } + ok &&= test_result( + "Correct ownership", + res.result, + res.removed, + res.needs_sealing, + false, + data.correct_ownership, + quiet, + ); - if (!quiet) { - // Double check that when we run everything through our normal GoEngine.computeScore function, - // that we get the result we're expecting - if (match) { - const board = original_board.map((row: number[]) => row.slice()); - - let black_state = ""; - let white_state = ""; - - for (let y = 0; y < board.length; ++y) { - for (let x = 0; x < board[y].length; ++x) { - const v = board[y][x]; - const c = num2char(x) + num2char(y); - if (v === JGOFNumericPlayerColor.BLACK) { - black_state += c; - } else if (v === 2) { - white_state += c; - } + return ok; + + function test_result( + mnemonic: string, + result: JGOFNumericPlayerColor[][], + removed: JGOFMove[], + needs_sealing: JGOFSealingIntersection[], + perform_sealing: boolean, + correct_ownership: string[], + quiet: boolean, + ) { + if (!quiet) { + console.log(""); + console.log(`=== Testing ${mnemonic} ===`); + } + + let match = true; + const matches: boolean[][] = []; + + for (let y = 0; y < result.length; ++y) { + matches[y] = []; + for (let x = 0; x < result[0].length; ++x) { + const v = result[y][x]; + let m = + correct_ownership[y][x] === "*" || + correct_ownership[y][x] === "s" || // seal + (v === 0 && correct_ownership[y][x] === " ") || + (v === 1 && correct_ownership[y][x] === "B") || + (v === 2 && correct_ownership[y][x] === "W"); + + if (correct_ownership[y][x] === "s") { + const has_needs_sealing = + needs_sealing.find((pt) => pt.x === x && pt.y === y) !== undefined; + + m &&= has_needs_sealing; } + + matches[y][x] = m; + match &&= m; } + } - const initial_state: GoEngineInitialState = { - black: black_state, - white: white_state, - }; + /* Ensure all needs_sealing are marked as such */ + for (const { x, y } of needs_sealing) { + if (correct_ownership[y][x] !== "s" && correct_ownership[y][x] !== "*") { + console.error( + `Engine thought we needed sealing at ${x},${y} but the that spot wasn't flagged as needing it in the test file`, + ); + match = false; + matches[y][x] = false; + } + } - const engine = new GoEngine({ - width: board[0].length, - height: board.length, - initial_state, - rules: "chinese", // for area scoring - removed: res.removed_string, - }); + if (!quiet) { + // Double check that when we run everything through our normal GoEngine.computeScore function, + // that we get the result we're expecting. We exclude the japanese and korean rules here because + // our test file ownership maps always include territory and stones. + if (match && rules !== "japanese" && rules !== "korean") { + const board = original_board.map((row: number[]) => row.slice()); - const score = engine.computeScore(); + if (perform_sealing) { + for (const { x, y, color } of needs_sealing) { + board[y][x] = color; + } + } - const scored_board = makeMatrix(board[0].length, board.length, 0); + let black_state = ""; + let white_state = ""; + + for (let y = 0; y < board.length; ++y) { + for (let x = 0; x < board[y].length; ++x) { + const v = board[y][x]; + const c = num2char(x) + num2char(y); + if (v === JGOFNumericPlayerColor.BLACK) { + black_state += c; + } else if (v === 2) { + white_state += c; + } + } + } - for (let i = 0; i < score.black.scoring_positions.length; i += 2) { - const x = char2num(score.black.scoring_positions[i]); - const y = char2num(score.black.scoring_positions[i + 1]); - scored_board[y][x] = JGOFNumericPlayerColor.BLACK; - } - for (let i = 0; i < score.white.scoring_positions.length; i += 2) { - const x = char2num(score.white.scoring_positions[i]); - const y = char2num(score.white.scoring_positions[i + 1]); - scored_board[y][x] = JGOFNumericPlayerColor.WHITE; - } + const initial_state: GoEngineInitialState = { + black: black_state, + white: white_state, + }; - let official_match = true; - const official_matches: boolean[][] = []; - for (let y = 0; y < scored_board.length; ++y) { - official_matches[y] = []; - for (let x = 0; x < scored_board[0].length; ++x) { - const v = scored_board[y][x]; - const m = - data.correct_ownership[y][x] === "*" || - (v === 0 && data.correct_ownership[y][x] === "s") || - (v === 0 && data.correct_ownership[y][x] === " ") || - (v === 1 && data.correct_ownership[y][x] === "B") || - (v === 2 && data.correct_ownership[y][x] === "W"); - official_matches[y][x] = m; - official_match &&= m; - } - } + const engine = new GoEngine({ + width: board[0].length, + height: board.length, + initial_state, + rules, + removed, + }); - if (!quiet) { - console.log(""); - console.log(""); - console.log("Final scored board"); - print_expected( - scored_board.map((row) => - row.map((v) => (v === 1 ? "B" : v === 2 ? "W" : " ")).join(""), - ), - ); + const score = engine.computeScore(); + + const scored_board = makeMatrix(board[0].length, board.length, 0); - if (official_match) { - console.log("Final autoscore matches official scoring"); - } else { - console.error("Official score did not match our expected scoring"); - print_mismatches(official_matches); + for (let i = 0; i < score.black.scoring_positions.length; i += 2) { + const x = char2num(score.black.scoring_positions[i]); + const y = char2num(score.black.scoring_positions[i + 1]); + scored_board[y][x] = JGOFNumericPlayerColor.BLACK; + } + for (let i = 0; i < score.white.scoring_positions.length; i += 2) { + const x = char2num(score.white.scoring_positions[i]); + const y = char2num(score.white.scoring_positions[i + 1]); + scored_board[y][x] = JGOFNumericPlayerColor.WHITE; } - } - match &&= official_match; - } + let official_match = true; + const official_matches: boolean[][] = []; + for (let y = 0; y < scored_board.length; ++y) { + official_matches[y] = []; + for (let x = 0; x < scored_board[0].length; ++x) { + const v = scored_board[y][x]; + const m = + correct_ownership[y][x] === "*" || + correct_ownership[y][x] === "s" || + //(v === 0 && correct_ownership[y][x] === "s") || + (v === 0 && correct_ownership[y][x] === " ") || + (v === 1 && correct_ownership[y][x] === "B") || + (v === 2 && correct_ownership[y][x] === "W"); + official_matches[y][x] = m; + official_match &&= m; + } + } - if (!match) { - console.log(""); - console.log(""); - console.log(`>>> ${path} failed`); - console.log(`>>> ${path} failed`); - console.log(`>>> ${path} failed`); - console.log(""); - console.log(debug_output); - console.log(""); - console.log("Expected ownership:"); - print_expected(data.correct_ownership); - console.log("Mismatches:"); - print_mismatches(matches); - console.log(""); + if (!quiet && !official_match) { + console.log(""); + console.log(""); + console.log("Final scored board"); + print_expected( + scored_board.map((row) => + row.map((v) => (v === 1 ? "B" : v === 2 ? "W" : " ")).join(""), + ), + ); + + if (official_match) { + console.log("Final autoscore matches official scoring"); + } else { + console.error("Official score did not match our expected scoring"); + print_mismatches(official_matches); + } + } - /* - console.log("Removed"); - for (const [x, y, reason] of res.removed) { - console.log( - ` ${"ABCDEFGHJKLMNOPQRSTUVWXYZ"[x]}${data.board.length - y}: ${reason}`, - ); + match &&= official_match; } - */ - console.log(`<<< ${path} failed`); - console.log(`<<< ${path} failed`); - console.log(`<<< ${path} failed`); - console.log(""); - console.log(""); - } else { - console.log(""); - console.log(""); - console.log(debug_output); + if (!match) { + console.log("Expected ownership:"); + print_expected(correct_ownership); + console.log("Mismatches:"); + print_mismatches(matches); + console.log(""); + + console.log(`${mnemonic} ${path} failed`); + } else { + console.log(`${mnemonic} ${path} passed`); + } console.log(""); console.log(""); - console.log(`${path} passed`); } - } - return match; + return match; + } } if (require.main === module) { diff --git a/tsconfig.json b/tsconfig.json index 3d68a67d..4085b2d7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,6 +25,7 @@ "strictNullChecks": true, "strictPropertyInitialization": true, "noUnusedLocals": true, + "allowJs": true, "sourceMap": true, "jsx": "react" diff --git a/webpack.config.js b/webpack.config.js index 22214442..a6aa5815 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -166,11 +166,6 @@ module.exports = (env, argv) => { filename: '[name].js' }, - externals: { - "pixi.js": "PIXI", - "pixi.js-legacy": "PIXI", - }, - plugins: plugins.concat([ new webpack.DefinePlugin({ CLIENT: false,