UNPKG

tafl

Version:

A Typescript library for Hnefatafl (Viking Chess, or Tafl) that is used for move generation/validation, piece placement/movement, and game over detection. You can use it to simulate games between AI agents - written for this purpose, and also serves as th

1,589 lines (1,389 loc) 46.4 kB
import crypto from "crypto"; interface Named { name: String; } interface Coords { readonly r: number; readonly c: number; } interface MoveAction { from: Coords; to: Coords; } interface Game extends Named { getPossibleActions(state: GameState): MoveAction[]; isActionPossible(state: GameState, action: MoveAction): boolean; isGameOver(state: GameState): typeof state; act(state: GameState, action: MoveAction): typeof state; } export enum Piece { __ = " ", PA = "A", PD = "D", PK = "K", } export type Board = Array<Array<Piece>>; export interface GameState { board?: Board; actions?: Array<MoveAction>; boardHistory?: Record<string, number>; turn?: number; result?: { finished: boolean; winner?: TaflSide; desc: string; }; captures?: Array<Coords>; lastAction?: MoveAction; rules?: { [key: string]: TaflRule }; } const util = require("util"); const _ = Piece.__; const A = Piece.PA; const D = Piece.PD; const K = Piece.PK; export class TaflBoard { // Irish static readonly _7_BRANDUBH = [ [_, _, _, A, _, _, _], [_, _, _, A, _, _, _], [_, _, _, D, _, _, _], [A, A, D, K, D, A, A], [_, _, _, D, _, _, _], [_, _, _, A, _, _, _], [_, _, _, A, _, _, _], ]; // Saami static readonly _9_TABLUT = [ [_, _, _, A, A, A, _, _, _], [_, _, _, _, A, _, _, _, _], [_, _, _, _, D, _, _, _, _], [A, _, _, _, D, _, _, _, A], [A, A, D, D, K, D, D, A, A], [A, _, _, _, D, _, _, _, A], [_, _, _, _, D, _, _, _, _], [_, _, _, _, A, _, _, _, _], [_, _, _, A, A, A, _, _, _], ]; static readonly _11_CLASSIC = [ [_, _, _, A, A, A, A, A, _, _, _], [_, _, _, _, _, A, _, _, _, _, _], [_, _, _, _, _, _, _, _, _, _, _], [A, _, _, _, _, D, _, _, _, _, A], [A, _, _, _, D, D, D, _, _, _, A], [A, A, _, D, D, K, D, D, _, A, A], [A, _, _, _, D, D, D, _, _, _, A], [A, _, _, _, _, D, _, _, _, _, A], [_, _, _, _, _, _, _, _, _, _, _], [_, _, _, _, _, A, _, _, _, _, _], [_, _, _, A, A, A, A, A, _, _, _], ]; static readonly _11_LINE = [ [_, _, _, A, A, A, A, A, _, _, _], [_, _, _, _, _, A, _, _, _, _, _], [_, _, _, _, _, D, _, _, _, _, _], [A, _, _, _, _, D, _, _, _, _, A], [A, _, _, _, _, D, _, _, _, _, A], [A, A, D, D, D, K, D, D, D, A, A], [A, _, _, _, _, D, _, _, _, _, A], [A, _, _, _, _, D, _, _, _, _, A], [_, _, _, _, _, D, _, _, _, _, _], [_, _, _, _, _, A, _, _, _, _, _], [_, _, _, A, A, A, A, A, _, _, _], ]; // Welsh static readonly _11_TAWLBWRDD = [ [_, _, _, _, A, A, A, _, _, _, _], [_, _, _, _, A, _, A, _, _, _, _], [_, _, _, _, _, A, _, _, _, _, _], [_, _, _, _, _, D, _, _, _, _, _], [A, A, _, _, D, D, D, _, _, A, A], [A, _, A, D, D, K, D, D, A, _, A], [A, A, _, _, D, D, D, _, _, A, A], [_, _, _, _, _, D, _, _, _, _, _], [_, _, _, _, _, A, _, _, _, _, _], [_, _, _, _, A, _, A, _, _, _, _], [_, _, _, _, A, A, A, _, _, _, _], ]; static readonly _11_LEWISS = [ [_, _, _, _, A, A, A, _, _, _, _], [_, _, _, _, A, A, A, _, _, _, _], [_, _, _, _, _, D, _, _, _, _, _], [_, _, _, _, _, D, _, _, _, _, _], [A, A, _, _, _, D, _, _, _, A, A], [A, A, D, D, D, K, D, D, D, A, A], [A, A, _, _, _, D, _, _, _, A, A], [_, _, _, _, _, D, _, _, _, _, _], [_, _, _, _, _, D, _, _, _, _, _], [_, _, _, _, A, A, A, _, _, _, _], [_, _, _, _, A, A, A, _, _, _, _], ]; static readonly _13_PARLETT = [ [_, _, _, _, A, A, A, A, A, _, _, _, _], [_, _, _, _, _, A, _, A, _, _, _, _, _], [_, _, _, _, _, _, A, _, _, _, _, _, _], [_, _, _, _, _, _, D, _, _, _, _, _, _], [A, _, _, _, D, _, _, _, D, _, _, _, A], [A, A, _, _, _, D, D, D, _, _, _, A, A], [A, _, A, D, _, D, K, D, _, D, A, _, A], [A, A, _, _, _, D, D, D, _, _, _, A, A], [A, _, _, _, D, _, _, _, D, _, _, _, A], [_, _, _, _, _, _, D, _, _, _, _, _, _], [_, _, _, _, _, _, A, _, _, _, _, _, _], [_, _, _, _, _, A, _, A, _, _, _, _, _], [_, _, _, _, A, A, A, A, A, _, _, _, _], ]; static readonly _15_DAMIEN_WALKER = [ [_, _, _, _, _, A, A, A, A, A, _, _, _, _, _], [_, _, _, _, _, _, A, A, A, _, _, _, _, _, _], [_, _, _, _, _, _, _, A, _, _, _, _, _, _, _], [_, _, _, _, _, _, _, A, _, _, _, _, _, _, _], [_, _, _, _, _, _, _, D, _, _, _, _, _, _, _], [A, _, _, _, _, _, D, D, D, _, _, _, _, _, A], [A, A, _, _, _, D, _, D, _, D, _, _, _, A, A], [A, A, A, A, D, D, D, K, D, D, D, A, A, A, A], [A, A, _, _, _, D, _, D, _, D, _, _, _, A, A], [A, _, _, _, _, _, D, D, D, _, _, _, _, _, A], [_, _, _, _, _, _, _, D, _, _, _, _, _, _, _], [_, _, _, _, _, _, _, A, _, _, _, _, _, _, _], [_, _, _, _, _, _, _, A, _, _, _, _, _, _, _], [_, _, _, _, _, _, A, A, A, _, _, _, _, _, _], [_, _, _, _, _, A, A, A, A, A, _, _, _, _, _], ]; static readonly _19_ALEA_EVANGELII = [ [_, _, A, _, _, A, _, _, _, _, _, _, _, A, _, _, A, _, _], [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], [A, _, _, _, _, A, _, _, _, _, _, _, _, A, _, _, _, _, A], [_, _, _, _, _, _, _, A, _, A, _, A, _, _, _, _, _, _, _], [_, _, _, _, _, _, A, _, D, _, D, _, A, _, _, _, _, _, _], [A, _, A, _, _, A, _, _, _, _, _, _, _, A, _, _, A, _, A], [_, _, _, _, A, _, _, _, _, D, _, _, _, _, A, _, _, _, _], [_, _, _, A, _, _, _, _, D, _, D, _, _, _, _, A, _, _, _], [_, _, _, _, D, _, _, D, _, D, _, D, _, _, D, _, _, _, _], [_, _, _, A, _, _, D, _, D, K, D, _, D, _, _, A, _, _, _], [_, _, _, _, D, _, _, D, _, D, _, D, _, _, D, _, _, _, _], [_, _, _, A, _, _, _, _, D, _, D, _, _, _, _, A, _, _, _], [_, _, _, _, A, _, _, _, _, D, _, _, _, _, A, _, _, _, _], [A, _, A, _, _, A, _, _, _, _, _, _, _, A, _, _, A, _, A], [_, _, _, _, _, _, A, _, D, _, D, _, A, _, _, _, _, _, _], [_, _, _, _, _, _, _, A, _, A, _, A, _, _, _, _, _, _, _], [A, _, _, _, _, A, _, _, _, _, _, _, _, A, _, _, _, _, A], [_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _], [_, _, A, _, _, A, _, _, _, _, _, _, _, A, _, _, A, _, _], ]; } export class TaflSide extends String { static readonly ATTACKER = "Attacker"; static readonly DEFENDER = "Defender"; } export class TaflRule { static readonly KING_IS_ARMED = "kingIsArmed"; static readonly KING_CAN_RETURN_TO_CENTER = "kingCanReturnToCenter"; static readonly ATTACKER_COUNT_TO_CAPTURE = "attackerCountToCapture"; static readonly REPETITION_TURN_LIMIT = "repetitionTurnLimit"; static readonly SHIELD_WALLS = "shieldWalls"; static readonly EXIT_FORTS = "exitForts"; static readonly EDGE_ESCAPE = "edgeEscape"; static readonly CORNER_BASE_WIDTH = "cornerBaseWidth"; static readonly STARTING_SIDE = "startingSide"; static readonly SAVE_BOARD_HISTORY = "saveBoardHistory"; static readonly SAVE_ACTIONS = "saveActions"; } export class TaflRuleSet { static readonly COPENHAGEN = { [TaflRule.KING_IS_ARMED]: true, [TaflRule.KING_CAN_RETURN_TO_CENTER]: true, [TaflRule.ATTACKER_COUNT_TO_CAPTURE]: 4, [TaflRule.REPETITION_TURN_LIMIT]: 3, [TaflRule.SHIELD_WALLS]: true, [TaflRule.EXIT_FORTS]: true, [TaflRule.EDGE_ESCAPE]: false, [TaflRule.CORNER_BASE_WIDTH]: 1, [TaflRule.STARTING_SIDE]: TaflSide.ATTACKER, [TaflRule.SAVE_BOARD_HISTORY]: true, [TaflRule.SAVE_ACTIONS]: true, }; } function rotateLeft<T>(array: Array<Array<T>>): Array<Array<T>> { const result: Array<Array<T>> = []; array.forEach(function (a, i, aa) { a.forEach(function (b, j, bb) { result[j] = result[j] || []; result[j][aa.length - i - 1] = b; }); }); return result; } export class Tafl implements Game { name = "Tafl"; controlMap = new Map<TaflSide, Set<Piece>>([ [TaflSide.ATTACKER, new Set([Piece.PA])], [TaflSide.DEFENDER, new Set([Piece.PD, Piece.PK])], ]); sideMap = new Map<Piece, TaflSide>([ [Piece.PA, TaflSide.ATTACKER], [Piece.PD, TaflSide.DEFENDER], [Piece.PK, TaflSide.DEFENDER], ]); initialState(init?: GameState): GameState { const board = init?.board || TaflBoard._11_CLASSIC; const hash = this.getBoardHash(board); const initialState: GameState = { turn: 0, actions: [], boardHistory: { [hash]: 1 }, result: { finished: false, winner: undefined, desc: "", }, lastAction: undefined, rules: init?.rules || TaflRuleSet.COPENHAGEN, board, }; return initialState; } log(thing: any) { console.log( util.inspect(thing, { showHidden: false, depth: null, colorize: true, maxArrayLength: 100, breakLength: 100, compact: true, }) ); } isCenter(board: Board, coords: Coords): boolean { const n = board.length; const center = Math.floor(n / 2); return coords.r === center && coords.c === center; } isCorner(state: GameState, coords: Coords): boolean { if (!state.board) { return false; } const n = state.board.length; const w = state.rules?.[TaflRule.CORNER_BASE_WIDTH]!; let [rowRes, colRes] = [false, false]; for (let ww = 0; ww < w; ww += 1) { rowRes = rowRes || coords.r === ww || coords.r === n - 1 - ww; colRes = colRes || coords.c === ww || coords.c === n - 1 - ww; } return rowRes && colRes; } isEdge(state: GameState, coords: Coords): boolean { if (!state.board) { return false; } const n = state.board.length; const fstRow = coords.r === 0; const lstRow = coords.r === n - 1; const fstCol = coords.c === 0; const lstCol = coords.c === n - 1; const onEdge = fstRow || lstRow || fstCol || lstCol; return onEdge && !this.isCorner(state, coords); } repr(coords: Coords): String { return `${coords.r}_${coords.c}`; } coords(repr: String): Coords { const splitted = repr.split("_"); return { r: parseInt(splitted[0], 10), c: parseInt(splitted[1], 10) }; } toPathRepr(...args: Array<Coords>): String { return args.map(this.repr).join("->"); } toPathCoords(path: String): Array<Coords> { return path.split("->").map(this.coords); } insideBounds(board: Board, coords: Coords): boolean { const rowInsideBounds = coords.r >= 0 && coords.r <= board.length - 1; const colInsideBounds = coords.c >= 0 && coords.c <= board.length - 1; return rowInsideBounds && colInsideBounds; } connectedPieces(board: Board, coords: Coords, side: TaflSide): Set<String> { const that = this; function getConnectedPieces( board: Board, coords: Coords, setOfCoords: Set<String> ) { setOfCoords.add(that.repr(coords)); for (let r = -1; r <= 1; r += 1) { for (let c = -1; c <= 1; c += 1) { const newCoords: Coords = { r: coords.r + r, c: coords.c + c }; const isSelf = r === 0 && c === 0; if ( that.insideBounds(board, newCoords) && !isSelf && ((side === TaflSide.DEFENDER && that.isDefender(board, newCoords)) || (side === TaflSide.ATTACKER && that.isAttacker(board, newCoords))) ) { if (!setOfCoords.has(that.repr(newCoords))) { getConnectedPieces(board, newCoords, setOfCoords); } } } } } const connectedPieces = new Set<String>(); getConnectedPieces(board, coords, connectedPieces); return connectedPieces; } connectedDefenders(board: Board, coords: Coords): Set<String> { return this.connectedPieces(board, coords, TaflSide.DEFENDER); } connectedAttackers(board: Board, coords: Coords): Set<String> { return this.connectedPieces(board, coords, TaflSide.ATTACKER); } possiblySurroundingPieces( board: Board, kingCoords: Coords, side: TaflSide ): Array<Coords> { const possiblySurroundingPiecesSet = new Set<String>(); const processed = new Set<String>(); const q = new Array<String>(); // we'll look in 4 directions starting from the king const neighbors_4 = [ [-1, 0], [0, -1], [0, 1], [1, 0], ]; q.push(this.repr(kingCoords)); while (q.length > 0) { const curCoords = this.coords(q.pop()!); for (const neighbor of neighbors_4) { const neighborCoords: Coords = { r: curCoords.r + neighbor[0], c: curCoords.c + neighbor[1], }; if ( this.insideBounds(board, neighborCoords) && !processed.has(this.repr(neighborCoords)) ) { if ( (side === TaflSide.DEFENDER && this.isDefender(board, neighborCoords)) || (side === TaflSide.ATTACKER && this.isAttacker(board, neighborCoords)) ) { possiblySurroundingPiecesSet.add(this.repr(neighborCoords)); } else { q.push(this.repr(neighborCoords)); } } } processed.add(this.repr(curCoords)); } return [...possiblySurroundingPiecesSet].map(this.coords); } possiblySurrondingDefenders(board: Board, kingCoords: Coords): Array<Coords> { return this.possiblySurroundingPieces(board, kingCoords, TaflSide.DEFENDER); } possiblySurrondingAttackers(board: Board, kingCoords: Coords): Array<Coords> { return this.possiblySurroundingPieces(board, kingCoords, TaflSide.ATTACKER); } isInsideEye( board: Board, coords: Coords, fullFortStructure: Set<String> ): boolean { const [smallestEye, attackersInEye] = this.getEmptyPiecesAndAttackersInsideSmallestFort( board, coords, fullFortStructure ); if (attackersInEye.size > 0) { return false; } return smallestEye.has(this.repr(coords)); } getPossibleSmallestFortStructure( board: Board, innerSet: Set<String>, fullStructure: Set<String> ): Set<String> { const processed = new Set<String>(); const neighbors_4 = [ [-1, 0], [0, -1], [0, 1], [1, 0], ]; const smallest: Set<String> = new Set<String>(); for (const innerRepr of innerSet) { const innerCoords = this.coords(innerRepr); for (const neighbor of neighbors_4) { const neighborCoords: Coords = { r: innerCoords.r + neighbor[0], c: innerCoords.c + neighbor[1], }; if ( this.insideBounds(board, neighborCoords) && !processed.has(this.repr(neighborCoords)) ) { if (this.isDefender(board, neighborCoords)) { smallest.add(this.repr(neighborCoords)); } processed.add(this.repr(neighborCoords)); } } } let intersection = new Set( [...fullStructure].filter((x) => smallest.has(x)) ); return intersection; } getEmptyPiecesAndOpponentsInsideSmallestClosedStructure( board: Board, kingCoords: Coords, closedStructure: Set<String>, oppSide: TaflSide = TaflSide.ATTACKER ): Array<Set<String>> { const opponentSet = new Set<String>(); const innerSet = new Set<String>(); const processed = new Set<String>(); const q = new Array<String>(); // we'll look in 4 directions starting from the king const neighbors_4 = [ [-1, 0], [0, -1], [0, 1], [1, 0], ]; q.push(this.repr(kingCoords)); while (q.length > 0) { const curCoords = this.coords(q.pop()!); if ( this.insideBounds(board, curCoords) && !processed.has(this.repr(curCoords)) ) { if (!closedStructure.has(this.repr(curCoords))) { if ( this.isEmpty(board, curCoords) || this.isDefenderOrKing(board, curCoords) ) { innerSet.add(this.repr(curCoords)); } else if ( (oppSide === TaflSide.ATTACKER && this.isAttacker(board, curCoords)) || (oppSide === TaflSide.DEFENDER && this.isDefender(board, curCoords)) ) { opponentSet.add(this.repr(curCoords)); } for (const neighbor of neighbors_4) { const neighborCoords: Coords = { r: curCoords.r + neighbor[0], c: curCoords.c + neighbor[1], }; q.push(this.repr(neighborCoords)); } } } processed.add(this.repr(curCoords)); } return [innerSet, opponentSet]; } getEmptyPiecesAndAttackersInsideSmallestFort( board: Board, kingCoords: Coords, fullFortStructure: Set<String> ): Array<Set<String>> { return this.getEmptyPiecesAndOpponentsInsideSmallestClosedStructure( board, kingCoords, fullFortStructure ); } insideFort(board: Board, kingCoords: Coords): boolean { const kingOnTopRow = kingCoords.r === 0; const kingOnBottomRow = kingCoords.r === board.length - 1; const kingOnLeftmostCol = kingCoords.c === 0; const kingOnRightmostCol = kingCoords.c === board.length - 1; let res = false; if (kingOnTopRow || kingOnBottomRow) { const defenderCount = board[kingCoords.r].reduce((acc, cur) => { const p = cur === Piece.PD ? 1 : 0; return acc + p; }, 0); if (defenderCount >= 2) { // at least 2 defenders are required for a fort res = this.fortSearchFromKing(board, kingCoords); } } else if (kingOnLeftmostCol || kingOnRightmostCol) { const defenderCount = board.reduce((acc, cur) => { const p = cur[kingCoords.c] === Piece.PD ? 1 : 0; return acc + p; }, 0); if (defenderCount >= 2) { // at least 2 defenders are required for a fort res = this.fortSearchFromKing(board, kingCoords); } } return res; } kingEscapedThroughFort(state: GameState): boolean { const lastTo = state.lastAction?.to; if (!lastTo || !state.board) { return false; } const king = this.isKing(state.board, lastTo); if (!king) { return false; } const onEdge = this.isEdge(state, lastTo); if (!onEdge) { return false; } return this.insideFort(state.board, lastTo); } isBase(state: GameState, coords: Coords): boolean { return this.isCenter(state.board!, coords) || this.isCorner(state, coords); } isEmpty(board: Board, coords: Coords) { return this.pieceAt(board, coords) === Piece.__; } isEmptyBase(state: GameState, coords: Coords): boolean { return this.isBase(state, coords) && this.isEmpty(state.board!, coords); } isKing(board: Board, coords: Coords): boolean { return this.pieceAt(board, coords) === Piece.PK; } isAttacker(board: Board, coords: Coords): boolean { return this.pieceAt(board, coords) === Piece.PA; } isDefender(board: Board, coords: Coords): boolean { return this.pieceAt(board, coords) === Piece.PD; } isDefenderOrKing(board: Board, coords: Coords): boolean { return this.isDefender(board, coords) || this.isKing(board, coords); } canHelpCapture(state: GameState, coords: Coords, canHelp: TaflSide): boolean { const isAttackerAndCanHelpAttackers = this.isAttacker(state.board!, coords) && canHelp === TaflSide.ATTACKER; const isDefenderAndCanHelpDefenders = this.isDefender(state.board!, coords) && canHelp === TaflSide.DEFENDER; const isKingAndCanHelpDefenders = state.rules?.[TaflRule.KING_IS_ARMED]! && this.isKing(state.board!, coords) && canHelp === TaflSide.DEFENDER; return ( this.isEmptyBase(state, coords) || isAttackerAndCanHelpAttackers || isDefenderAndCanHelpDefenders || isKingAndCanHelpDefenders ); } turnSide(state: GameState): TaflSide { const evenTurn = state.turn! % 2 === 0; const attackerStarts = state.rules?.[TaflRule.STARTING_SIDE]! === TaflSide.ATTACKER; return attackerStarts === evenTurn ? TaflSide.ATTACKER : TaflSide.DEFENDER; } opponentSide(state: GameState): TaflSide { const turnSide = this.turnSide(state); return turnSide === TaflSide.ATTACKER ? TaflSide.DEFENDER : TaflSide.ATTACKER; } canControl(side: TaflSide, piece: Piece): boolean { return !!this.controlMap.get(side)?.has(piece); } sideOfPiece(piece: Piece): TaflSide | undefined { return this.sideMap.get(piece); } pieceAt(board: Board, coords: Coords): Piece { return board[coords.r][coords.c]; } canMovePieceHere(state: GameState, piece: Piece, coords: Coords): boolean { const empty = this.isEmpty(state.board!, coords); const king = piece === Piece.PK; const base = this.isBase(state, coords); const center = this.isCenter(state.board!, coords); const corner = this.isCorner(state, coords); const centerOk = state.rules?.[TaflRule.KING_CAN_RETURN_TO_CENTER]!; return (empty && !base) || (king && ((centerOk && center) || corner)); } getPossibleMovesFrom(state: GameState, coords: Coords): Array<Coords> { const row = coords.r; const col = coords.c; const res: Coords[] = []; if (!state.board) { return []; } const piece: Piece = this.pieceAt(state.board, coords); const n = state.board.length; let rowCount = row - 1; while ( rowCount >= 0 && this.canMovePieceHere(state, piece, { r: rowCount, c: col }) ) { res.push({ r: rowCount, c: col }); rowCount -= 1; } rowCount = row + 1; while ( rowCount <= n - 1 && this.canMovePieceHere(state, piece, { r: rowCount, c: col }) ) { res.push({ r: rowCount, c: col }); rowCount += 1; } let colCount = col - 1; while ( colCount >= 0 && this.canMovePieceHere(state, piece, { r: row, c: colCount }) ) { res.push({ r: row, c: colCount }); colCount -= 1; } colCount = col + 1; while ( colCount <= n - 1 && this.canMovePieceHere(state, piece, { r: row, c: colCount }) ) { res.push({ r: row, c: colCount }); colCount += 1; } return res; } canMakeAMove(state: GameState, side: TaflSide): boolean { return this.getPossibleActions(state, side).length > 0; } getKingCoords(board: Board): Coords { let kingCoords = null; for (const _r in board) { const r = parseInt(_r, 10); for (const _c in board[r]) { const c = parseInt(_c, 10); const coords = { r, c }; if (this.isKing(board, coords)) { kingCoords = coords; } } } return kingCoords!; } didAttackersSurroundDefenders(board: Board): boolean { const kingCoords = this.getKingCoords(board); let [top, right, bottom, left] = [0, 0, 0, 0]; for (let r = 0; r < kingCoords.r; r += 1) { if (this.isAttacker(board, { r, c: kingCoords.c })) { top += 1; } } for (let r = kingCoords.r + 1; r < board.length; r += 1) { if (this.isAttacker(board, { r, c: kingCoords.c })) { bottom += 1; } } for (let c = 0; c < kingCoords.c; c += 1) { if (this.isAttacker(board, { r: kingCoords.r, c })) { left += 1; } } for (let c = kingCoords.c + 1; c < board.length; c += 1) { if (this.isAttacker(board, { r: kingCoords.r, c })) { right += 1; } } if (top === 0 || right === 0 || bottom === 0 || left === 0) { return false; } const psa = this.possiblySurrondingAttackers(board, kingCoords); const psaSet = new Set(psa.map(this.repr)); const [innerSet, _] = this.getEmptyPiecesAndOpponentsInsideSmallestClosedStructure( board, kingCoords, psaSet ); const coordsArr = [...innerSet].map(this.coords); const n = board.length; const m = board[n - 1].length; const ind = coordsArr.findIndex( (coords) => coords.r === 0 || coords.c === 0 || coords.r === n - 1 || coords.c === m - 1 ); if (ind !== -1) { return false; } const defCount = coordsArr.reduce( (acc, cur) => acc + (this.isDefender(board, cur) ? 1 : 0), 0 ); const totCount = board.reduce( (acc, cur) => acc + cur.reduce((pAcc, pCur) => pAcc + (pCur === Piece.PD ? 1 : 0), 0), 0 ); if (defCount !== totCount) { const boardCopy = board.map(function (row) { return row.slice(); }); [...psaSet].map(this.coords).forEach((coords) => { boardCopy[coords.r][coords.c] = Piece.__; }); return this.didAttackersSurroundDefenders(boardCopy); } return true; } canBeCaptured(board: Board, coords: Coords, side: TaflSide): boolean { const mid = this.sideOfPiece(this.pieceAt(board, coords)); return ( (side === TaflSide.ATTACKER && mid === TaflSide.DEFENDER && !this.isKing(board, coords)) || (side === TaflSide.DEFENDER && mid === TaflSide.ATTACKER) ); } checkCaptures(state: GameState): Array<Coords> { let res: Array<Coords> = []; if (!state.lastAction?.to || !state.board) { return []; } const [lpr, lpc] = [state.lastAction.to.r, state.lastAction.to.c]; const side = this.turnSide(state); if ( lpr >= 2 && this.canHelpCapture(state, { r: lpr - 2, c: lpc }, side) && this.canBeCaptured(state.board, { r: lpr - 1, c: lpc }, side) ) { res.push({ r: lpr - 1, c: lpc }); } if ( lpr <= state.board.length - 3 && this.canHelpCapture(state, { r: lpr + 2, c: lpc }, side) && this.canBeCaptured(state.board, { r: lpr + 1, c: lpc }, side) ) { res.push({ r: lpr + 1, c: lpc }); } if ( lpc >= 2 && this.canHelpCapture(state, { r: lpr, c: lpc - 2 }, side) && this.canBeCaptured(state.board, { r: lpr, c: lpc - 1 }, side) ) { res.push({ r: lpr, c: lpc - 1 }); } if ( lpc <= state.board.length - 3 && this.canHelpCapture(state, { r: lpr, c: lpc + 2 }, side) && this.canBeCaptured(state.board, { r: lpr, c: lpc + 1 }, side) ) { res.push({ r: lpr, c: lpc + 1 }); } if (state.rules?.[TaflRule.SHIELD_WALLS]!) { res.push(...this.checkShieldWalls(state)); } return res; } checkShieldWalls(state: GameState): Array<Coords> { const res: Array<Coords> = []; const side = this.turnSide(state); if (!state.lastAction?.to || !state.board) { return []; } const [lpr, lpc] = [state.lastAction.to.r, state.lastAction.to.c]; const opp = this.opponentSide(state); // check top or bottom if (lpr === 0 || lpr === state.board.length - 1) { const [lCaptured, rCaptured]: Array<Array<Coords>> = [[], []]; let theCol = lpc - 1; const rowBehind = lpr === state.board.length - 1 ? state.board.length - 2 : 1; while ( this.insideBounds(state.board, { r: lpr, c: theCol }) && this.sideOfPiece(this.pieceAt(state.board, { r: lpr, c: theCol })) === opp && this.insideBounds(state.board, { r: rowBehind, c: theCol }) && this.canHelpCapture(state, { r: rowBehind, c: theCol }, side) ) { if ( this.sideOfPiece(this.pieceAt(state.board, { r: lpr, c: theCol })) === opp && !this.isKing(state.board, { r: lpr, c: theCol }) ) { lCaptured.push({ r: lpr, c: theCol }); } theCol -= 1; } if ( this.insideBounds(state.board, { r: lpr, c: theCol }) && this.canHelpCapture(state, { r: lpr, c: theCol }, side) ) { res.push(...lCaptured); } theCol = lpc + 1; while ( this.insideBounds(state.board, { r: lpr, c: theCol }) && this.sideOfPiece(this.pieceAt(state.board, { r: lpr, c: theCol })) === opp && this.insideBounds(state.board, { r: rowBehind, c: theCol }) && this.canHelpCapture(state, { r: rowBehind, c: theCol }, side) ) { if ( this.sideOfPiece(this.pieceAt(state.board, { r: lpr, c: theCol })) === opp && !this.isKing(state.board, { r: lpr, c: theCol }) ) { rCaptured.push({ r: lpr, c: theCol }); } theCol += 1; } if ( this.insideBounds(state.board, { r: lpr, c: theCol }) && this.canHelpCapture(state, { r: lpr, c: theCol }, side) ) { res.push(...rCaptured); } } // check left or right if (lpc === 0 || lpc === state.board.length - 1) { const [bCaptured, tCaptured]: Array<Array<Coords>> = [[], []]; let theRow = lpr - 1; const colBehind = lpc === state.board.length - 1 ? state.board.length - 2 : 1; while ( this.insideBounds(state.board, { r: theRow, c: lpc }) && this.sideOfPiece(this.pieceAt(state.board, { r: theRow, c: lpc })) === opp && this.insideBounds(state.board, { r: theRow, c: colBehind }) && this.canHelpCapture(state, { r: theRow, c: colBehind }, side) ) { if ( this.sideOfPiece(this.pieceAt(state.board, { r: theRow, c: lpc })) === opp && !this.isKing(state.board, { r: theRow, c: lpc }) ) { bCaptured.push({ r: theRow, c: lpc }); } theRow -= 1; } if ( this.insideBounds(state.board, { r: theRow, c: lpc }) && this.canHelpCapture(state, { r: theRow, c: lpc }, side) ) { res.push(...bCaptured); } theRow = lpr + 1; while ( this.insideBounds(state.board, { r: theRow, c: lpc }) && this.sideOfPiece(this.pieceAt(state.board, { r: theRow, c: lpc })) === opp && this.insideBounds(state.board, { r: theRow, c: colBehind }) && this.canHelpCapture(state, { r: theRow, c: colBehind }, side) ) { if ( this.sideOfPiece(this.pieceAt(state.board, { r: theRow, c: lpc })) === opp && !this.isKing(state.board, { r: theRow, c: lpc }) ) { tCaptured.push({ r: theRow, c: lpc }); } theRow += 1; } if ( this.insideBounds(state.board, { r: theRow, c: lpc }) && this.canHelpCapture(state, { r: theRow, c: lpc }, side) ) { res.push(...tCaptured); } } return res; } getBoardHash(board: Board) { const data = board.reduce((acc, cur) => acc + cur.join(""), ""); return crypto.createHash("sha1").update(data).digest("base64"); } addBoardToHistory(state: GameState, board: Board): typeof state { const eqBoardsHashes = Object.keys(this.getEquivalentBoards(board)); const boardHistoryCopy: Record<string, number> = { ...state.boardHistory }; for (const boardHash of eqBoardsHashes) { if (!(boardHash in boardHistoryCopy)) { boardHistoryCopy[boardHash] = 0; } boardHistoryCopy[boardHash] += 1; } return Object.assign({}, state, { boardHistory: boardHistoryCopy }); } getEquivalentBoards(board: Board) { const b = board.map(function (arr) { return arr.slice(); }); const res: Record<string, Board> = {}; const bHash = this.getBoardHash(b); if (!(bHash in res)) { res[bHash] = b; } const rev = b.slice().reverse(); const revHash = this.getBoardHash(rev); if (!(revHash in res)) { res[revHash] = rev; } const b90 = rotateLeft(b); const b90Hash = this.getBoardHash(b90); if (!(b90Hash in res)) { res[b90Hash] = b90; } const b180 = rotateLeft(b90); const b180Hash = this.getBoardHash(b180); if (!(b180Hash in res)) { res[b180Hash] = b180; } const b270 = rotateLeft(b180); const b270Hash = this.getBoardHash(b270); if (!(b270Hash in res)) { res[b270Hash] = b270; } const rev90 = rotateLeft(rev); const rev90Hash = this.getBoardHash(rev90); if (!(rev90Hash in res)) { res[rev90Hash] = rev90; } const rev180 = rotateLeft(rev90); const rev180Hash = this.getBoardHash(rev180); if (!(rev180Hash in res)) { res[rev180Hash] = rev180; } const rev270 = rotateLeft(rev180); const rev270Hash = this.getBoardHash(rev270); if (!(rev270Hash in res)) { res[rev270Hash] = rev270; } return res; } public fortSearchFromKing(board: Board, kingCoords: Coords): boolean { const possiblySurroundingDefendersCoords = this.possiblySurrondingDefenders( board, kingCoords ); const searchOnRow = kingCoords.r === 0 || kingCoords.r === board.length - 1; const defendersOnSameSearchRowOrSearchCol = searchOnRow ? possiblySurroundingDefendersCoords.filter( (coords) => coords.r === kingCoords.r ) : possiblySurroundingDefendersCoords.filter( (coords) => coords.c === kingCoords.c ); const defendersBeforeKing = searchOnRow ? defendersOnSameSearchRowOrSearchCol.filter( (coords) => coords.c < kingCoords.c ) : defendersOnSameSearchRowOrSearchCol.filter( (coords) => coords.r < kingCoords.r ); const defendersAfterKing = searchOnRow ? defendersOnSameSearchRowOrSearchCol.filter( (coords) => coords.c > kingCoords.c ) : defendersOnSameSearchRowOrSearchCol.filter( (coords) => coords.r > kingCoords.r ); // look for connected paths through before-after pieces const startEnds: Array<Array<Coords>> = []; for (const defenderBeforeKing of defendersBeforeKing) { const defendersConnectedToThisDefender = this.connectedDefenders( board, defenderBeforeKing ); for (const defenderAfterKing of defendersAfterKing) { const index = [...defendersConnectedToThisDefender].findIndex( (el) => el === this.repr(defenderAfterKing) ); if (index !== -1) { startEnds.push([defenderBeforeKing, defenderAfterKing]); } } } if (startEnds.length < 1) { return false; } // for start/end pair of possible fort, get full structure const fullStructuresWithPossibleDuplicates: Array<Set<String>> = []; for (const startEnd of startEnds) { const startingDefender = startEnd[0]; fullStructuresWithPossibleDuplicates.push( this.connectedDefenders(board, startingDefender) ); } // deduplication const fullStructures: Array<Set<String>> = [ ...new Set( fullStructuresWithPossibleDuplicates .map((fullStructure) => [...fullStructure].sort().map(this.coords)) .map((x) => this.toPathRepr(...x)) ), ] .map((x) => this.toPathCoords(x)) .map((x) => x.map((y) => this.repr(y))) .map((x) => new Set(x)); // for full structure, find the pieces that can be taken and replace them with _ // and check if remaining structure is still connected let isAFort = true; for (const fullStructure of fullStructures) { const [innerSet, attackerSet] = this.getEmptyPiecesAndAttackersInsideSmallestFort( board, kingCoords, fullStructure ); // eliminate structures that contain any attacker if (attackerSet.size > 0) { return false; } const possibleSmallestFortStructure = this.getPossibleSmallestFortStructure(board, innerSet, fullStructure); const weakCoordsInPossibleFort: Set<String> = new Set<String>(); for (const repr of possibleSmallestFortStructure) { const possibleFortCoords = this.coords(repr); const neighborsInOppositeDirections = [ [ { r: -1, c: 0 }, { r: 1, c: 0 }, ], [ { r: 0, c: -1 }, { r: 0, c: 1 }, ], ]; for (const oppositeNeighbors of neighborsInOppositeDirections) { let oppositeSum = 0; for (const neighbor of oppositeNeighbors) { const neighborCoords: Coords = { r: possibleFortCoords.r + neighbor.r, c: possibleFortCoords.c + neighbor.c, }; const insideBounds = this.insideBounds(board, neighborCoords); if (insideBounds) { const neighborRepr = this.repr(neighborCoords); // increase opposite sum if NOT // - is a defender or king OR // - is inside the fort OR // - is inside an eye if ( !( this.isDefenderOrKing(board, neighborCoords) || innerSet.has(neighborRepr) || this.isInsideEye(board, neighborCoords, fullStructure) ) ) { oppositeSum += 1; } if (oppositeSum > 1) { weakCoordsInPossibleFort.add(repr); } } } } } if (weakCoordsInPossibleFort.size > 0) { const boardCopy = board.map(function (row) { return row.slice(); }); [...weakCoordsInPossibleFort].forEach((weakRepr) => { const weakCoords = this.coords(weakRepr); boardCopy[weakCoords.r][weakCoords.c] = Piece.__; }); isAFort = isAFort && this.fortSearchFromKing(boardCopy, kingCoords); } } // if any structure makes it this far, it must be a fort return isAFort; } public getPossibleActions( state: GameState, side: TaflSide = this.turnSide(state) ): Array<MoveAction> { const res: Array<MoveAction> = []; for (const _r in state.board) { const r = parseInt(_r, 10); const row = state.board[r]; for (const _c in row) { const c = parseInt(_c, 10); const piece = row[c]; if (this.canControl(side, piece)) { const coords: Coords = { r, c }; const movesFrom = this.getPossibleMovesFrom(state, coords); movesFrom.forEach((toCoords) => { const moveAction: MoveAction = { from: coords, to: toCoords, }; res.push(moveAction); }); } } } return res; } public isActionPossible(state: GameState, act: MoveAction): boolean { if (!state.board) { return false; } if (act.from.r !== act.to.r && act.from.c !== act.to.c) { this.log("Move should be in the same row or column"); return false; } const f = act.from; const from = this.pieceAt(state.board, f); if (this.isEmpty(state.board, f)) { this.log(`There is no piece at [${f.r}, ${f.c}]`); return false; } const side = this.turnSide(state); if (!this.canControl(side, from)) { this.log(`You can not control the piece at [${f.r}, ${f.c}]`); return false; } const t = act.to; if (!this.isEmpty(state.board, t)) { this.log(`There is already a piece at [${t.r}, ${t.c}]`); return false; } const possibleCoords = this.getPossibleMovesFrom(state, f); const isPossible = possibleCoords.find( (pc) => pc.r === t.r && pc.c === t.c ); if (isPossible) { return true; } this.log(`Move [${f.r}, ${f.c}] -> [${t.r}, ${t.c}] is not possible`); return false; } public isGameOver(state: GameState): typeof state { if (!state.board) { return state; } const to = state.lastAction?.to; if (!to) { return Object.assign({}, state, { result: { ...state.result, finished: false, }, }); } if ( state.rules?.[TaflRule.SAVE_BOARD_HISTORY]! && state.boardHistory?.[this.getBoardHash(state.board)]! === state.rules?.[TaflRule.REPETITION_TURN_LIMIT]! ) { return Object.assign({}, state, { result: { finished: true, winner: null, desc: "Draw on repetition", }, }); } const side = this.turnSide(state); if (side === TaflSide.ATTACKER) { // King should be captured or all defenders should be surrounded const n = state.board.length; let [kingOnTop, kingOnRight, kingOnBottom, kingOnLeft] = [ false, false, false, false, ]; kingOnTop = to?.r > 1 && this.isKing(state.board, { r: to.r - 1, c: to.c }); kingOnBottom = to?.r < n - 2 && this.isKing(state.board, { r: to.r + 1, c: to.c }); kingOnLeft = to?.c > 1 && this.isKing(state.board, { r: to.r, c: to.c - 1 }); kingOnRight = to?.c < n - 2 && this.isKing(state.board, { r: to.r, c: to.c + 1 }); let kingPosition: Coords = { r: -1, c: -1 }; // king is initially not on the board if (kingOnTop) { kingPosition = { r: to.r - 1, c: to.c }; } else if (kingOnBottom) { kingPosition = { r: to.r + 1, c: to.c }; } else if (kingOnRight) { kingPosition = { r: to.r, c: to.c + 1 }; } else if (kingOnLeft) { kingPosition = { r: to.r, c: to.c - 1 }; } if ( kingPosition?.r > 0 && kingPosition?.r < n - 1 && kingPosition?.c > 0 && kingPosition?.c < n - 1 ) { const [kr, kc] = [kingPosition?.r, kingPosition?.c]; const tSurrounded = this.canHelpCapture( state, { r: kr - 1, c: kc }, side ) ? 1 : 0; const bSurrounded = this.canHelpCapture( state, { r: kr + 1, c: kc }, side ) ? 1 : 0; const lSurrounded = this.canHelpCapture( state, { r: kr, c: kc - 1 }, side ) ? 1 : 0; const rSurrounded = this.canHelpCapture( state, { r: kr, c: kc + 1 }, side ) ? 1 : 0; if (state.rules?.[TaflRule.ATTACKER_COUNT_TO_CAPTURE]! >= 3) { const sum = tSurrounded + bSurrounded + lSurrounded + rSurrounded; if (sum >= state.rules?.[TaflRule.ATTACKER_COUNT_TO_CAPTURE]!) { return Object.assign({}, state, { result: { finished: true, winner: TaflSide.ATTACKER, desc: "Attackers captured the king", }, }); } } if (state.rules?.[TaflRule.ATTACKER_COUNT_TO_CAPTURE]! === 2) { if ( tSurrounded + bSurrounded === 2 || lSurrounded + rSurrounded === 2 ) { return Object.assign({}, state, { result: { finished: true, winner: TaflSide.ATTACKER, desc: "Attackers captured the king", }, }); } } } const surrounded = this.didAttackersSurroundDefenders(state.board); if (surrounded) { return Object.assign({}, state, { result: { finished: true, winner: TaflSide.ATTACKER, desc: "Attackers surrounded the defenders", }, }); } } else { // King should be on edge, corner, or in an exit fort const edgeEscape = state.rules?.[TaflRule.EDGE_ESCAPE]! && this.isKing(state.board, to) && this.isEdge(state, to); if (edgeEscape) { return Object.assign({}, state, { result: { finished: true, winner: TaflSide.DEFENDER, desc: "King escaped from edge", }, }); } const cornerEscape = this.isKing(state.board, to) && this.isCorner(state, to); if (cornerEscape) { return Object.assign({}, state, { result: { finished: true, winner: TaflSide.DEFENDER, desc: "King escaped from corner", }, }); } const fortEscape = state.rules?.[TaflRule.EXIT_FORTS]! && this.kingEscapedThroughFort(state); if (fortEscape) { return Object.assign({}, state, { result: { finished: true, winner: TaflSide.DEFENDER, desc: "King escaped through exit fort", }, }); } } const canOpponentMove = this.canMakeAMove(state, this.opponentSide(state)); return Object.assign({}, state, { result: { finished: !canOpponentMove, winner: !canOpponentMove ? this.turnSide(state) : null, desc: !canOpponentMove ? "No move left for opponent" : "Game continues...", }, }); } public act(state: GameState, moveAction: MoveAction): typeof state { if (!state.board) { return state; } if (!this.isActionPossible(state, moveAction)) { return state; } const boardCopy = state.board.map(function (row) { return row.slice(); }); const fr = moveAction.from.r; const fc = moveAction.from.c; boardCopy[moveAction.to.r][moveAction.to.c] = this.pieceAt( state.board, moveAction.from ); boardCopy[fr][fc] = Piece.__; let actionsCopy; if (state.rules?.[TaflRule.SAVE_ACTIONS]!) { actionsCopy = state.actions?.slice()!; actionsCopy.push(moveAction); } const playerMovedState = Object.assign( {}, state, { lastAction: moveAction }, { board: boardCopy }, state.rules?.[TaflRule.SAVE_ACTIONS]! ? { actions: actionsCopy } : {} ); const captureds = this.checkCaptures(playerMovedState); if (captureds.length > 0) { for (const capturedCoords of captureds) { boardCopy[capturedCoords.r][capturedCoords.c] = Piece.__; } } const capturedPiecesState = Object.assign({}, playerMovedState, { board: boardCopy, captures: captureds, }); let gameOverState; if (state.rules?.[TaflRule.SAVE_BOARD_HISTORY]!) { const boardAddedToHistoryState = this.addBoardToHistory( capturedPiecesState, boardCopy ); gameOverState = this.isGameOver(boardAddedToHistoryState); } else { gameOverState = this.isGameOver(capturedPiecesState); } return Object.assign({}, gameOverState, { turn: state.turn! + 1 }); } }