UNPKG

@reis/seki

Version:

Seki – A modern javascript based Go board renderer and player, that is simple to use, extensible, compact and intuitive.

2,293 lines (1,982 loc) 53.1 kB
import Base from './base.js' import {ErrorOutcome, ValidOutcome} from './outcomes.js' import GamePath from './game-path.js' import GameNode from './game-node.js' import GamePosition from './game-position.js' import ConvertFromJgf from './converters/convert-from-jgf.js' import ConvertFromSgf from './converters/convert-from-sgf.js' import ConvertFromGib from './converters/convert-from-gib.js' import ConvertToJgf from './converters/convert-to-jgf.js' import ConvertToSgf from './converters/convert-to-sgf.js' import {copy, get, set, merge, isObject} from '../helpers/object.js' import {parseTime, parseKomi, parseHandicap, parseEvent, parseResult} from '../helpers/parsing.js' import {isValidColor, colorToNumeric} from '../helpers/color.js' import {stoneColors} from '../constants/stone.js' import {handicapPlacements} from '../constants/game.js' import {kifuFormats} from '../constants/app.js' import {setupTypes} from '../constants/setup.js' import {defaultGameInfo} from '../constants/defaults.js' /** * This class represents a game record or a game that is being played/edited. * The class traverses the move tree nodes and keeps track of the changes between * the previous and new game positions. These changes can then be fed to the * board, to add or remove stones and markup. The class also keeps a stack of * all board positions in memory and can validate moves to make sure they are * not repeating or suicide. * * - A game position is a snapshot of stones and markup on the board at a point in time * - The positions stack is an array of all traversed game positions * - The game path tracks which variation was selected at each fork and what move we're at * - The current node points at the current node in the game tree */ export default class Game extends Base { //Positions stack positions = [] /** * Constructor */ constructor(info) { //Parent constructor super() //Initialise this.init() this.initInfo(info) this.initPositionStack() } /** * Initialize game */ init() { //The rood node and pointer to the current node this.path = new GamePath() this.root = new GameNode() this.node = this.root //Record properties this.recordVersion = '' this.recordCharset = '' this.recordGenerator = '' this.recordTranscriber = '' //Source properties this.sourceName = '' this.sourceUrl = '' this.sourceCopyright = '' //Event properties this.eventName = '' this.eventLocation = '' this.eventRound = '' //Game properties this.gameType = '' this.gameName = '' this.gameResult = '' this.gameDate = '' this.gameOpening = '' this.gameAnnotator = '' this.gameDescription = '' //Board properties this.boardWidth = 19 this.boardHeight = 19 this.boardCutOffLeft = 0 this.boardCutOffRight = 0 this.boardCutOffTop = 0 this.boardCutOffBottom = 0 //Rules this.ruleset = '' this.allowSuicide = false this.disallowRepeats = false this.komi = 0 this.handicap = 0 this.time = 0 this.overtime = '' //Meta data and player settings this.meta = {} this.settings = {} //Players this.players = { black: { name: '', team: '', rank: '', }, white: { name: '', team: '', rank: '', }, } } /** * Initialise game info */ initInfo(info) { this.setInfo(merge(defaultGameInfo, info)) } /** * Set game info in bulk */ setInfo(info) { //Extract record info const recordVersion = get(info, 'record.version') const recordCharset = get(info, 'record.charset') const recordGenerator = get(info, 'record.generator') const recordTranscriber = get(info, 'record.transcriber') //Extract source info const sourceName = get(info, 'source.name') const sourceUrl = get(info, 'source.url') const sourceCopyright = get(info, 'source.copyright') //Extract event info const eventName = get(info, 'event.name') const eventLocation = get(info, 'event.location') const eventRound = get(info, 'event.round') //Extract game info const gameType = get(info, 'game.type') const gameName = get(info, 'game.name') const gameResult = get(info, 'game.result') const gameDate = get(info, 'game.date') const gameDates = get(info, 'game.dates') const gameOpening = get(info, 'game.opening') const gameAnnotator = get(info, 'game.annotator') const gameDescription = get(info, 'game.description') //Extract board info const boardSize = get(info, 'board.size') const boardWidth = get(info, 'board.width') const boardHeight = get(info, 'board.height') const boardCutOffLeft = get(info, 'board.cutOffLeft') const boardCutOffRight = get(info, 'board.cutOffRight') const boardCutOffTop = get(info, 'board.cutOffTop') const boardCutOffBottom = get(info, 'board.cutOffBottom') //Extract rules const ruleset = get(info, 'rules.ruleset') const allowSuicide = get(info, 'rules.allowSuicide') const disallowRepeats = get(info, 'rules.disallowRepeats') const komi = get(info, 'rules.komi') const handicap = get(info, 'rules.handicap') const time = get(info, 'rules.time') const overtime = get(info, 'rules.overtime') const numberOfPeriods = get(info, 'rules.numberOfPeriods') const timePerPeriod = get(info, 'rules.timePerPeriod') //Extract players, settings and meta data const players = get(info, 'players') const settings = get(info, 'settings') const meta = get(info, 'meta') //Set record info if (typeof recordVersion !== 'undefined') { this.setRecordVersion(recordVersion) } if (typeof recordCharset !== 'undefined') { this.setRecordCharset(recordCharset) } if (typeof recordGenerator !== 'undefined') { this.setRecordGenerator(recordGenerator) } if (typeof recordTranscriber !== 'undefined') { this.setRecordTranscriber(recordTranscriber) } //Set source info if (typeof sourceName !== 'undefined') { this.setSourceName(sourceName) } if (typeof sourceUrl !== 'undefined') { this.setSourceUrl(sourceUrl) } if (typeof sourceCopyright !== 'undefined') { this.setSourceCopyright(sourceCopyright) } //Set event info if (typeof eventName !== 'undefined') { this.setEventName(eventName) } if (typeof eventLocation !== 'undefined') { this.setEventLocation(eventLocation) } if (typeof eventRound !== 'undefined') { this.setEventRound(eventRound) } //Set game info if (typeof gameType !== 'undefined') { this.setGameType(gameType) } if (typeof gameName !== 'undefined') { this.setGameName(gameName) } if (typeof gameResult !== 'undefined') { this.setGameResult(gameResult) } if (typeof gameOpening !== 'undefined') { this.setGameOpening(gameOpening) } if (typeof gameAnnotator !== 'undefined') { this.setGameAnnotator(gameAnnotator) } if (typeof gameDescription !== 'undefined') { this.setGameDescription(gameDescription) } //Set rules if (typeof ruleset !== 'undefined') { this.setRuleset(ruleset) } if (typeof allowSuicide !== 'undefined') { this.setAllowSuicide(allowSuicide) } if (typeof disallowRepeats !== 'undefined') { this.setDisallowRepeats(disallowRepeats) } if (typeof komi !== 'undefined') { this.setKomi(komi) } if (typeof handicap !== 'undefined') { this.setHandicap(handicap) } if (typeof time !== 'undefined') { this.setTime(time) } if (typeof overtime !== 'undefined') { this.setOvertime(overtime) } if (typeof numberOfPeriods !== 'undefined') { this.setNumberOfPeriods(numberOfPeriods) } if (typeof timePerPeriod !== 'undefined') { this.setTimePerPeriod(timePerPeriod) } //Set date if (typeof gameDate !== 'undefined') { this.setGameDate(gameDate) } else if (Array.isArray(gameDates) && gameDates.length > 0) { this.setGameDate(gameDates[0]) } //Set board size if (boardWidth && boardHeight) { this.setBoardSize(boardWidth, boardHeight) } else if (boardSize) { this.setBoardSize(boardSize) } //Set board cut off if ( typeof boardCutOffLeft !== 'undefined' || typeof boardCutOffRight !== 'undefined' || typeof boardCutOffTop !== 'undefined' || typeof boardCutOffBottom !== 'undefined' ) { this.setBoardCutOff( boardCutOffLeft, boardCutOffRight, boardCutOffTop, boardCutOffBottom ) } //Set players if (typeof players !== 'undefined') { for (const color in players) { this.setPlayer(color, players[color]) } } //Set meta data and settings if (typeof meta !== 'undefined') { this.setMeta(meta) } if (typeof settings !== 'undefined') { this.setSettings(settings) } } /** * Get game info in bulk */ getInfo() { //Initialise const info = {} //Get info const { recordVersion, recordCharset, recordGenerator, recordTranscriber, sourceName, sourceUrl, sourceCopyright, eventName, eventLocation, eventRound, gameType, gameName, gameResult, gameDate, gameDates, gameOpening, gameAnnotator, gameDescription, boardSize, boardWidth, boardHeight, boardCutOffLeft, boardCutOffRight, boardCutOffTop, boardCutOffBottom, ruleset, allowSuicide, disallowRepeats, komi, handicap, time, overtime, players, settings, meta, } = this //Set on info set(info, 'record.version', recordVersion) set(info, 'record.charset', recordCharset) set(info, 'record.generator', recordGenerator) set(info, 'record.transcriber', recordTranscriber) //Extract source info set(info, 'source.name', sourceName) set(info, 'source.url', sourceUrl) set(info, 'source.copyright', sourceCopyright) //Extract event info set(info, 'event.name', eventName) set(info, 'event.location', eventLocation) set(info, 'event.round', eventRound) //Extract game info set(info, 'game.type', gameType) set(info, 'game.name', gameName) set(info, 'game.result', gameResult) set(info, 'game.date', gameDate) set(info, 'game.dates', gameDates) set(info, 'game.opening', gameOpening) set(info, 'game.annotator', gameAnnotator) set(info, 'game.description', gameDescription) //Extract board info set(info, 'board.size', boardSize) set(info, 'board.width', boardWidth) set(info, 'board.height', boardHeight) set(info, 'board.cutOffLeft', boardCutOffLeft) set(info, 'board.cutOffRight', boardCutOffRight) set(info, 'board.cutOffTop', boardCutOffTop) set(info, 'board.cutOffBottom', boardCutOffBottom) //Extract rules set(info, 'rules.ruleset', ruleset) set(info, 'rules.allowSuicide', allowSuicide) set(info, 'rules.disallowRepeats', disallowRepeats) set(info, 'rules.komi', komi) set(info, 'rules.handicap', handicap) set(info, 'rules.time', time) set(info, 'rules.overtime', overtime) //Extract players, settings and meta data set(info, 'players', players) set(info, 'settings', settings) set(info, 'meta', meta) //Return info return info } /** * Reset game (but preserve info) */ reset() { this.init() this.initPositionStack() } /************************************************************************** * Game info getters and setters ***/ /** * Set/get record version */ setRecordVersion(recordVersion = '') { this.recordVersion = recordVersion this.triggerEvent('info', {recordVersion}) } getRecordVersion() { return this.recordVersion } /** * Set/get record char set */ setRecordCharset(recordCharset = '') { this.recordCharset = recordCharset this.triggerEvent('info', {recordCharset}) } getRecordCharset() { return this.recordCharset } /** * Set/get record generator */ setRecordGenerator(recordGenerator = '') { this.recordGenerator = recordGenerator this.triggerEvent('info', {recordGenerator}) } getRecordGenerator() { return this.recordGenerator } /** * Set/get record transcriber */ setRecordTranscriber(recordTranscriber = '') { this.recordTranscriber = recordTranscriber this.triggerEvent('info', {recordTranscriber}) } getRecordTranscriber() { return this.recordTranscriber } /** * Set/get source name */ setSourceName(sourceName = '') { if (sourceName) { const regexUrl = /(:\s|,\s|\sat\s)?(https?:\/\/(.*?(?=\s|$)))/ const match = sourceName.match(regexUrl) if (match) { sourceName = sourceName.replace(regexUrl, '') this.setSourceUrl(match[2]) } } this.sourceName = sourceName this.triggerEvent('info', {sourceName}) } getSourceName() { return this.sourceName } /** * Set/get source URL */ setSourceUrl(sourceUrl = '') { this.sourceUrl = sourceUrl this.triggerEvent('info', {sourceUrl}) } getSourceUrl() { return this.sourceUrl } /** * Set/get source copyright */ setSourceCopyright(sourceCopyright = '') { this.sourceCopyright = sourceCopyright this.triggerEvent('info', {sourceCopyright}) } getSourceCopyright() { return this.sourceCopyright } /** * Set/get event name */ setEventName(name = '') { //Check for URL presence const [eventName, eventLocation] = parseEvent(name) if (eventName && eventLocation) { this.eventName = eventName this.eventLocation = eventLocation this.triggerEvent('info', {eventName, eventLocation}) } //Set as given else { this.eventName = eventName this.triggerEvent('info', {eventName}) } } getEventName() { return this.eventName } /** * Set/get event location */ setEventLocation(location = '') { //Check for URL presence const [eventName, eventLocation] = parseEvent(location) if (eventName && eventLocation) { this.eventName = eventName this.eventLocation = eventLocation this.triggerEvent('info', {eventName, eventLocation}) } //Set as given else { this.eventLocation = eventLocation this.triggerEvent('info', {eventLocation}) } } getEventLocation() { return this.eventLocation } /** * Set/get event round */ setEventRound(eventRound = '') { this.eventRound = eventRound this.triggerEvent('info', {eventRound}) } getEventRound() { return this.eventRound } /** * Set/get game type */ setGameType(gameType = '') { this.gameType = gameType this.triggerEvent('info', {gameType}) } getGameType() { return this.gameType } /** * Set/get game name */ setGameName(gameName = '') { this.gameName = gameName this.triggerEvent('info', {gameName}) } getGameName() { return this.gameName } /** * Set/get game result */ setGameResult(gameResult = '') { this.gameResult = parseResult(gameResult) this.triggerEvent('info', {gameResult: this.gameResult}) } getGameResult() { return this.gameResult } /** * Set/get game date */ setGameDate(gameDate = '') { const match = gameDate .match(/^(([0-9]{4})(-[0-9]{2})?(-[0-9]{2})?)/) this.gameDate = match ? match[1] : '' this.triggerEvent('info', {gameDate: this.gameDate}) } getGameDate() { return this.gameDate } /** * Set/get game opening */ setGameOpening(gameOpening = '') { this.gameOpening = gameOpening this.triggerEvent('info', {gameOpening}) } getGameOpening() { return this.gameOpening } /** * Set/get game annotator */ setGameAnnotator(gameAnnotator = '') { this.gameAnnotator = gameAnnotator this.triggerEvent('info', {gameAnnotator}) } getGameAnnotator() { return this.gameAnnotator } /** * Set/get game description */ setGameDescription(gameDescription = '') { this.gameDescription = gameDescription this.triggerEvent('info', {gameDescription}) } getGameDescription() { return this.gameDescription } /** * Set/get the board size */ setBoardSize(width = 0, height = 0) { width = parseInt(width) if (isNaN(width)) { width = 0 } height = parseInt(height) if (isNaN(height)) { height = 0 } if (width && height && width !== height) { this.boardWidth = width this.boardHeight = height } else if (width) { this.boardWidth = this.boardHeight = width } this.triggerEvent('info', { boardWidth: this.boardWidth, boardHeight: this.boardHeight, }) } getBoardSize() { const {boardWidth: width, boardHeight: height} = this return {width, height} } /** * Set the board cut off */ setBoardCutOff(left = 0, right = 0, top = 0, bottom = 0) { left = parseInt(left) right = parseInt(right) top = parseInt(top) bottom = parseInt(bottom) this.boardCutOffLeft = isNaN(left) ? 0 : left this.boardCutOffRight = isNaN(right) ? 0 : right this.boardCutOffTop = isNaN(top) ? 0 : top this.boardCutOffBottom = isNaN(bottom) ? 0 : bottom this.triggerEvent('info', { boardCutOffLeft: this.boardCutOffLeft, boardCutOffRight: this.boardCutOffRight, boardCutOffTop: this.boardCutOffTop, boardCutOffBottom: this.boardCutOffBottom, }) } getBoardCutOff() { const { boardCutOffLeft: cutOffLeft, boardCutOffRight: cutOffRight, boardCutOffTop: cutOffTop, boardCutOffBottom: cutOffBottom, } = this return {cutOffLeft, cutOffRight, cutOffTop, cutOffBottom} } /** * Get combined board config for Board class */ getBoardConfig() { //Get config const { boardWidth: width, boardHeight: height, boardCutOffLeft: cutOffLeft, boardCutOffRight: cutOffRight, boardCutOffTop: cutOffTop, boardCutOffBottom: cutOffBottom, } = this //Return combined config return { width, height, cutOffLeft, cutOffRight, cutOffTop, cutOffBottom, } } /** * Set/get ruleset */ setRuleset(ruleset = '') { this.ruleset = ruleset this.triggerEvent('info', {ruleset}) } getRuleset() { return this.ruleset } /** * Set/get allow suicide */ setAllowSuicide(allowSuicide = false) { this.allowSuicide = Boolean(allowSuicide) this.triggerEvent('info', {allowSuicide: this.allowSuicide}) } getAllowSuicide() { return this.allowSuicide } /** * Set/get disallow repeats */ setDisallowRepeats(disallowRepeats = false) { this.disallowRepeats = Boolean(disallowRepeats) this.triggerEvent('info', {disallowRepeats: this.disallowRepeats}) } getDisallowRepeats() { return this.disallowRepeats } /** * Set/get komi */ setKomi(komi) { this.komi = parseKomi(komi) this.triggerEvent('info', {komi: this.komi}) } getKomi() { return this.komi } /** * Set/get handicap */ setHandicap(handicap) { this.handicap = parseHandicap(handicap) this.triggerEvent('info', {handicap: this.handicap}) } getHandicap() { return this.handicap } /** * Set/get main time */ setTime(time = 0) { this.time = parseTime(time) this.triggerEvent('info', {time: this.time}) } getTime() { return this.time } /** * Set/get over time */ setOvertime(overtime = '') { this.overtime = overtime || '' this.triggerEvent('info', {overtime: this.overtime}) const match = overtime.match(/([0-9]+)x([0-9.]+)/) if (match) { this.setNumberOfPeriods(match[1]) this.setTimePerPeriod(match[2]) } } getOvertime() { if (this.overtime) { return this.overtime } if (this.numberOfPeriods && this.timePerPeriod) { return `${this.numberOfPeriods}x${this.timePerPeriod} byo-yomi` } } /** * Set/get number of periods */ setNumberOfPeriods(numberOfPeriods) { numberOfPeriods = parseInt(numberOfPeriods) if (isNaN(numberOfPeriods)) { numberOfPeriods = 0 } this.numberOfPeriods = numberOfPeriods } getNumberOfPeriods() { return this.numberOfPeriods } /** * Set/get time per period */ setTimePerPeriod(timePerPeriod) { timePerPeriod = parseFloat(timePerPeriod) if (isNaN(timePerPeriod)) { timePerPeriod = 0 } this.timePerPeriod = timePerPeriod } getTimePerPeriod() { return this.timePerPeriod } /** * Set/get meta data */ setMeta(meta = {}) { if (isObject(meta)) { this.meta = copy(meta) } } getMeta() { return this.meta } /** * Set/get player settings */ setSettings(settings = {}) { if (isObject(settings)) { this.settings = copy(settings) } } getSettings() { return this.settings } /** * Set/get player of a specific color */ setPlayer(color, info) { if (isObject(info)) { const {name, rank, team} = info this.players[color] = { name, rank, team, } this.triggerEvent('info', {players: this.players}) } } updatePlayer(color, info) { if (isObject(info)) { for (const key in info) { this.players[color][key] = info[key] } this.triggerEvent('info', {players: this.players}) } } getPlayer(color) { return this.players[color] } /** * Get all players */ getPlayers() { return this.players } /************************************************************************** * Turn and capture count ***/ /** * Get the player turn for this position */ getTurn() { const {position} = this if (position) { return position.getTurn() } return stoneColors.BLACK } /** * Set the player turn for the current position */ setTurn(color) { const {position} = this if (position) { this.debug(`setting turn to ${color}`) position.setTurn(color) this.triggerEvent('positionChange', {position}) } } /** * Switch the player turn for the current position */ switchTurn() { const {position} = this if (position) { this.debug(`switching turn`) position.switchTurn() this.triggerEvent('positionChange', {position}) } } /** * Get the total capture count up to the current position */ getCaptureCount() { //Initialize const {positions} = this const captures = {} const colors = [ stoneColors.BLACK, stoneColors.WHITE, ] //Loop all positions for (const position of positions) { for (const color of colors) { captures[color] ??= 0 captures[color] += position.getCaptureCount(color) } } //Return return captures } /** * Get time left */ getTimeLeft(color) { //Get node let {node} = this //Root node? Return main time if (node.isRoot()) { return this.getTime() } //Not a move node if (!node.isMove()) { return } //Check previous node if it's not this player's turn if (node.getMoveColor() !== color) { node = node.getPreviousMove() if (!node) { return this.getTime() } } //Return time left return node.move.timeLeft } /** * Get periods left */ getPeriodsLeft(color) { //Get node let {node} = this if (!node.isMove()) { return } //Check previous node if it's not this player's turn if (node.getMoveColor() !== color) { node = node.getPreviousMove() if (!node) { return } } //Return info return node.move.periodsLeft } /** * Place default handicap stones */ placeDefaultHandicapStones() { //Get handicap const {handicap} = this if (handicap < 2) { return } //Get size const {width, height} = this.getBoardSize() if (width !== height) { return } //Check if handicap position is available if (!handicapPlacements[width] || !handicapPlacements[width][handicap]) { return } //Debug this.debug(`placing ${handicap} handicap stones`) //Add stones for (const {x, y} of handicapPlacements[width][handicap]) { this.addStone(x, y, stoneColors.BLACK) } //Set white to play this.setTurn(stoneColors.WHITE) } /***************************************************************************** * Position handling ***/ /** * Getter returns the last position from the stack */ get position() { const {positions} = this return positions[positions.length - 1] } /** * Setter adds a new position to the stack */ set position(newPosition) { const {positions} = this positions[positions.length] = newPosition } /** * Initialise the position stack */ initPositionStack() { //Create new blank game position const {positions} = this const {width, height} = this.getBoardSize() const position = new GamePosition(width, height) //Debug this.debug(`initialising position stack at ${width}x${height}`) //Clear positions stack push the position positions.length = 0 positions.push(position) } /** * Add position to stack */ addPositionToStack(newPosition) { this.positions.push(newPosition) } /** * Remove last position from stack */ removeLastPositionFromStack() { if (this.positions.length > 0) { return this.positions.pop() } } /** * Replace the current position in the stack */ replaceLastPositionInStack(newPosition) { if (newPosition) { this.positions.pop() this.positions.push(newPosition) } } /** * Clear the position stack */ clearPositionStack() { this.positions = [] } /** * Check if a given position is repeating within this game */ isRepeatingPosition(checkPosition) { //Get data const {positions, disallowRepeats} = this let stop //Check all positions? if (disallowRepeats) { stop = 0 } //Otherwise check for ko only (last two positions) else if ((positions.length - 2) >= 0) { stop = positions.length - 2 } //Not checking for repeating positions else { return false } //Loop positions to check for (let i = positions.length - 2; i >= stop; i--) { if (checkPosition.isSameAs(positions[i])) { return true } } //Not repeating return false } /***************************************************************************** * Node and position handling ***/ /** * Get the current node */ getCurrentNode() { return this.node } /** * Check if a node is the current node */ isCurrentNode(node) { return this.node === node } /** * Set root node */ setRootNode(root) { this.root = root } /** * Get root node */ getRootNode() { return this.root } /** * Check if a node is the root node */ isRootNode(node) { return this.root === node } /** * Get the current game position */ getPosition() { return this.position } /** * Get position matrix */ getPositionMatrix() { return this.position.stones.toMatrix(colorToNumeric) } /** * Get the game path */ getPath() { return this.path } /** * Get the game path as a plain object */ getPathObject() { return this.path.toObject() } /** * Find a node by name */ findNodeByName(name) { return this.root.findNodeByName(name) } /************************************************************************** * Move number and named node handling ***/ /** * Get path index of current node */ getCurrentPathIndex() { return this.node.getPathIndex() } /** * Set current path index */ setCurrentPathIndex(i) { this.node.setPathIndex(i) this.root.markPath() } /** * Reset current path index */ resetCurrentPathIndex() { this.node.setPathIndex(0) this.root.markPath() this.path.forgetPathChoice() } /** * Get current move number */ getCurrentMoveNumber() { return this.node.getMoveNumber() } /** * Get current move color */ getCurrentMoveColor() { return this.node.getMoveColor() } /** * Get current node name */ getCurrentNodeName() { return this.node.name } /** * Get the number of moves in the main branch */ getTotalNumberOfMoves() { let node = this.root let m = 0 while (node) { if (node.isMove()) { m++ } node = node.getPathNode() } return m } /** * Get node for a certain move number */ findNodeForMoveNumber(number) { let node = this.root let m = 0 while (node) { if (node.isMove()) { m++ if (m === number) { return node } } node = node.getPathNode() } } /** * Find named node */ findNamedNode(name) { return this.root.findNamedNode(name) } /** * Get game path to a given move number */ getPathToMoveNumber(number) { const path = new GamePath() path.setMove(number) return path } /** * Get path to named node */ getPathToNamedNode(name) { const {root} = this const path = new GamePath() const node = root.findNamedNode(name, path) return node ? path : null } /** * Get path to a specific node */ getPathToNode(target) { const {root} = this const path = new GamePath() const node = root.findNode(target, path) return node ? path : null } /***************************************************************************** * Coordinate checkers ***/ /** * Check if coordinates are valid * * NOTE: This checks against game info, as opposed to an actual board object, * because this class can be used independently of the board class. */ isValidCoordinate(x, y) { const {width, height} = this.getBoardSize() return (x >= 0 && y >= 0 && x < width && y < height) } /** * Check if given coordinates are one of the next child node coordinates */ isMoveVariation(x, y) { this.node.isMoveVariation(x, y) } /** * Get move variation index */ getMoveVariationIndex(x, y) { return this.node.getMoveVariationIndex(x, y) } /************************************************************************** * Move and setup placement validation ***/ /** * Wrapper for validateMove() returning a boolean and catching any errors */ isValidMove(x, y, color) { const position = this.position.clone() const [isValid] = this.validateMove(position, x, y, color) return isValid } /** * Check if a move is valid against a given position */ validateMove(position, x, y, color) { //Get data const {allowSuicide} = this //Check coordinates validity if (!this.isValidCoordinate(x, y)) { return new ErrorOutcome(`Position (${x},${y}) is out of bounds`) } //Something already here? if (position.stones.has(x, y)) { return new ErrorOutcome(`Position (${x},${y}) already has a stone`) } //Set color of move to make if (typeof color === 'undefined') { color = position.getTurn() } //Place the new stone position.stones.set(x, y, color) //Capture adjacent stones if possible const hadCaptures = position.captureAdjacent(x, y) //No captures occurred? Check if the move we're making is a suicide move if (!hadCaptures) { if (!position.hasLiberties(x, y)) { if (allowSuicide) { position.captureGroup(x, y) } else { return new ErrorOutcome(`Move on (${x},${y}) is suicide`) } } } //Check position stack for repeating moves if (this.isRepeatingPosition(position)) { return new ErrorOutcome(`Move on (${x},${y}) creates a repeating position`) } //Switch turn position.switchTurn() return new ValidOutcome() } /** * Check if a setup placement is valid. */ validateSetupPlacement(x, y, color, newPosition) { //Get data const {position} = this //Check coordinates validity if (!this.isValidCoordinate(x, y)) { return [null, `Position (${x},${y}) is out of bounds`] } //Create position newPosition = newPosition || position.clone() newPosition.stones.set(x, y, color) //Capture adjacent stones if possible const hadCaptures = newPosition.captureAdjacent(x, y) //No captures occurred? Check if the move we're making is a suicide move if (!hadCaptures) { //No liberties for the group we've just created? Capture it if (!newPosition.hasLiberties(x, y)) { newPosition.captureGroup(x, y) } } //Return position return [newPosition] } /***************************************************************************** * Markup and setup stones handling ***/ /** * Get markup on coordinates */ getMarkup(x, y) { const {position} = this return position.markup.get(x, y) } /** * Check if there is markup at the given coordinate for the current position */ hasMarkup(x, y, type) { const {position} = this if (typeof type === 'undefined') { return position.markup.has(x, y) } return position.markup.is(x, y, {type}) } /** * Check if we have markup in a given area */ hasMarkupInArea(area) { return area.some(({x, y}) => { return this.hasMarkup(x, y) }) } /** * Add markup */ addMarkup(x, y, markup) { //No markup here if (this.hasMarkup(x, y, markup)) { this.debug(`already has markup of type ${markup.type} on (${x},${y})`) return } //Add const {position, node} = this position.markup.set(x, y, markup) node.addMarkup(x, y, markup) } /** * Remove markup */ removeMarkup(x, y) { //No markup here if (!this.hasMarkup(x, y)) { this.debug(`no markup present on (${x},${y})`) return } //Remove const {position, node} = this node.removeMarkup(x, y) position.markup.delete(x, y) } /** * Remove markup from area */ removeMarkupFromArea(area) { for (const {x, y} of area) { if (this.hasMarkup(x, y)) { this.removeMarkup(x, y) } } } /** * Remove all markup from position */ removeAllMarkup() { //Remove all markup const {position, node} = this node.removeAllMarkupInstructions() position.markup.clear() } /** * Get stone on coordinates */ getStone(x, y) { const {position} = this return position.stones.get(x, y) } /** * Check if there is a stone at given coordinates */ hasStone(x, y, color) { const {position} = this if (typeof color === 'undefined') { return position.stones.has(x, y) } return position.stones.is(x, y, {color}) } /** * Check if we have one or more stones in a given area */ hasStonesInArea(area) { return area.some(({x, y}) => this.hasStone(x, y)) } /** * Add a stone */ addStone(x, y, color) { //Validate color if (!isValidColor(color)) { this.warn(`invalid color ${color}`) return } //Already have stone of this color if (this.hasStone(x, y, color)) { this.debug(`already has stone of color ${color} on (${x},${y})`) return } //Debug this.debug(`adding ${color} stone at (${x},${y})`) //Get data and validate placement const {position, node} = this const [newPosition, reason] = this.validateSetupPlacement(x, y, color) //Invalid placement if (!newPosition) { this.warn(reason) return } //Add to node as a setup instruction const newNodeIndex = node.addSetup(x, y, {type: color}) //Replace the position if a new node was created if (typeof newNodeIndex !== 'undefined') { this.debug(`new node was created with index ${newNodeIndex}`) this.handleNewSetupNodeCreation(newNodeIndex) this.replaceLastPositionInStack(newPosition) this.triggerEvent('positionChange', {position}) return } //Just set stone on current position position.stones.set(x, y, color) this.triggerEvent('positionChange', {position}) } /** * Remove a stone */ removeStone(x, y) { //No stone on this position if (!this.hasStone(x, y)) { this.debug(`no stone present on (${x},${y})`) return } //Debug this.debug(`removing stone from (${x},${y})`) //Get data const {position, node} = this //Check if stone is present in setup instructions //If so, just remove it from the setup if (node.hasSetup(x, y)) { node.removeSetup(x, y) position.stones.delete(x, y) return } //Not present, so it was added on the board previously, //either by another setup instruction or by a move //We have to clear it using a new setup instruction and //this also creates a new position const newPosition = position.clone() newPosition.stones.delete(x, y) //Add setup instruction const newNodeIndex = node.addSetup(x, y, {type: setupTypes.CLEAR}) //Replace current position this.handleNewSetupNodeCreation(newNodeIndex) this.replaceLastPositionInStack(newPosition) this.triggerEvent('positionChange', {position}) } /** * Remove stones from area */ removeStonesFromArea(area) { for (const {x, y} of area) { if (this.hasStone(x, y)) { this.removeStone(x, y) } } } /** * Add line to position (does not trigger a board redraw, to allow the line * to be drawn on the board simultaneously in real time) */ addLine(...args) { this.node.addLine(...args) this.position.addLine(...args) } /** * Has lines check */ hasLines() { return this.position.hasLines() } /** * Get lines */ getLines() { return this.position.getLines() } /** * Remove all lines */ removeAllLines() { this.node.removeLines() this.position.removeLines() } /** * Helper to handle the creation of a new setup node */ handleNewSetupNodeCreation(i) { //Nothing to do if (typeof i === 'undefined') { return } //Advance path to the added node index this.node = this.node.getChild(i) this.path.advance(i) //Clone our position const position = this.position.clone() this.addPositionToStack(position) } /***************************************************************************** * Playing a move or passing ***/ /** * Play a move */ playMove(x, y) { //Get color const color = this.position.getTurn() //Already have a variation here? if (this.node.hasMoveVariation(x, y)) { //Get variation node const i = this.node.getMoveVariationIndex(x, y) const child = this.node.getChild(i) //If this was the same color as current color, just go to the variation if (color === child.getMoveColor()) { return this.goToNextPosition(i) } } //Validate move and get new position const newPosition = this.position.clone() const outcome = this.validateMove(newPosition, x, y, color) //Invalid move if (!outcome.isValid) { this.warn(outcome.reason) return outcome } //Create new move node const node = new GameNode({ move: {x, y, color}, }) //Append it to the current node, remember the variation, and change the pointer const parent = this.node const i = node.appendToParent(parent) parent.setPathIndex(i) //Advance path to the added node index this.node = node this.path.advance(i) this.root.markPath() //Valid move this.addPositionToStack(newPosition) return new ValidOutcome() } /** * Pass move */ passMove() { //Get color const color = this.position.getTurn() //Initialize new position and switch the turn const newPosition = this.position.clone() newPosition.switchTurn() //Create new move node const node = new GameNode({ move: { color, pass: true, }, }) //Append it to the current node, remember the path const parent = this.node const i = node.appendToParent(parent) parent.setPathIndex(i) //Advance path to the added node index this.node = node this.path.advance(i) //Add new position to stack this.addPositionToStack(newPosition) return new ValidOutcome() } /** * Get comments from current node */ getComments() { return this.node.getComments() } /** * Set comments in current node */ setComments(comments) { this.node.setComments(comments) } /***************************************************************************** * Game tree navigation ***/ /** * Check if there is a next position */ hasNextPosition() { const {node} = this return node.hasChildren() } /** * Check if there is a previous position */ hasPreviousPosition() { const {root, node} = this return (root !== node) } /** * Is at first position */ isAtFirstPosition() { return !this.hasPreviousPosition() } /** * Is at last position */ isAtLastPosition() { return !this.hasNextPosition() } /** * Go to the next position */ goToNextPosition(i) { if (this.goToNextNode(i)) { return this.processCurrentNode() } return new ErrorOutcome(`No next position`) } /** * Go to the previous position */ goToPreviousPosition() { if (this.goToPreviousNode()) { return new ValidOutcome() } return new ErrorOutcome(`No previous position`) } /** * Go to the last position */ goToLastPosition() { while (this.goToNextNode()) { this.processCurrentNode() } } /** * Go to the first position */ goToFirstPosition() { this.goToFirstNode() this.processCurrentNode() } /** * Go to next variation (if there is one) */ goToNextVariation() { const next = this.node.getNextSibling() if (next) { this.goToNode(next) } } /** * Go to previous variation (if there is one) */ goToPreviousVariation() { const previous = this.node.getPreviousSibling() if (previous) { this.goToNode(previous) } } /** * Go to specific move number */ goToMoveNumber(number) { //Already here if (this.getCurrentMoveNumber() === number) { return } //Get path to the named node const path = this.getPathToMoveNumber(number) this.goToPath(path) } /** * Go to specific named node */ goToNamedNode(name) { //Already here if (this.getCurrentNodeName() === name) { return } //Get path to the named node const path = this.getPathToNamedNode(name) this.goToPath(path) } /** * Go to specific target node */ goToNode(target) { //Already here if (this.node === target) { return } //Get path to the named node const path = this.getPathToNode(target) this.goToPath(path) } /** * Go to position indicated by given path */ goToPath(path) { //No path if (!path) { return } //Not an instance of a GamePath if (!(path instanceof GamePath)) { path = GamePath.fromObject(path) } //No path or already here? if (this.path.isSameAs(path)) { return } //Go to the first position this.goToFirstPosition() //Loop path const n = path.getMoveNumber() for (let m = 0; m < n; m++) { //Try going to the next node const i = path.indexAtMove(m) if (!this.goToNextNode(i)) { break } //Execute node and break if invalid const outcome = this.processCurrentNode() if (!outcome.isValid) { break } } } /** * Go to the next fork */ goToNextFork() { while (this.goToNextNode()) { const outcome = this.processCurrentNode() if (!outcome.isValid) { break } if (this.node.hasMultipleChildren()) { break } } } /** * Go to the previous fork */ goToPreviousFork() { while (this.goToPreviousNode()) { if (this.node.hasMultipleChildren()) { break } } } /** * Go to the next move with comments */ goToNextComment() { while (this.goToNextNode()) { const outcome = this.processCurrentNode() if (!outcome.isValid) { break } if (this.node.hasComments()) { break } } } /** * Go to the previous move with comments */ goToPreviousComment() { while (this.goToPreviousNode()) { if (this.node.hasComments()) { break } } } /** * Go forward a number of positions */ goForwardNumPositions(num) { for (let i = 0; i < num; i++) { if (!this.goToNextPosition()) { return } } } /** * Go backward a number of positions */ goBackNumPositions(num) { for (let i = 0; i < num; i++) { if (!this.goToPreviousPosition()) { return } } } /***************************************************************************** * Node navigation helpers ***/ /** * Select next variation */ selectNextVariation() { this.node.incrementPathIndex() } /** * Select previous variation */ selectPreviousVariation() { this.node.decrementPathIndex() } /** * Make a node the main variation */ makeMainVariation(node) { //Must be a variation branch if (!node.isVariationBranch()) { throw new Error('Node is not a variation branch') } //Move the variation root to index 0 node.variationRoot.moveToIndex(0) } /** * Remove a node */ removeNode(node) { //Warn when trying to remove root node if (node.isRoot()) { throw new Error('Cannot remove root node') } //Detach node from parent const parent = node.detachFromParent() if (!parent) { throw new Error('Node has no parent') } //Go to parent node if we were at the node being removed if (this.isCurrentNode(node)) { this.goToNode(parent) } } /** * Go to the next node */ goToNextNode(i) { //Get data const {node} = this //Check if we have children if (!node.hasChildren()) { return false } //Validate index if (!node.isValidPathIndex(i)) { i = 0 } //Advance path and set pointer of current node this.path.advance(i) this.node = node.getChild(i) return true } /** * Go to the previous node */ goToPreviousNode() { //Get data const {node} = this //No parent node? if (!node.hasParent()) { return false } //Retreat path and set pointer to current node this.path.retreat() this.node = node.getParent() this.root.markPath() //Remove last position from stack this.removeLastPositionFromStack() return true } /** * Go to the first node */ goToFirstNode() { //Reset path and point to root this.path.reset() this.node = this.root this.root.markPath() //Determine initial turn based on handicap //Can be overwritten by game record instructions const handicap = this.getHandicap() const turn = (handicap > 1) ? stoneColors.WHITE : stoneColors.BLACK //Set turn this.setTurn(turn) this.initPositionStack() } /** * Execute the current node */ processCurrentNode(revertPositionOnFail = true) { //Get data const {node, root, position} = this //Make this node the path node on its parent node.setAsParentPathNode() root.markPath() //Initialize new position const newPosition = position.clone() //Pass move if (node.isPassMove()) { newPosition.switchTurn() } //Play move if (node.isPlayMove()) { const {x, y, color} = node.move const outcome = this.validateMove(newPosition, x, y, color) //New position is not valid if (!outcome.isValid) { //Revert position on failure? if (revertPositionOnFail) { this.goToPreviousNode() } //Return failure reason this.warn(outcome.reason) return outcome } } //Handle turn instructions if (node.hasTurnInstructions()) { newPosition.setTurn(node.turn) } //Handle setup instructions if (node.hasSetupInstructions()) { for (const setup of node.setup) { const {type, coords} = setup for (const coord of coords) { const {x, y} = coord if (type === setupTypes.CLEAR) { newPosition.removeStone(x, y) } else { newPosition.setStone(x, y, type) } } } } //Handle markup if (node.hasMarkupInstructions()) { for (const markup of node.markup) { const {type, coords} = markup for (const coord of coords) { const {x, y, text} = coord newPosition.setMarkup(x, y, {type, text}) } } } //Lines if (no