mirror of
https://github.com/bitburner-official/bitburner-src.git
synced 2026-04-29 12:27:07 +02:00
BITNODE: IPvGO territory control strategy game (#934)
This commit is contained in:
committed by
GitHub
parent
c6141f2adf
commit
7ef12a0323
@@ -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();
|
||||
}
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user