BITNODE: IPvGO territory control strategy game (#934)

This commit is contained in:
Michael Ficocelli
2023-12-26 11:45:27 -05:00
committed by GitHub
parent c6141f2adf
commit 7ef12a0323
68 changed files with 7833 additions and 17 deletions
+683
View File
@@ -0,0 +1,683 @@
import {
Board,
BoardState,
Neighbor,
opponents,
PlayerColor,
playerColors,
PointState,
validityReason,
} from "../boardState/goConstants";
import {
findAdjacentPointsInChain,
findNeighbors,
getArrayFromNeighbor,
getBoardCopy,
getEmptySpaces,
getNewBoardState,
getStateCopy,
isDefined,
isNotNull,
updateCaptures,
updateChains,
} from "../boardState/boardState";
/**
* Determines if the given player can legally make a move at the specified coordinates.
*
* You cannot repeat previous board states, to prevent endless loops (superko rule)
*
* You cannot make a move that would remove all liberties of your own piece(s) unless it captures opponent's pieces
*
* You cannot make a move in an occupied space
*
* You cannot make a move if it is not your turn, or if the game is over
*
* @returns a validity explanation for if the move is legal or not
*/
export function evaluateIfMoveIsValid(
boardState: BoardState,
x: number,
y: number,
player: PlayerColor,
shortcut = true,
) {
const point = boardState.board?.[x]?.[y];
if (boardState.previousPlayer === null) {
return validityReason.gameOver;
}
if (boardState.previousPlayer === player) {
return validityReason.notYourTurn;
}
if (!point) {
return validityReason.pointBroken;
}
if (point.player !== playerColors.empty) {
return validityReason.pointNotEmpty;
}
// Detect if the current player has ever previously played this move. Used to detect potential repeated board states
const moveHasBeenPlayedBefore = !!boardState.history.find((board) => board[x]?.[y]?.player === player);
if (shortcut) {
// If the current point has some adjacent open spaces, it is not suicide. If the move is not repeated, it is legal
const liberties = findAdjacentLibertiesForPoint(boardState, x, y);
const hasLiberty = liberties.north || liberties.east || liberties.south || liberties.west;
if (!moveHasBeenPlayedBefore && hasLiberty) {
return validityReason.valid;
}
// If a connected friendly chain has more than one liberty, the move is not suicide. If the move is not repeated, it is legal
const neighborChainLibertyCount = findMaxLibertyCountOfAdjacentChains(boardState, x, y, player);
if (!moveHasBeenPlayedBefore && neighborChainLibertyCount > 1) {
return validityReason.valid;
}
// If there is any neighboring enemy chain with only one liberty, and the move is not repeated, it is valid,
// because it would capture the enemy chain and free up some liberties for itself
const potentialCaptureChainLibertyCount = findMinLibertyCountOfAdjacentChains(
boardState,
x,
y,
player === playerColors.black ? playerColors.white : playerColors.black,
);
if (!moveHasBeenPlayedBefore && potentialCaptureChainLibertyCount < 2) {
return validityReason.valid;
}
// If there is no direct liberties for the move, no captures, and no neighboring friendly chains with multiple liberties,
// the move is not valid because it would suicide the piece
if (!hasLiberty && potentialCaptureChainLibertyCount >= 2 && neighborChainLibertyCount <= 1) {
return validityReason.noSuicide;
}
}
// If the move has been played before and is not obviously illegal, we have to actually play it out to determine
// if it is a repeated move, or if it is a valid move
const evaluationBoard = evaluateMoveResult(boardState, x, y, player, true);
if (evaluationBoard.board[x]?.[y]?.player !== player) {
return validityReason.noSuicide;
}
if (moveHasBeenPlayedBefore && checkIfBoardStateIsRepeated(evaluationBoard)) {
return validityReason.boardRepeated;
}
return validityReason.valid;
}
/**
* Create a new evaluation board and play out the results of the given move on the new board
*/
export function evaluateMoveResult(
initialBoardState: BoardState,
x: number,
y: number,
player: playerColors,
resetChains = false,
) {
const boardState = getStateCopy(initialBoardState);
boardState.history.push(getBoardCopy(boardState).board);
const point = boardState.board[x]?.[y];
if (!point) {
return initialBoardState;
}
point.player = player;
boardState.previousPlayer = player;
const neighbors = getArrayFromNeighbor(findNeighbors(boardState, x, y));
const chainIdsToUpdate = [point.chain, ...neighbors.map((point) => point.chain)];
resetChainsById(boardState, chainIdsToUpdate);
return updateCaptures(boardState, player, resetChains);
}
export function getControlledSpace(boardState: BoardState) {
const chains = getAllChains(boardState);
const length = boardState.board[0].length;
const whiteControlledEmptyNodes = getAllPotentialEyes(boardState, chains, playerColors.white, length * 2)
.map((eye) => eye.chain)
.flat();
const blackControlledEmptyNodes = getAllPotentialEyes(boardState, chains, playerColors.black, length * 2)
.map((eye) => eye.chain)
.flat();
const ownedPointGrid = Array.from({ length }, () => Array.from({ length }, () => playerColors.empty));
whiteControlledEmptyNodes.forEach((node) => {
ownedPointGrid[node.x][node.y] = playerColors.white;
});
blackControlledEmptyNodes.forEach((node) => {
ownedPointGrid[node.x][node.y] = playerColors.black;
});
return ownedPointGrid;
}
/**
Clear the chain and liberty data of all points in the given chains
*/
const resetChainsById = (boardState: BoardState, chainIds: string[]) => {
const pointsToUpdate = boardState.board
.flat()
.filter(isDefined)
.filter(isNotNull)
.filter((point) => chainIds.includes(point.chain));
pointsToUpdate.forEach((point) => {
point.chain = "";
point.liberties = [];
});
};
/**
* For a potential move, determine what the liberty of the point would be if played, by looking at adjacent empty nodes
* as well as the remaining liberties of neighboring friendly chains
*/
export function findEffectiveLibertiesOfNewMove(boardState: BoardState, x: number, y: number, player: PlayerColor) {
const friendlyChains = getAllChains(boardState).filter((chain) => chain[0].player === player);
const neighbors = findAdjacentLibertiesAndAlliesForPoint(boardState, x, y, player);
const neighborPoints = [neighbors.north, neighbors.east, neighbors.south, neighbors.west]
.filter(isNotNull)
.filter(isDefined);
// Get all chains that the new move will connect to
const allyNeighbors = neighborPoints.filter((neighbor) => neighbor.player === player);
const allyNeighborChainLiberties = allyNeighbors
.map((neighbor) => {
const chain = friendlyChains.find((chain) => chain[0].chain === neighbor.chain);
return chain?.[0]?.liberties ?? null;
})
.flat()
.filter(isNotNull);
// Get all empty spaces that the new move connects to that aren't already part of friendly liberties
const directLiberties = neighborPoints.filter((neighbor) => neighbor.player === playerColors.empty);
const allLiberties = [...directLiberties, ...allyNeighborChainLiberties];
// filter out duplicates, and starting point
return allLiberties
.filter(
(liberty, index) =>
allLiberties.findIndex((neighbor) => liberty.x === neighbor.x && liberty.y === neighbor.y) === index,
)
.filter((liberty) => liberty.x !== x || liberty.y !== y);
}
/**
* Find the number of open spaces that are connected to chains adjacent to a given point, and return the maximum
*/
export function findMaxLibertyCountOfAdjacentChains(
boardState: BoardState,
x: number,
y: number,
player: playerColors,
) {
const neighbors = findAdjacentLibertiesAndAlliesForPoint(boardState, x, y, player);
const friendlyNeighbors = [neighbors.north, neighbors.east, neighbors.south, neighbors.west]
.filter(isNotNull)
.filter(isDefined)
.filter((neighbor) => neighbor.player === player);
return friendlyNeighbors.reduce((max, neighbor) => Math.max(max, neighbor?.liberties?.length ?? 0), 0);
}
/**
* Find the number of open spaces that are connected to chains adjacent to a given point, and return the minimum
*/
export function findMinLibertyCountOfAdjacentChains(
boardState: BoardState,
x: number,
y: number,
player: playerColors,
) {
const chain = findEnemyNeighborChainWithFewestLiberties(boardState, x, y, player);
return chain?.[0]?.liberties?.length ?? 99;
}
export function findEnemyNeighborChainWithFewestLiberties(
boardState: BoardState,
x: number,
y: number,
player: playerColors,
) {
const chains = getAllChains(boardState);
const neighbors = findAdjacentLibertiesAndAlliesForPoint(boardState, x, y, player);
const friendlyNeighbors = [neighbors.north, neighbors.east, neighbors.south, neighbors.west]
.filter(isNotNull)
.filter(isDefined)
.filter((neighbor) => neighbor.player === player);
const minimumLiberties = friendlyNeighbors.reduce(
(min, neighbor) => Math.min(min, neighbor?.liberties?.length ?? 0),
friendlyNeighbors?.[0]?.liberties?.length ?? 99,
);
const chainId = friendlyNeighbors.find((neighbor) => neighbor?.liberties?.length === minimumLiberties)?.chain;
return chains.find((chain) => chain[0].chain === chainId);
}
/**
* Returns a list of points that are valid moves for the given player
*/
export function getAllValidMoves(boardState: BoardState, player: PlayerColor) {
return getEmptySpaces(boardState).filter(
(point) => evaluateIfMoveIsValid(boardState, point.x, point.y, player) === validityReason.valid,
);
}
/**
Find all empty point groups where either:
* all of its immediate surrounding player-controlled points are in the same continuous chain, or
* it is completely surrounded by some single larger chain and the edge of the board
Eyes are important, because a chain of pieces cannot be captured if it fully surrounds two or more eyes.
*/
export function getAllEyesByChainId(boardState: BoardState, player: playerColors) {
const allChains = getAllChains(boardState);
const eyeCandidates = getAllPotentialEyes(boardState, allChains, player);
const eyes: { [s: string]: PointState[][] } = {};
eyeCandidates.forEach((candidate) => {
if (candidate.neighbors.length === 0) {
return;
}
// If only one chain surrounds the empty space, it is a true eye
if (candidate.neighbors.length === 1) {
const neighborChainID = candidate.neighbors[0][0].chain;
eyes[neighborChainID] = eyes[neighborChainID] || [];
eyes[neighborChainID].push(candidate.chain);
return;
}
// If any chain fully encircles the empty space (even if there are other chains encircled as well), the eye is true
const neighborsEncirclingEye = findNeighboringChainsThatFullyEncircleEmptySpace(
boardState,
candidate.chain,
candidate.neighbors,
allChains,
);
neighborsEncirclingEye.forEach((neighborChain) => {
const neighborChainID = neighborChain[0].chain;
eyes[neighborChainID] = eyes[neighborChainID] || [];
eyes[neighborChainID].push(candidate.chain);
});
});
return eyes;
}
/**
* Get a list of all eyes, grouped by the chain they are adjacent to
*/
export function getAllEyes(boardState: BoardState, player: playerColors, eyesObject?: { [s: string]: PointState[][] }) {
const eyes = eyesObject ?? getAllEyesByChainId(boardState, player);
return Object.keys(eyes).map((key) => eyes[key]);
}
/**
Find all empty spaces completely surrounded by a single player color.
For each player chain number, add any empty space chains that are completely surrounded by a single player's color to
an array at that chain number's index.
*/
export function getAllPotentialEyes(
boardState: BoardState,
allChains: PointState[][],
player: playerColors,
_maxSize?: number,
) {
const nodeCount = boardState.board.map((row) => row.filter((p) => p)).flat().length;
const maxSize = _maxSize ?? Math.min(nodeCount * 0.4, 11);
const emptyPointChains = allChains.filter((chain) => chain[0].player === playerColors.empty);
const eyeCandidates: { neighbors: PointState[][]; chain: PointState[]; id: string }[] = [];
emptyPointChains
.filter((chain) => chain.length <= maxSize)
.forEach((chain) => {
const neighboringChains = getAllNeighboringChains(boardState, chain, allChains);
const hasWhitePieceNeighbor = neighboringChains.find(
(neighborChain) => neighborChain[0]?.player === playerColors.white,
);
const hasBlackPieceNeighbor = neighboringChains.find(
(neighborChain) => neighborChain[0]?.player === playerColors.black,
);
// Record the neighbor chains of the eye candidate empty chain, if all of its neighbors are the same color piece
if (
(hasWhitePieceNeighbor && !hasBlackPieceNeighbor && player === playerColors.white) ||
(!hasWhitePieceNeighbor && hasBlackPieceNeighbor && player === playerColors.black)
) {
eyeCandidates.push({
neighbors: neighboringChains,
chain: chain,
id: chain[0].chain,
});
}
});
return eyeCandidates;
}
/**
* For each chain bordering an eye candidate:
* remove all other neighboring chains. (replace with empty points)
* check if the eye candidate is a simple true eye now
* If so, the original candidate is a true eye.
*/
function findNeighboringChainsThatFullyEncircleEmptySpace(
boardState: BoardState,
candidateChain: PointState[],
neighborChainList: PointState[][],
allChains: PointState[][],
) {
const boardMax = boardState.board[0].length - 1;
const candidateSpread = findFurthestPointsOfChain(candidateChain);
return neighborChainList.filter((neighborChain, index) => {
// If the chain does not go far enough to surround the eye in question, don't bother building an eval board
const neighborSpread = findFurthestPointsOfChain(neighborChain);
const couldWrapNorth =
neighborSpread.north > candidateSpread.north ||
(candidateSpread.north === boardMax && neighborSpread.north === boardMax);
const couldWrapEast =
neighborSpread.east > candidateSpread.east ||
(candidateSpread.east === boardMax && neighborSpread.east === boardMax);
const couldWrapSouth =
neighborSpread.south < candidateSpread.south || (candidateSpread.south === 0 && neighborSpread.south === 0);
const couldWrapWest =
neighborSpread.west < candidateSpread.west || (candidateSpread.west === 0 && neighborSpread.west === 0);
if (!couldWrapNorth || !couldWrapEast || !couldWrapSouth || !couldWrapWest) {
return false;
}
const evaluationBoard = getStateCopy(boardState);
const examplePoint = candidateChain[0];
const otherChainNeighborPoints = removePointAtIndex(neighborChainList, index)
.flat()
.filter(isNotNull)
.filter(isDefined);
otherChainNeighborPoints.forEach((point) => {
const pointToEdit = evaluationBoard.board[point.x]?.[point.y];
if (pointToEdit) {
pointToEdit.player = playerColors.empty;
}
});
const updatedBoard = updateChains(evaluationBoard);
const newChains = getAllChains(updatedBoard);
const newChainID = updatedBoard.board[examplePoint.x]?.[examplePoint.y]?.chain;
const chain = newChains.find((chain) => chain[0].chain === newChainID) || [];
const newNeighborChains = getAllNeighboringChains(boardState, chain, allChains);
return newNeighborChains.length === 1;
});
}
/**
* Determine the furthest that a chain extends in each of the cardinal directions
*/
function findFurthestPointsOfChain(chain: PointState[]) {
return chain.reduce(
(directions, point) => {
if (point.y > directions.north) {
directions.north = point.y;
}
if (point.y < directions.south) {
directions.south = point.y;
}
if (point.x > directions.east) {
directions.east = point.x;
}
if (point.x < directions.west) {
directions.west = point.x;
}
return directions;
},
{
north: chain[0].y,
east: chain[0].x,
south: chain[0].y,
west: chain[0].x,
},
);
}
/**
* Removes an element from an array at the given index
*/
function removePointAtIndex(arr: PointState[][], index: number) {
const newArr = [...arr];
newArr.splice(index, 1);
return newArr;
}
/**
* Get all player chains that are adjacent / touching the current chain
*/
export function getAllNeighboringChains(boardState: BoardState, chain: PointState[], allChains: PointState[][]) {
const playerNeighbors = getPlayerNeighbors(boardState, chain);
const neighboringChains = playerNeighbors.reduce(
(neighborChains, neighbor) =>
neighborChains.add(allChains.find((chain) => chain[0].chain === neighbor.chain) || []),
new Set<PointState[]>(),
);
return [...neighboringChains];
}
/**
* Gets all points that have player pieces adjacent to the given point
*/
export function getPlayerNeighbors(boardState: BoardState, chain: PointState[]) {
return getAllNeighbors(boardState, chain).filter((neighbor) => neighbor && neighbor.player !== playerColors.empty);
}
/**
* Gets all points adjacent to the given point
*/
export function getAllNeighbors(boardState: BoardState, chain: PointState[]) {
const allNeighbors = chain.reduce((chainNeighbors: Set<PointState>, point: PointState) => {
getArrayFromNeighbor(findNeighbors(boardState, point.x, point.y))
.filter((neighborPoint) => !isPointInChain(neighborPoint, chain))
.forEach((neighborPoint) => chainNeighbors.add(neighborPoint));
return chainNeighbors;
}, new Set<PointState>());
return [...allNeighbors];
}
/**
* Determines if chain has a point that matches the given coordinates
*/
export function isPointInChain(point: PointState, chain: PointState[]) {
return !!chain.find((chainPoint) => chainPoint.x === point.x && chainPoint.y === point.y);
}
/**
* Looks through the board history to see if the current state is identical to any previous state
* Capped at 5 for calculation speed, because loops of size 6 are essentially impossible
*/
function checkIfBoardStateIsRepeated(boardState: BoardState) {
const currentBoard = boardState.board;
return boardState.history.slice(-5).find((state) => {
for (let x = 0; x < state.length; x++) {
for (let y = 0; y < state[x].length; y++) {
if (currentBoard[x]?.[y]?.player && currentBoard[x]?.[y]?.player !== state[x]?.[y]?.player) {
return false;
}
}
}
return true;
});
}
/**
* Finds all groups of connected pieces, or empty space groups
*/
export function getAllChains(boardState: BoardState): PointState[][] {
const chains: { [s: string]: PointState[] } = {};
for (let x = 0; x < boardState.board.length; x++) {
for (let y = 0; y < boardState.board[x].length; y++) {
const point = boardState.board[x]?.[y];
// If the current chain is already analyzed, skip it
if (!point || point.chain === "") {
continue;
}
chains[point.chain] = chains[point.chain] || [];
chains[point.chain].push(point);
}
}
return Object.keys(chains).map((key) => chains[key]);
}
/**
* Find any group of stones with no liberties (who therefore are to be removed from the board)
*/
export function findAllCapturedChains(chainList: PointState[][], playerWhoMoved: PlayerColor) {
const opposingPlayer = playerWhoMoved === playerColors.white ? playerColors.black : playerColors.white;
const enemyChainsToCapture = findCapturedChainOfColor(chainList, opposingPlayer);
if (enemyChainsToCapture) {
return enemyChainsToCapture;
}
const friendlyChainsToCapture = findCapturedChainOfColor(chainList, playerWhoMoved);
if (friendlyChainsToCapture) {
return friendlyChainsToCapture;
}
}
function findCapturedChainOfColor(chainList: PointState[][], playerColor: PlayerColor) {
return chainList.filter((chain) => chain?.[0].player === playerColor && chain?.[0].liberties?.length === 0);
}
/**
* Find all empty points adjacent to any piece in a given chain
*/
export function findLibertiesForChain(boardState: BoardState, chain: PointState[]): PointState[] {
return getAllNeighbors(boardState, chain).filter((neighbor) => neighbor && neighbor.player === playerColors.empty);
}
/**
* Find all empty points adjacent to any piece in the chain that a given point belongs to
*/
export function findChainLibertiesForPoint(boardState: BoardState, x: number, y: number): PointState[] {
const chain = findAdjacentPointsInChain(boardState, x, y);
return findLibertiesForChain(boardState, chain);
}
/**
* Returns an object that includes which of the cardinal neighbors are empty
* (adjacent 'liberties' of the current piece )
*/
export function findAdjacentLibertiesForPoint(boardState: BoardState, x: number, y: number): Neighbor {
const neighbors = findNeighbors(boardState, x, y);
const hasNorthLiberty = neighbors.north && neighbors.north.player === playerColors.empty;
const hasEastLiberty = neighbors.east && neighbors.east.player === playerColors.empty;
const hasSouthLiberty = neighbors.south && neighbors.south.player === playerColors.empty;
const hasWestLiberty = neighbors.west && neighbors.west.player === playerColors.empty;
return {
north: hasNorthLiberty ? neighbors.north : null,
east: hasEastLiberty ? neighbors.east : null,
south: hasSouthLiberty ? neighbors.south : null,
west: hasWestLiberty ? neighbors.west : null,
};
}
/**
* Returns an object that includes which of the cardinal neighbors are either empty or contain the
* current player's pieces. Used for making the connection map on the board
*/
export function findAdjacentLibertiesAndAlliesForPoint(
boardState: BoardState,
x: number,
y: number,
_player?: PlayerColor,
): Neighbor {
const currentPoint = boardState.board[x]?.[y];
const player =
_player || (!currentPoint || currentPoint.player === playerColors.empty ? undefined : currentPoint.player);
const adjacentLiberties = findAdjacentLibertiesForPoint(boardState, x, y);
const neighbors = findNeighbors(boardState, x, y);
return {
north: adjacentLiberties.north || neighbors.north?.player === player ? neighbors.north : null,
east: adjacentLiberties.east || neighbors.east?.player === player ? neighbors.east : null,
south: adjacentLiberties.south || neighbors.south?.player === player ? neighbors.south : null,
west: adjacentLiberties.west || neighbors.west?.player === player ? neighbors.west : null,
};
}
/**
* Retrieves a simplified version of the board state. "X" represents black pieces, "O" white, and "." empty points.
*
* For example, a 5x5 board might look like this:
* ```
* [
* "XX.O.",
* "X..OO",
* ".XO..",
* "XXO..",
* ".XOO.",
* ]
* ```
*
* Each string represents a vertical column on the board, and each character in the string represents a point.
*
* Traditional notation for Go is e.g. "B,1" referring to second ("B") column, first rank. This is the equivalent of index [1][0].
*
* Note that the [0][0] point is shown on the bottom-left on the visual board (as is traditional), and each
* string represents a vertical column on the board. In other words, the printed example above can be understood to
* be rotated 90 degrees clockwise compared to the board UI as shown in the IPvGO game.
*
*/
export function getSimplifiedBoardState(board: Board): string[] {
return board.map((column) =>
column.reduce((str, point) => {
if (!point) {
return str + "#";
}
if (point.player === playerColors.black) {
return str + "X";
}
if (point.player === playerColors.white) {
return str + "O";
}
return str + ".";
}, ""),
);
}
export function getBoardFromSimplifiedBoardState(
boardStrings: string[],
ai = opponents.Daedalus,
lastPlayer = playerColors.black,
) {
const newBoardState = getNewBoardState(boardStrings[0].length, ai);
newBoardState.previousPlayer = lastPlayer;
for (let x = 0; x < boardStrings[0].length; x++) {
for (let y = 0; y < boardStrings[0].length; y++) {
const boardStringPoint = boardStrings[x]?.[y];
const newBoardPoint = newBoardState.board[x]?.[y];
if (boardStringPoint === "#") {
newBoardState.board[x][y] = null;
}
if (boardStringPoint === "X" && newBoardPoint?.player) {
newBoardPoint.player = playerColors.black;
}
if (boardStringPoint === "O" && newBoardPoint?.player) {
newBoardPoint.player = playerColors.white;
}
}
}
return updateCaptures(newBoardState, lastPlayer);
}
@@ -0,0 +1,98 @@
import { BoardState, playerColors, type PointState } from "../boardState/goConstants";
import {
getAllChains,
getAllEyes,
getAllNeighboringChains,
getAllPotentialEyes,
getAllValidMoves,
} from "./boardAnalysis";
import { contains, isNotNull } from "../boardState/boardState";
/**
* Any empty space fully encircled by the opponent is not worth playing in, unless one of its borders explicitly has a weakness
*
* Specifically, ignore any empty space encircled by the opponent, unless one of the chains that is on the exterior:
* * does not have too many more liberties
* * has been fully encircled on the outside by the current player
* * Only has liberties remaining inside the abovementioned empty space
*
* In which case, only the liberties of that one weak chain are worth considering. Other parts of that fully-encircled
* enemy space, and other similar spaces, should be ignored, otherwise the game drags on too long
*/
export function findDisputedTerritory(boardState: BoardState, player: playerColors, excludeFriendlyEyes?: boolean) {
let validMoves = getAllValidMoves(boardState, player);
if (excludeFriendlyEyes) {
const friendlyEyes = getAllEyes(boardState, player)
.filter((eye) => eye.length >= 2)
.flat()
.flat();
validMoves = validMoves.filter((point) => !contains(friendlyEyes, point));
}
const opponent = player === playerColors.white ? playerColors.black : playerColors.white;
const chains = getAllChains(boardState);
const emptySpacesToAnalyze = getAllPotentialEyes(boardState, chains, opponent);
const nodesInsideEyeSpacesToAnalyze = emptySpacesToAnalyze.map((space) => space.chain).flat();
const playableNodesInsideOfEnemySpace = emptySpacesToAnalyze.reduce((playableNodes: PointState[], space) => {
// Look for any opponent chains on the border of the empty space, to see if it has a weakness
const attackableLiberties = space.neighbors
.map((neighborChain) => {
const liberties = neighborChain[0].liberties ?? [];
// Ignore border chains with too many liberties, they can't effectively be attacked
if (liberties.length > 4) {
return [];
}
// Get all opponent chains that make up the border of the opponent-controlled space
const neighborChains = getAllNeighboringChains(boardState, neighborChain, chains);
// Ignore border chains that do not touch the current player's pieces somewhere, as they are likely fully interior
// to the empty space in question, or only share a border with the edge of the board and the space, or are not yet
// surrounded on the exterior and ready to be attacked within
if (!neighborChains.find((chain) => chain?.[0]?.player === player)) {
return [];
}
const libertiesInsideOfSpaceToAnalyze = liberties
.filter(isNotNull)
.filter((point) => contains(space.chain, point));
// If the chain has any liberties outside the empty space being analyzed, it is not yet fully surrounded,
// and should not be attacked yet
if (libertiesInsideOfSpaceToAnalyze.length !== liberties.length) {
return [];
}
// If the enemy chain is fully surrounded on the outside of the space by the current player, then its liberties
// inside the empty space is worth considering for an attack
return libertiesInsideOfSpaceToAnalyze;
})
.flat();
return [...playableNodes, ...attackableLiberties];
}, []);
// Return only valid moves that are not inside enemy surrounded empty spaces, or ones that are explicitly next to an enemy chain that can be attacked
return validMoves.filter(
(move) => !contains(nodesInsideEyeSpacesToAnalyze, move) || contains(playableNodesInsideOfEnemySpace, move),
);
}
/**
If a group of stones has more than one empty holes that it completely surrounds, it cannot be captured, because white can
only play one stone at a time.
Thus, the empty space of those holes is firmly claimed by the player surrounding them, and it can be ignored as a play area
Once all points are either stones or claimed territory in this way, the game is over
Note that this does not detect mutual eyes formed by two chains making an eye together, or eyes via seki, or some other edge cases.
*/
export function findClaimedTerritory(boardState: BoardState) {
const whiteClaimedTerritory = getAllEyes(boardState, playerColors.white).filter(
(eyesForChainN) => eyesForChainN.length >= 2,
);
const blackClaimedTerritory = getAllEyes(boardState, playerColors.black).filter(
(eyesForChainN) => eyesForChainN.length >= 2,
);
return [...blackClaimedTerritory, ...whiteClaimedTerritory].flat().flat();
}
+96
View File
@@ -0,0 +1,96 @@
/** @param {NS} ns */
export async function main(ns) {
let result;
do {
const board = ns.go.getBoardState();
const validMoves = ns.go.analysis.getValidMoves();
const [growX, growY] = getGrowMove(board, validMoves);
const [randX, randY] = getRandomMove(board, validMoves);
// Try to pick a grow move, otherwise choose a random move
const x = growX ?? randX;
const y = growY ?? randY;
if (x === undefined) {
// Pass turn if no moves are found
result = await ns.go.passTurn();
} else {
// Play the selected move
result = await ns.go.makeMove(x, y);
}
await ns.sleep(100);
} while (result?.type !== "gameOver" && result?.type !== "pass");
// After the opponent passes, end the game by passing as well
await ns.go.passTurn();
}
/**
* Choose one of the empty points on the board at random to play
*/
const getRandomMove = (board, validMoves) => {
const moveOptions = [];
const size = board[0].length;
// Look through all the points on the board
for (let x = 0; x < size; x++) {
for (let y = 0; y < size; y++) {
// Make sure the point is a valid move
const isValidMove = validMoves[x][y];
// Leave some spaces to make it harder to capture our pieces
const isNotReservedSpace = x % 2 || y % 2;
if (isValidMove && isNotReservedSpace) {
moveOptions.push([x, y]);
}
}
}
// Choose one of the found moves at random
const randomIndex = Math.floor(Math.random() * moveOptions.length);
return moveOptions[randomIndex] ?? [];
};
/**
* Choose a point connected to a friendly stone to play
*/
const getGrowMove = (board, validMoves) => {
const moveOptions = [];
const size = board[0].length;
// Look through all the points on the board
for (let x = 0; x < size; x++) {
for (let y = 0; y < size; y++) {
// make sure the move is valid
const isValidMove = validMoves[x][y];
// Leave some open spaces to make it harder to capture our pieces
const isNotReservedSpace = x % 2 || y % 2;
// Make sure we are connected to a friendly piece
const neighbors = getNeighbors(board, x, y);
const hasFriendlyNeighbor = neighbors.includes("X");
if (isValidMove && isNotReservedSpace && hasFriendlyNeighbor) {
moveOptions.push([x, y]);
}
}
}
// Choose one of the found moves at random
const randomIndex = Math.floor(Math.random() * moveOptions.length);
return moveOptions[randomIndex] ?? [];
};
/**
* Find all adjacent points in the four connected directions
*/
const getNeighbors = (board, x, y) => {
const north = board[x + 1]?.[y];
const east = board[x][y + 1];
const south = board[x - 1]?.[y];
const west = board[x]?.[y - 1];
return [north, east, south, west];
};
+811
View File
@@ -0,0 +1,811 @@
import {
BoardState,
EyeMove,
Move,
MoveOptions,
opponentDetails,
opponents,
PlayerColor,
playerColors,
playTypes,
PointState,
} from "../boardState/goConstants";
import { findNeighbors, floor, isDefined, isNotNull, passTurn } from "../boardState/boardState";
import {
evaluateIfMoveIsValid,
evaluateMoveResult,
findEffectiveLibertiesOfNewMove,
findEnemyNeighborChainWithFewestLiberties,
findMinLibertyCountOfAdjacentChains,
getAllChains,
getAllEyes,
getAllEyesByChainId,
getAllNeighboringChains,
getAllValidMoves,
} from "./boardAnalysis";
import { findDisputedTerritory } from "./controlledTerritory";
import { findAnyMatchedPatterns } from "./patternMatching";
import { WHRNG } from "../../Casino/RNG";
import { Player } from "@player";
import { AugmentationName } from "@enums";
/*
Basic GO AIs, each with some personality and weaknesses
The AIs are aware of chains of connected pieces, their liberties, and their eyes.
They know how to lok for moves that capture or threaten capture, moves that create eyes, and moves that take
away liberties from their opponent, as well as some pattern matching on strong move ideas.
They do not know about larger jump moves, nor about frameworks on the board. Also, they each have a tendancy to
over-focus on a different type of move, giving each AI a different playstyle and weakness to exploit.
*/
/**
* Finds an array of potential moves based on the current board state, then chooses one
* based on the given opponent's personality and preferences. If no preference is given by the AI,
* will choose one from the reasonable moves at random.
*
* @returns a promise that will resolve with a move (or pass) from the designated AI opponent.
*/
export async function getMove(boardState: BoardState, player: PlayerColor, opponent: opponents, rngOverride?: number) {
await sleep(200);
const rng = new WHRNG(rngOverride || Player.totalPlaytime);
const smart = isSmart(opponent, rng.random());
const moves = await getMoveOptions(boardState, player, rng.random(), smart);
const priorityMove = getFactionMove(moves, opponent, rng.random());
if (priorityMove) {
return {
type: playTypes.move,
x: priorityMove.x,
y: priorityMove.y,
};
}
// If no priority move is chosen, pick one of the reasonable moves
const moveOptions = [
moves.growth?.point,
moves.surround?.point,
moves.defend?.point,
moves.expansion?.point,
moves.pattern,
moves.eyeMove?.point,
moves.eyeBlock?.point,
]
.filter(isNotNull)
.filter(isDefined)
.filter((point) => evaluateIfMoveIsValid(boardState, point.x, point.y, player, false));
const chosenMove = moveOptions[floor(rng.random() * moveOptions.length)];
if (chosenMove) {
await sleep(200);
console.debug(`Non-priority move chosen: ${chosenMove.x} ${chosenMove.y}`);
return {
type: playTypes.move,
x: chosenMove.x,
y: chosenMove.y,
};
} else {
console.debug("No valid moves found");
return handleNoMoveFound(boardState, player);
}
}
/**
* Detects if the AI is merely passing their turn, or if the game should end.
*
* Ends the game if the player passed on the previous turn before the AI passes,
* or if the player will be forced to pass their next turn after the AI passes.
*/
function handleNoMoveFound(boardState: BoardState, player: playerColors) {
passTurn(boardState, player);
const opposingPlayer = player === playerColors.white ? playerColors.black : playerColors.white;
const remainingTerritory = getAllValidMoves(boardState, opposingPlayer).length;
if (remainingTerritory > 0 && boardState.passCount < 2) {
return {
type: playTypes.pass,
x: -1,
y: -1,
};
} else {
return {
type: playTypes.gameOver,
x: -1,
y: -1,
};
}
}
/**
* Given a group of move options, chooses one based on the given opponent's personality (if any fit their priorities)
*/
function getFactionMove(moves: MoveOptions, faction: opponents, rng: number): PointState | null {
if (faction === opponents.Netburners) {
return getNetburnersPriorityMove(moves, rng);
}
if (faction === opponents.SlumSnakes) {
return getSlumSnakesPriorityMove(moves, rng);
}
if (faction === opponents.TheBlackHand) {
return getBlackHandPriorityMove(moves, rng);
}
if (faction === opponents.Tetrads) {
return getTetradPriorityMove(moves, rng);
}
if (faction === opponents.Daedalus) {
return getDaedalusPriorityMove(moves, rng);
}
return getIlluminatiPriorityMove(moves, rng);
}
/**
* Determines if certain failsafes and mistake avoidance are enabled for the given move
*/
function isSmart(faction: opponents, rng: number) {
if (faction === opponents.Netburners) {
return false;
}
if (faction === opponents.SlumSnakes) {
return rng < 0.3;
}
if (faction === opponents.TheBlackHand) {
return rng < 0.8;
}
return true;
}
/**
* Netburners mostly just put random points around the board, but occasionally have a smart move
*/
function getNetburnersPriorityMove(moves: MoveOptions, rng: number): PointState | null {
if (rng < 0.2) {
return getIlluminatiPriorityMove(moves, rng);
} else if (rng < 0.4 && moves.expansion) {
return moves.expansion.point;
} else if (rng < 0.6 && moves.growth) {
return moves.growth.point;
} else if (rng < 0.75) {
return moves.random;
}
return null;
}
/**
* Slum snakes prioritize defending their pieces and building chains that snake around as much of the bord as possible.
*/
function getSlumSnakesPriorityMove(moves: MoveOptions, rng: number): PointState | null {
if (moves.defendCapture) {
console.debug("defend capture: defend move chosen");
return moves.defendCapture.point;
}
if (rng < 0.2) {
return getIlluminatiPriorityMove(moves, rng);
} else if (rng < 0.6 && moves.growth) {
return moves.growth.point;
} else if (rng < 0.65) {
return moves.random;
}
return null;
}
/**
* Black hand just wants to smOrk. They always capture or smother the opponent if possible.
*/
function getBlackHandPriorityMove(moves: MoveOptions, rng: number): PointState | null {
if (moves.capture) {
console.debug("capture: capture move chosen");
return moves.capture.point;
}
if (moves.surround && moves.surround.point && (moves.surround?.newLibertyCount ?? 999) <= 1) {
console.debug("surround move chosen");
return moves.surround.point;
}
if (moves.defendCapture) {
console.debug("defend capture: defend move chosen");
return moves.defendCapture.point;
}
if (moves.surround && moves.surround.point && (moves.surround?.newLibertyCount ?? 999) <= 2) {
console.debug("surround move chosen");
return moves.surround.point;
}
if (rng < 0.3) {
return getIlluminatiPriorityMove(moves, rng);
} else if (rng < 0.75 && moves.surround) {
return moves.surround.point;
} else if (rng < 0.8) {
return moves.random;
}
return null;
}
/**
* Tetrads really like to be up close and personal, cutting and circling their opponent
*/
function getTetradPriorityMove(moves: MoveOptions, rng: number) {
if (moves.capture) {
console.debug("capture: capture move chosen");
return moves.capture.point;
}
if (moves.defendCapture) {
console.debug("defend capture: defend move chosen");
return moves.defendCapture.point;
}
if (moves.pattern) {
console.debug("pattern match move chosen");
return moves.pattern;
}
if (moves.surround && moves.surround.point && (moves.surround?.newLibertyCount ?? 9) <= 1) {
console.debug("surround move chosen");
return moves.surround.point;
}
if (rng < 0.4) {
return getIlluminatiPriorityMove(moves, rng);
}
return null;
}
/**
* Daedalus almost always picks the Illuminati move, but very occasionally gets distracted.
*/
function getDaedalusPriorityMove(moves: MoveOptions, rng: number): PointState | null {
if (rng < 0.9) {
return getIlluminatiPriorityMove(moves, rng);
}
return null;
}
/**
* First prioritizes capturing of opponent pieces.
* Then, preventing capture of their own pieces.
* Then, creating "eyes" to solidify their control over the board
* Then, finding opportunities to capture on their next move
* Then, blocking the opponent's attempts to create eyes
* Finally, will match any of the predefined local patterns indicating a strong move.
*/
function getIlluminatiPriorityMove(moves: MoveOptions, rng: number): PointState | null {
if (moves.capture) {
console.debug("capture: capture move chosen");
return moves.capture.point;
}
if (moves.defendCapture) {
console.debug("defend capture: defend move chosen");
return moves.defendCapture.point;
}
if (moves.eyeMove) {
console.debug("Create eye move chosen");
return moves.eyeMove.point;
}
if (moves.surround && moves.surround.point && (moves.surround?.newLibertyCount ?? 9) <= 1) {
console.debug("surround move chosen");
return moves.surround.point;
}
if (moves.eyeBlock) {
console.debug("Block eye move chosen");
return moves.eyeBlock.point;
}
if (moves.corner) {
console.debug("Corner move chosen");
return moves.corner;
}
const hasMoves = [moves.eyeMove, moves.eyeBlock, moves.growth, moves.defend, moves.surround].filter((m) => m).length;
const usePattern = rng > 0.25 || !hasMoves;
if (moves.pattern && usePattern) {
console.debug("pattern match move chosen");
return moves.pattern;
}
if (rng > 0.4 && moves.jump) {
console.debug("Jump move chosen");
return moves.jump.point;
}
if (rng < 0.6 && moves.surround && moves.surround.point && (moves.surround?.newLibertyCount ?? 9) <= 2) {
console.debug("surround move chosen");
return moves.surround.point;
}
return null;
}
/**
* Get a move that places a piece to influence (and later control) a corner
*/
function getCornerMove(boardState: BoardState) {
const boardEdge = boardState.board[0].length - 1;
const cornerMax = boardEdge - 2;
if (isCornerAvailableForMove(boardState, cornerMax, cornerMax, boardEdge, boardEdge)) {
return boardState.board[cornerMax][cornerMax];
}
if (isCornerAvailableForMove(boardState, 0, cornerMax, cornerMax, boardEdge)) {
return boardState.board[2][cornerMax];
}
if (isCornerAvailableForMove(boardState, 0, 0, 2, 2)) {
return boardState.board[2][2];
}
if (isCornerAvailableForMove(boardState, cornerMax, 0, boardEdge, 2)) {
return boardState.board[cornerMax][2];
}
return null;
}
/**
* Find all non-offline nodes in a given area
*/
function findLiveNodesInArea(boardState: BoardState, x1: number, y1: number, x2: number, y2: number) {
const foundPoints: PointState[] = [];
boardState.board.forEach((column) =>
column.forEach(
(point) => point && point.x >= x1 && point.x <= x2 && point.y >= y1 && point.y <= y2 && foundPoints.push(point),
),
);
return foundPoints;
}
/**
* Determine if a corner is largely intact and currently empty, and thus a good target for corner takeover moves
*/
function isCornerAvailableForMove(boardState: BoardState, x1: number, y1: number, x2: number, y2: number) {
const foundPoints = findLiveNodesInArea(boardState, x1, y1, x2, y2);
const foundPieces = foundPoints.filter((point) => point.player !== playerColors.empty);
return foundPoints.length >= 7 ? foundPieces.length === 0 : false;
}
/**
* Select a move from the list of open-area moves
*/
function getExpansionMove(
boardState: BoardState,
player: PlayerColor,
availableSpaces: PointState[],
rng: number,
moveArray?: Move[],
) {
const moveOptions = moveArray ?? getExpansionMoveArray(boardState, player, availableSpaces);
const randomIndex = floor(rng * moveOptions.length);
return moveOptions[randomIndex];
}
/**
* Get a move in open space that is nearby a friendly piece
*/
function getJumpMove(
boardState: BoardState,
player: PlayerColor,
availableSpaces: PointState[],
rng: number,
moveArray?: Move[],
) {
const board = boardState.board;
const moveOptions = (moveArray ?? getExpansionMoveArray(boardState, player, availableSpaces)).filter(({ point }) =>
[
board[point.x]?.[point.y + 2],
board[point.x + 2]?.[point.y],
board[point.x]?.[point.y - 2],
board[point.x - 2]?.[point.y],
].some((point) => point?.player === player),
);
const randomIndex = floor(rng * moveOptions.length);
return moveOptions[randomIndex];
}
/**
* Finds a move in an open area to expand influence and later build on
*/
export function getExpansionMoveArray(
boardState: BoardState,
player: PlayerColor,
availableSpaces: PointState[],
): Move[] {
// Look for any empty spaces fully surrounded by empty spaces to expand into
const emptySpaces = availableSpaces.filter((space) => {
const neighbors = findNeighbors(boardState, space.x, space.y);
return (
[neighbors.north, neighbors.east, neighbors.south, neighbors.west].filter(
(point) => point && point.player === playerColors.empty,
).length === 4
);
});
// Once no such empty areas exist anymore, instead expand into any disputed territory
// to gain a few more points in endgame
const disputedSpaces = emptySpaces.length ? [] : getDisputedTerritoryMoves(boardState, player, availableSpaces, 1);
const moveOptions = [...emptySpaces, ...disputedSpaces];
return moveOptions.map((point) => {
return {
point: point,
newLibertyCount: -1,
oldLibertyCount: -1,
};
});
}
function getDisputedTerritoryMoves(
boardState: BoardState,
player: PlayerColor,
availableSpaces: PointState[],
maxChainSize = 99,
) {
const chains = getAllChains(boardState).filter((chain) => chain.length <= maxChainSize);
return availableSpaces.filter((space) => {
const chain = chains.find((chain) => chain[0].chain === space.chain) ?? [];
const playerNeighbors = getAllNeighboringChains(boardState, chain, chains);
const hasWhitePieceNeighbor = playerNeighbors.find(
(neighborChain) => neighborChain[0]?.player === playerColors.white,
);
const hasBlackPieceNeighbor = playerNeighbors.find(
(neighborChain) => neighborChain[0]?.player === playerColors.black,
);
return hasWhitePieceNeighbor && hasBlackPieceNeighbor;
});
}
/**
* Finds all moves that increases the liberties of the player's pieces, making them harder to capture and occupy more space on the board.
*/
async function getLibertyGrowthMoves(boardState: BoardState, player: PlayerColor, availableSpaces: PointState[]) {
const friendlyChains = getAllChains(boardState).filter((chain) => chain[0].player === player);
if (!friendlyChains.length) {
return [];
}
// Get all liberties of friendly chains as potential growth move options
const liberties = friendlyChains
.map((chain) =>
chain[0].liberties?.filter(isNotNull).map((liberty) => ({
libertyPoint: liberty,
oldLibertyCount: chain[0].liberties?.length,
})),
)
.flat()
.filter(isNotNull)
.filter(isDefined)
.filter((liberty) =>
availableSpaces.find((point) => liberty.libertyPoint.x === point.x && liberty.libertyPoint.y === point.y),
);
// Find a liberty where playing a piece increases the liberty of the chain (aka expands or defends the chain)
return liberties
.map((liberty) => {
const move = liberty.libertyPoint;
const newLibertyCount = findEffectiveLibertiesOfNewMove(boardState, move.x, move.y, player).length;
// Get the smallest liberty count of connected chains to represent the old state
const oldLibertyCount = findMinLibertyCountOfAdjacentChains(boardState, move.x, move.y, player);
return {
point: move,
oldLibertyCount: oldLibertyCount,
newLibertyCount: newLibertyCount,
};
})
.filter((move) => move.newLibertyCount > 1 && move.newLibertyCount >= move.oldLibertyCount);
}
/**
* Find a move that increases the player's liberties by the maximum amount
*/
async function getGrowthMove(
initialState: BoardState,
player: PlayerColor,
availableSpaces: PointState[],
rng: number,
) {
const growthMoves = await getLibertyGrowthMoves(initialState, player, availableSpaces);
const maxLibertyCount = Math.max(...growthMoves.map((l) => l.newLibertyCount - l.oldLibertyCount));
const moveCandidates = growthMoves.filter((l) => l.newLibertyCount - l.oldLibertyCount === maxLibertyCount);
return moveCandidates[floor(rng * moveCandidates.length)];
}
/**
* Find a move that specifically increases a chain's liberties from 1 to more than 1, preventing capture
*/
async function getDefendMove(initialState: BoardState, player: PlayerColor, availableSpaces: PointState[]) {
const growthMoves = await getLibertyGrowthMoves(initialState, player, availableSpaces);
const libertyIncreases =
growthMoves?.filter((move) => move.oldLibertyCount <= 1 && move.newLibertyCount > move.oldLibertyCount) ?? [];
const maxLibertyCount = Math.max(...libertyIncreases.map((l) => l.newLibertyCount - l.oldLibertyCount));
if (maxLibertyCount < 1) {
return null;
}
const moveCandidates = libertyIncreases.filter((l) => l.newLibertyCount - l.oldLibertyCount === maxLibertyCount);
return moveCandidates[floor(Math.random() * moveCandidates.length)];
}
/**
* Find a move that reduces the opponent's liberties as much as possible,
* capturing (or making it easier to capture) their pieces
*/
async function getSurroundMove(
boardState: BoardState,
player: PlayerColor,
availableSpaces: PointState[],
smart = true,
) {
const opposingPlayer = player === playerColors.black ? playerColors.white : playerColors.black;
const enemyChains = getAllChains(boardState).filter((chain) => chain[0].player === opposingPlayer);
if (!enemyChains.length || !availableSpaces.length) {
return null;
}
const enemyLiberties = enemyChains
.map((chain) => chain[0].liberties)
.flat()
.filter((liberty) => availableSpaces.find((point) => liberty?.x === point.x && liberty?.y === point.y))
.filter(isNotNull);
const captureMoves: Move[] = [];
const atariMoves: Move[] = [];
const surroundMoves: Move[] = [];
enemyLiberties.forEach((move) => {
const newLibertyCount = findEffectiveLibertiesOfNewMove(boardState, move.x, move.y, player).length;
const weakestEnemyChain = findEnemyNeighborChainWithFewestLiberties(
boardState,
move.x,
move.y,
player === playerColors.black ? playerColors.white : playerColors.black,
);
const weakestEnemyChainLength = weakestEnemyChain?.length ?? 99;
const enemyChainLibertyCount = weakestEnemyChain?.[0]?.liberties?.length ?? 99;
const enemyLibertyGroups = [
...(weakestEnemyChain?.[0]?.liberties ?? []).reduce(
(chainIDs, point) => chainIDs.add(point?.chain ?? ""),
new Set<string>(),
),
];
// Do not suggest moves that do not capture anything and let your opponent immediately capture
if (newLibertyCount <= 2 && enemyChainLibertyCount > 2) {
return;
}
// If a neighboring enemy chain has only one liberty, the current move suggestion will capture
if (enemyChainLibertyCount <= 1) {
captureMoves.push({
point: move,
oldLibertyCount: enemyChainLibertyCount,
newLibertyCount: enemyChainLibertyCount - 1,
});
}
// If the move puts the enemy chain in threat of capture, it forces the opponent to respond.
// Only do this if your piece cannot be captured, or if the enemy group is surrounded and vulnerable to losing its only interior space
else if (
enemyChainLibertyCount === 2 &&
(newLibertyCount >= 2 || (enemyLibertyGroups.length === 1 && weakestEnemyChainLength > 3) || !smart)
) {
atariMoves.push({
point: move,
oldLibertyCount: enemyChainLibertyCount,
newLibertyCount: enemyChainLibertyCount - 1,
});
}
// If the move will not immediately get re-captured, and limit's the opponent's liberties
else if (newLibertyCount >= 2) {
surroundMoves.push({
point: move,
oldLibertyCount: enemyChainLibertyCount,
newLibertyCount: enemyChainLibertyCount - 1,
});
}
});
return [...captureMoves, ...atariMoves, ...surroundMoves][0];
}
/**
* Finds all moves that would create an eye for the given player.
*
* An "eye" is empty point(s) completely surrounded by a single player's connected pieces.
* If a chain has multiple eyes, it cannot be captured by the opponent (since they can only fill one eye at a time,
* and suiciding your own pieces is not legal unless it captures the opponents' first)
*/
function getEyeCreationMoves(
boardState: BoardState,
player: PlayerColor,
availableSpaces: PointState[],
maxLiberties = 99,
) {
const allEyes = getAllEyesByChainId(boardState, player);
const currentEyes = getAllEyes(boardState, player, allEyes);
const currentLivingGroupIDs = Object.keys(allEyes).filter((chainId) => allEyes[chainId].length >= 2);
const currentLivingGroupsCount = currentLivingGroupIDs.length;
const currentEyeCount = currentEyes.filter((eye) => eye.length).length;
const chains = getAllChains(boardState);
const friendlyLiberties = chains
.filter((chain) => chain[0].player === player)
.filter((chain) => chain.length > 1)
.filter((chain) => chain[0].liberties && chain[0].liberties?.length <= maxLiberties)
.filter((chain) => !currentLivingGroupIDs.includes(chain[0].chain))
.map((chain) => chain[0].liberties)
.flat()
.filter(isNotNull)
.filter((point) =>
availableSpaces.find((availablePoint) => availablePoint.x === point.x && availablePoint.y === point.y),
)
.filter((point: PointState) => {
console.warn("eye check ", point.x, point.y);
const neighbors = findNeighbors(boardState, point.x, point.y);
const neighborhood = [neighbors.north, neighbors.east, neighbors.south, neighbors.west];
return (
neighborhood.filter((point) => !point || point?.player === player).length >= 2 &&
neighborhood.some((point) => point?.player === playerColors.empty)
);
});
const eyeCreationMoves = friendlyLiberties.reduce((moveOptions: EyeMove[], point: PointState) => {
const evaluationBoard = evaluateMoveResult(boardState, point.x, point.y, player);
const newEyes = getAllEyes(evaluationBoard, player);
const newLivingGroupsCount = newEyes.filter((eye) => eye.length >= 2).length;
const newEyeCount = newEyes.filter((eye) => eye.length).length;
if (
newLivingGroupsCount > currentLivingGroupsCount ||
(newEyeCount > currentEyeCount && newLivingGroupsCount === currentLivingGroupsCount)
) {
moveOptions.push({
point: point,
createsLife: newLivingGroupsCount > currentLivingGroupsCount,
});
}
return moveOptions;
}, []);
return eyeCreationMoves.sort((moveA, moveB) => +moveB.createsLife - +moveA.createsLife);
}
function getEyeCreationMove(boardState: BoardState, player: PlayerColor, availableSpaces: PointState[]) {
return getEyeCreationMoves(boardState, player, availableSpaces)[0];
}
/**
* If there is only one move that would create two eyes for the opponent, it should be blocked if possible
*/
function getEyeBlockingMove(boardState: BoardState, player: PlayerColor, availablePoints: PointState[]) {
const opposingPlayer = player === playerColors.white ? playerColors.black : playerColors.white;
const opponentEyeMoves = getEyeCreationMoves(boardState, opposingPlayer, availablePoints, 5);
const twoEyeMoves = opponentEyeMoves.filter((move) => move.createsLife);
const oneEyeMoves = opponentEyeMoves.filter((move) => !move.createsLife);
if (twoEyeMoves.length === 1) {
return twoEyeMoves[0];
}
if (!twoEyeMoves.length && oneEyeMoves.length === 1) {
return oneEyeMoves[0];
}
return null;
}
/**
* Gets a group of reasonable moves based on the current board state, to be passed to the factions' AI to decide on
*/
async function getMoveOptions(
boardState: BoardState,
player: PlayerColor,
rng: number,
smart = true,
): Promise<MoveOptions> {
const availableSpaces = findDisputedTerritory(boardState, player, smart);
const contestedPoints = getDisputedTerritoryMoves(boardState, player, availableSpaces);
const expansionMoves = getExpansionMoveArray(boardState, player, availableSpaces);
// If the player is passing, and all territory is surrounded by a single color: do not suggest moves that
// needlessly extend the game, unless they actually can change the score
const endGameAvailable = !contestedPoints.length && boardState.passCount;
const growthMove = endGameAvailable ? null : await getGrowthMove(boardState, player, availableSpaces, rng);
await sleep(80);
const expansionMove = await getExpansionMove(boardState, player, availableSpaces, rng, expansionMoves);
await sleep(80);
const jumpMove = await getJumpMove(boardState, player, availableSpaces, rng, expansionMoves);
await sleep(80);
const defendMove = await getDefendMove(boardState, player, availableSpaces);
await sleep(80);
const surroundMove = await getSurroundMove(boardState, player, availableSpaces, smart);
await sleep(80);
const eyeMove = endGameAvailable ? null : getEyeCreationMove(boardState, player, availableSpaces);
await sleep(80);
const eyeBlock = endGameAvailable ? null : getEyeBlockingMove(boardState, player, availableSpaces);
await sleep(80);
const cornerMove = getCornerMove(boardState);
const pattern = endGameAvailable
? null
: await findAnyMatchedPatterns(boardState, player, availableSpaces, smart, rng);
// Only offer a random move if there are some contested spaces on the board.
// (Random move should not be picked if the AI would otherwise pass turn.)
const random = contestedPoints.length ? availableSpaces[floor(rng * availableSpaces.length)] : null;
const captureMove = surroundMove && surroundMove?.newLibertyCount === 0 ? surroundMove : null;
const defendCaptureMove =
defendMove && defendMove.oldLibertyCount == 1 && defendMove?.newLibertyCount > 1 ? defendMove : null;
console.debug("---------------------");
console.debug("capture: ", captureMove?.point?.x, captureMove?.point?.y);
console.debug("defendCapture: ", defendCaptureMove?.point?.x, defendCaptureMove?.point?.y);
console.debug("eyeMove: ", eyeMove?.point?.x, eyeMove?.point?.y);
console.debug("eyeBlock: ", eyeBlock?.point?.x, eyeBlock?.point?.y);
console.debug("pattern: ", pattern?.x, pattern?.y);
console.debug("surround: ", surroundMove?.point?.x, surroundMove?.point?.y);
console.debug("defend: ", defendMove?.point?.x, defendMove?.point?.y);
console.debug("Growth: ", growthMove?.point?.x, growthMove?.point?.y);
console.debug("Expansion: ", expansionMove?.point?.x, expansionMove?.point?.y);
console.debug("Jump: ", jumpMove?.point?.x, jumpMove?.point?.y);
console.debug("Corner: ", cornerMove?.x, cornerMove?.y);
console.debug("Random: ", random?.x, random?.y);
return {
capture: captureMove,
defendCapture: defendCaptureMove,
eyeMove: eyeMove,
eyeBlock: eyeBlock,
pattern: pattern,
growth: growthMove,
expansion: expansionMove,
jump: jumpMove,
defend: defendMove,
surround: surroundMove,
corner: cornerMove,
random: random,
};
}
/**
* Gets the starting score for white.
*/
export function getKomi(opponent: opponents) {
return opponentDetails[opponent].komi;
}
/**
* Allows time to pass
*/
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function showWorldDemon() {
return Player.augmentations.some((a) => a.name === AugmentationName.TheRedPill) && Player.sourceFileLvl(1);
}
+198
View File
@@ -0,0 +1,198 @@
// Inspired by https://github.com/pasky/michi/blob/master/michi.py
import { BoardState, PlayerColor, playerColors, PointState } from "../boardState/goConstants";
import { sleep } from "./goAI";
import { findEffectiveLibertiesOfNewMove } from "./boardAnalysis";
import { floor } from "../boardState/boardState";
export const threeByThreePatterns = [
// 3x3 piece patterns; X,O are color pieces; x,o are any state except the opposite color piece;
// " " is off the edge of the board; "?" is any state (even off the board)
[
"XOX", // hane pattern - enclosing hane
"...",
"???",
],
[
"XO.", // hane pattern - non-cutting hane
"...",
"?.?",
],
[
"XO?", // hane pattern - magari
"X..",
"o.?",
],
[
".O.", // generic pattern - katatsuke or diagonal attachment; similar to magari
"X..",
"...",
],
[
"XO?", // cut1 pattern (kiri] - unprotected cut
"O.x",
"?x?",
],
[
"XO?", // cut1 pattern (kiri] - peeped cut
"O.X",
"???",
],
[
"?X?", // cut2 pattern (de]
"O.O",
"xxx",
],
[
"OX?", // cut keima
"x.O",
"???",
],
[
"X.?", // side pattern - chase
"O.?",
" ",
],
[
"OX?", // side pattern - block side cut
"X.O",
" ",
],
[
"?X?", // side pattern - block side connection
"o.O",
" ",
],
[
"?XO", // side pattern - sagari
"o.o",
" ",
],
[
"?OX", // side pattern - cut
"X.O",
" ",
],
];
/**
* Searches the board for any point that matches the expanded pattern set
*/
export async function findAnyMatchedPatterns(
boardState: BoardState,
player: PlayerColor,
availableSpaces: PointState[],
smart = true,
rng: number,
) {
const board = boardState.board;
const boardSize = board[0].length;
const patterns = expandAllThreeByThreePatterns();
const moves = [];
for (let x = 0; x < boardSize; x++) {
for (let y = 0; y < boardSize; y++) {
const neighborhood = getNeighborhood(boardState, x, y);
const matchedPattern = patterns.find((pattern) => checkMatch(neighborhood, pattern, player));
if (
matchedPattern &&
availableSpaces.find((availablePoint) => availablePoint.x === x && availablePoint.y === y) &&
(!smart || findEffectiveLibertiesOfNewMove(boardState, x, y, player).length > 1)
) {
moves.push(board[x][y]);
}
}
await sleep(10);
}
return moves[floor(rng * moves.length)] || null;
}
/**
Returns false if any point does not match the pattern, and true if it matches fully.
*/
function checkMatch(neighborhood: (PointState | null)[][], pattern: string[], player: PlayerColor) {
const patternArr = pattern.join("").split("");
const neighborhoodArray = neighborhood.flat();
return patternArr.every((str, index) => matches(str, neighborhoodArray[index], player));
}
/**
* Gets the 8 points adjacent and diagonally adjacent to the given point
*/
function getNeighborhood(boardState: BoardState, x: number, y: number) {
const board = boardState.board;
return [
[board[x - 1]?.[y - 1], board[x - 1]?.[y], board[x - 1]?.[y + 1]],
[board[x]?.[y - 1], board[x]?.[y], board[x]?.[y + 1]],
[board[x + 1]?.[y - 1], board[x + 1]?.[y], board[x + 1]?.[y + 1]],
];
}
/**
* @returns true if the given point matches the given string representation, false otherwise
*
* Capital X and O only match stones of that color
* lowercase x and o match stones of that color, or empty space, or the edge of the board
* a period "." only matches empty nodes
* A space " " only matches the edge of the board
* question mark "?" matches anything
*/
function matches(stringPoint: string, point: PointState | null, player: PlayerColor) {
const opponent = player === playerColors.white ? playerColors.black : playerColors.white;
switch (stringPoint) {
case "X": {
return point?.player === player;
}
case "O": {
return point?.player === opponent;
}
case "x": {
return point?.player !== opponent;
}
case "o": {
return point?.player !== player;
}
case ".": {
return point?.player === playerColors.empty;
}
case " ": {
return point === null;
}
case "?": {
return true;
}
}
}
/**
* Finds all variations of the pattern list, by expanding it using rotation and mirroring
*/
function expandAllThreeByThreePatterns() {
const rotatedPatterns = [
...threeByThreePatterns,
...threeByThreePatterns.map(rotate90Degrees),
...threeByThreePatterns.map(rotate90Degrees).map(rotate90Degrees),
...threeByThreePatterns.map(rotate90Degrees).map(rotate90Degrees).map(rotate90Degrees),
];
const mirroredPatterns = [...rotatedPatterns, ...rotatedPatterns.map(verticalMirror)];
return [...mirroredPatterns, ...mirroredPatterns.map(horizontalMirror)];
}
function rotate90Degrees(pattern: string[]) {
return [
`${pattern[2][0]}${pattern[1][0]}${pattern[0][0]}`,
`${pattern[2][1]}${pattern[1][1]}${pattern[0][1]}`,
`${pattern[2][2]}${pattern[1][2]}${pattern[0][2]}`,
];
}
function verticalMirror(pattern: string[]) {
return [pattern[2], pattern[1], pattern[0]];
}
function horizontalMirror(pattern: string[]) {
return [
pattern[0].split("").reverse().join(),
pattern[1].split("").reverse().join(),
pattern[2].split("").reverse().join(),
];
}
+179
View File
@@ -0,0 +1,179 @@
import {
BoardState,
getGoPlayerStartingState,
opponents,
PlayerColor,
playerColors,
PointState,
} from "../boardState/goConstants";
import { getAllChains, getPlayerNeighbors } from "./boardAnalysis";
import { getKomi } from "./goAI";
import { Player } from "@player";
import { getDifficultyMultiplier, getMaxFavor, getWinstreakMultiplier } from "../effects/effect";
import { floor, isNotNull } from "../boardState/boardState";
import { Factions } from "../../Faction/Factions";
import { FactionName } from "@enums";
/**
* Returns the score of the current board.
* Each player gets one point for each piece on the board, and one point for any empty node
* fully surrounded by their pieces
*/
export function getScore(boardState: BoardState) {
const komi = getKomi(boardState.ai) ?? 6.5;
const whitePieces = getColoredPieceCount(boardState, playerColors.white);
const blackPieces = getColoredPieceCount(boardState, playerColors.black);
const territoryScores = getTerritoryScores(boardState);
return {
[playerColors.white]: {
pieces: whitePieces,
territory: territoryScores[playerColors.white],
komi: komi,
sum: whitePieces + territoryScores[playerColors.white] + komi,
},
[playerColors.black]: {
pieces: blackPieces,
territory: territoryScores[playerColors.black],
komi: 0,
sum: blackPieces + territoryScores[playerColors.black],
},
};
}
/**
* Handles ending the game. Sets the previous player to null to prevent further moves, calculates score, and updates
* player node count and power, and game history
*/
export function endGoGame(boardState: BoardState) {
if (boardState.previousPlayer === null) {
return;
}
boardState.previousPlayer = null;
const statusToUpdate = getPlayerStats(boardState.ai);
statusToUpdate.favor = statusToUpdate.favor ?? 0;
const score = getScore(boardState);
if (score[playerColors.black].sum < score[playerColors.white].sum) {
resetWinstreak(boardState.ai, true);
statusToUpdate.nodePower += floor(score[playerColors.black].sum * 0.25);
} else {
statusToUpdate.wins++;
statusToUpdate.oldWinStreak = statusToUpdate.winStreak;
statusToUpdate.winStreak = statusToUpdate.oldWinStreak < 0 ? 1 : statusToUpdate.winStreak + 1;
if (statusToUpdate.winStreak > statusToUpdate.highestWinStreak) {
statusToUpdate.highestWinStreak = statusToUpdate.winStreak;
}
const factionName = boardState.ai as unknown as FactionName;
if (
statusToUpdate.winStreak % 2 === 0 &&
Player.factions.includes(factionName) &&
statusToUpdate.favor < getMaxFavor() &&
Factions?.[factionName]
) {
Factions[factionName].favor++;
statusToUpdate.favor++;
}
}
statusToUpdate.nodePower +=
score[playerColors.black].sum *
getDifficultyMultiplier(score[playerColors.white].komi, boardState.board[0].length) *
getWinstreakMultiplier(statusToUpdate.winStreak, statusToUpdate.oldWinStreak);
statusToUpdate.nodes += score[playerColors.black].sum;
Player.go.boardState = boardState;
Player.go.previousGameFinalBoardState = boardState;
// Update multipliers with new bonuses, once at the end of the game
Player.applyEntropy(Player.entropy);
}
/**
* Sets the winstreak to zero for the given opponent, and adds a loss
*/
export function resetWinstreak(opponent: opponents, gameComplete: boolean) {
const statusToUpdate = getPlayerStats(opponent);
statusToUpdate.losses++;
statusToUpdate.oldWinStreak = statusToUpdate.winStreak;
if (statusToUpdate.winStreak >= 0) {
statusToUpdate.winStreak = -1;
} else if (gameComplete) {
// Only increase the "dry streak" count if the game actually finished
statusToUpdate.winStreak--;
}
}
/**
* Gets the number pieces of a given color on the board
*/
function getColoredPieceCount(boardState: BoardState, color: PlayerColor) {
return boardState.board.reduce(
(sum, row) => sum + row.filter(isNotNull).filter((point) => point.player === color).length,
0,
);
}
/**
* Finds all empty spaces fully surrounded by a single player's stones
*/
function getTerritoryScores(boardState: BoardState) {
const emptyTerritoryChains = getAllChains(boardState).filter((chain) => chain?.[0]?.player === playerColors.empty);
return emptyTerritoryChains.reduce(
(scores, currentChain) => {
const chainColor = checkTerritoryOwnership(boardState, currentChain);
return {
[playerColors.white]:
scores[playerColors.white] + (chainColor === playerColors.white ? currentChain.length : 0),
[playerColors.black]:
scores[playerColors.black] + (chainColor === playerColors.black ? currentChain.length : 0),
};
},
{
[playerColors.white]: 0,
[playerColors.black]: 0,
},
);
}
/**
* Finds all neighbors of the empty points in question. If they are all one color, that player controls that space
*/
function checkTerritoryOwnership(boardState: BoardState, emptyPointChain: PointState[]) {
if (emptyPointChain.length > boardState.board[0].length ** 2 - 3) {
return null;
}
const playerNeighbors = getPlayerNeighbors(boardState, emptyPointChain);
const hasWhitePieceNeighbors = playerNeighbors.find((p) => p.player === playerColors.white);
const hasBlackPieceNeighbors = playerNeighbors.find((p) => p.player === playerColors.black);
const isWhiteTerritory = hasWhitePieceNeighbors && !hasBlackPieceNeighbors;
const isBlackTerritory = hasBlackPieceNeighbors && !hasWhitePieceNeighbors;
return isWhiteTerritory ? playerColors.white : isBlackTerritory ? playerColors.black : null;
}
/**
* prints the board state to the console
*/
export function logBoard(boardState: BoardState): void {
const state = boardState.board;
console.log("--------------");
for (let x = 0; x < state.length; x++) {
let output = `${x}: `;
for (let y = 0; y < state[x].length; y++) {
const point = state[x][y];
output += ` ${point?.chain ?? ""}`;
}
console.log(output);
}
}
export function getPlayerStats(opponent: opponents) {
if (!Player.go.status[opponent]) {
Player.go = getGoPlayerStartingState();
}
return Player.go.status[opponent];
}