mirror of
https://github.com/bitburner-official/bitburner-src.git
synced 2026-04-16 06:18:42 +02:00
847 lines
30 KiB
TypeScript
847 lines
30 KiB
TypeScript
import type { Board, BoardState, EyeMove, Move, MoveOptions, MoveType, Play, PointState } from "../Types";
|
|
|
|
import { Player } from "@player";
|
|
import { AugmentationName, GoColor, GoOpponent, GoPlayType } from "@enums";
|
|
import { opponentDetails } from "../Constants";
|
|
import { findNeighbors, isNotNullish, makeMove, passTurn } from "../boardState/boardState";
|
|
import {
|
|
evaluateIfMoveIsValid,
|
|
evaluateMoveResult,
|
|
findEffectiveLibertiesOfNewMove,
|
|
findEnemyNeighborChainWithFewestLiberties,
|
|
findMinLibertyCountOfAdjacentChains,
|
|
getAllChains,
|
|
getAllEyes,
|
|
getAllEyesByChainId,
|
|
getAllNeighboringChains,
|
|
getAllValidMoves,
|
|
getPreviousMoveDetails,
|
|
} from "./boardAnalysis";
|
|
import { findDisputedTerritory } from "./controlledTerritory";
|
|
import { findAnyMatchedPatterns } from "./patternMatching";
|
|
import { WHRNG } from "../../Casino/RNG";
|
|
import { Go, GoEvents } from "../Go";
|
|
|
|
let isAiThinking: boolean = false;
|
|
let currentTurnResolver: (() => void) | null = null;
|
|
|
|
/**
|
|
* Retrieves a move from the current faction in response to the player's move
|
|
*/
|
|
export function makeAIMove(boardState: BoardState, useOfflineCycles = true): Promise<Play> {
|
|
// If AI is already taking their turn, return the existing turn.
|
|
if (isAiThinking) {
|
|
return Go.nextTurn;
|
|
}
|
|
isAiThinking = true;
|
|
let encounteredError = false;
|
|
|
|
// If the AI is disabled, simply make a promise to be resolved once the player makes a move as white
|
|
if (boardState.ai === GoOpponent.none) {
|
|
resetAI();
|
|
}
|
|
// If an AI is in use, find the faction's move in response, and resolve the Go.nextTurn promise once it is found and played.
|
|
else {
|
|
const currentMoveCount = Go.currentGame.previousBoards.length;
|
|
Go.nextTurn = getMove(boardState, GoColor.white, Go.currentGame.ai, useOfflineCycles).then(
|
|
async (play): Promise<Play> => {
|
|
if (boardState !== Go.currentGame) {
|
|
//Stale game
|
|
encounteredError = true;
|
|
return play;
|
|
}
|
|
|
|
// Handle AI passing
|
|
if (play.type === GoPlayType.pass) {
|
|
passTurn(boardState, GoColor.white);
|
|
// if passTurn called endGoGame, or the player has no valid moves left, the move should be shown as a game over
|
|
if (boardState.previousPlayer === null || !getAllValidMoves(boardState, GoColor.black).length) {
|
|
return { type: GoPlayType.gameOver, x: null, y: null };
|
|
}
|
|
return play;
|
|
}
|
|
|
|
// Handle AI making a move
|
|
await waitCycle(useOfflineCycles);
|
|
|
|
if (currentMoveCount !== Go.currentGame.previousBoards.length || boardState !== Go.currentGame) {
|
|
console.warn("AI move attempted, but the board state has changed.");
|
|
encounteredError = true;
|
|
return play;
|
|
}
|
|
|
|
const aiUpdatedBoard = makeMove(boardState, play.x, play.y, GoColor.white);
|
|
|
|
// Handle the AI breaking. This shouldn't ever happen.
|
|
if (!aiUpdatedBoard) {
|
|
boardState.previousPlayer = GoColor.white;
|
|
console.error(`Invalid AI move attempted: ${play.x}, ${play.y}. This should not happen.`);
|
|
}
|
|
|
|
return play;
|
|
},
|
|
);
|
|
}
|
|
|
|
// Once the AI moves (or the player playing as white with No AI moves),
|
|
// clear the isAiThinking semaphore and update the board UI.
|
|
Go.nextTurn = Go.nextTurn.finally(() => {
|
|
if (!encounteredError) {
|
|
isAiThinking = false;
|
|
}
|
|
GoEvents.emit();
|
|
});
|
|
|
|
return Go.nextTurn;
|
|
}
|
|
|
|
export function resetAI(thinking = true) {
|
|
isAiThinking = thinking;
|
|
GoEvents.emit();
|
|
// Update currentTurnResolver to call Go.nextTurn's resolve function with the last played move's details
|
|
Go.nextTurn = new Promise((resolve) => (currentTurnResolver = () => resolve(getPreviousMoveDetails())));
|
|
}
|
|
|
|
/**
|
|
* Resolves the current turn.
|
|
* This is used for players manually playing against their script on the no-ai board.
|
|
*/
|
|
export function resolveCurrentTurn() {
|
|
// Call the resolve function on Go.nextTurn, if it exists
|
|
currentTurnResolver?.();
|
|
currentTurnResolver = null;
|
|
}
|
|
|
|
/*
|
|
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: GoColor,
|
|
opponent: GoOpponent,
|
|
useOfflineCycles = true,
|
|
rngOverride?: number,
|
|
): Promise<Play & { type: GoPlayType.move | GoPlayType.pass }> {
|
|
await waitCycle(useOfflineCycles);
|
|
const rng = new WHRNG(rngOverride || Player.totalPlaytime);
|
|
const smart = isSmart(opponent, rng.random());
|
|
const moves = getMoveOptions(boardState, player, rng.random(), smart);
|
|
|
|
const priorityMove = await getFactionMove(moves, opponent, rng.random());
|
|
if (priorityMove) {
|
|
return {
|
|
type: GoPlayType.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,
|
|
(await moves.pattern())?.point,
|
|
moves.eyeMove()?.point,
|
|
moves.eyeBlock()?.point,
|
|
]
|
|
.filter(isNotNullish)
|
|
.filter((point) => evaluateIfMoveIsValid(boardState, point.x, point.y, player, false));
|
|
|
|
const chosenMove = moveOptions[Math.floor(rng.random() * moveOptions.length)];
|
|
await waitCycle(useOfflineCycles);
|
|
|
|
if (chosenMove) {
|
|
//console.debug(`Non-priority move chosen: ${chosenMove.x} ${chosenMove.y}`);
|
|
return { type: GoPlayType.move, x: chosenMove.x, y: chosenMove.y };
|
|
}
|
|
// Pass if no valid moves were found
|
|
return { type: GoPlayType.pass, x: null, y: null };
|
|
}
|
|
|
|
/**
|
|
* Given a group of move options, chooses one based on the given opponent's personality (if any fit their priorities)
|
|
*/
|
|
async function getFactionMove(moves: MoveOptions, faction: GoOpponent, rng: number): Promise<PointState | null> {
|
|
if (faction === GoOpponent.Netburners) {
|
|
return getNetburnersPriorityMove(moves, rng);
|
|
}
|
|
if (faction === GoOpponent.SlumSnakes) {
|
|
return getSlumSnakesPriorityMove(moves, rng);
|
|
}
|
|
if (faction === GoOpponent.TheBlackHand) {
|
|
return getBlackHandPriorityMove(moves, rng);
|
|
}
|
|
if (faction === GoOpponent.Tetrads) {
|
|
return getTetradPriorityMove(moves, rng);
|
|
}
|
|
if (faction === GoOpponent.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: GoOpponent, rng: number) {
|
|
if (faction === GoOpponent.Netburners) {
|
|
return false;
|
|
}
|
|
if (faction === GoOpponent.SlumSnakes) {
|
|
return rng < 0.3;
|
|
}
|
|
if (faction === GoOpponent.TheBlackHand) {
|
|
return rng < 0.8;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Netburners mostly just put random points around the board, but occasionally have a smart move
|
|
*/
|
|
async function getNetburnersPriorityMove(moves: MoveOptions, rng: number): Promise<PointState | null> {
|
|
if (rng < 0.2) {
|
|
return getIlluminatiPriorityMove(moves, rng);
|
|
} else if (rng < 0.4 && moves.expansion()) {
|
|
return moves.expansion()?.point ?? null;
|
|
} else if (rng < 0.6 && moves.growth()) {
|
|
return moves.growth()?.point ?? null;
|
|
} else if (rng < 0.75) {
|
|
return moves.random()?.point ?? null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Slum snakes prioritize defending their pieces and building chains that snake around as much of the bord as possible.
|
|
*/
|
|
async function getSlumSnakesPriorityMove(moves: MoveOptions, rng: number): Promise<PointState | null> {
|
|
if (await moves.defendCapture()) {
|
|
return (await moves.defendCapture())?.point ?? null;
|
|
}
|
|
|
|
if (rng < 0.2) {
|
|
return getIlluminatiPriorityMove(moves, rng);
|
|
} else if (rng < 0.6 && moves.growth()) {
|
|
return moves.growth()?.point ?? null;
|
|
} else if (rng < 0.65) {
|
|
return moves.random()?.point ?? null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Black hand just wants to smOrk. They always capture or smother the opponent if possible.
|
|
*/
|
|
async function getBlackHandPriorityMove(moves: MoveOptions, rng: number): Promise<PointState | null> {
|
|
if (await moves.capture()) {
|
|
//console.debug("capture: capture move chosen");
|
|
return (await moves.capture())?.point ?? null;
|
|
}
|
|
|
|
const surround = moves.surround();
|
|
|
|
if (surround && surround.point && (surround.newLibertyCount ?? 999) <= 1) {
|
|
//console.debug("surround move chosen");
|
|
return surround.point;
|
|
}
|
|
|
|
if (await moves.defendCapture()) {
|
|
//console.debug("defend capture: defend move chosen");
|
|
return (await moves.defendCapture())?.point ?? null;
|
|
}
|
|
|
|
if (surround && surround.point && (surround?.newLibertyCount ?? 999) <= 2) {
|
|
//console.debug("surround move chosen");
|
|
return surround.point;
|
|
}
|
|
|
|
if (rng < 0.3) {
|
|
return getIlluminatiPriorityMove(moves, rng);
|
|
} else if (rng < 0.75 && surround) {
|
|
return surround.point;
|
|
} else if (rng < 0.8) {
|
|
return moves.random()?.point ?? null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Tetrads really like to be up close and personal, cutting and circling their opponent
|
|
*/
|
|
async function getTetradPriorityMove(moves: MoveOptions, rng: number): Promise<PointState | null> {
|
|
if (await moves.capture()) {
|
|
//console.debug("capture: capture move chosen");
|
|
return (await moves.capture())?.point ?? null;
|
|
}
|
|
|
|
if (await moves.defendCapture()) {
|
|
//console.debug("defend capture: defend move chosen");
|
|
return (await moves.defendCapture())?.point ?? null;
|
|
}
|
|
|
|
if (await moves.pattern()) {
|
|
//console.debug("pattern match move chosen");
|
|
return (await moves.pattern())?.point ?? null;
|
|
}
|
|
|
|
const surround = moves.surround();
|
|
if (surround && surround.point && (surround?.newLibertyCount ?? 9) <= 1) {
|
|
//console.debug("surround move chosen");
|
|
return surround.point;
|
|
}
|
|
|
|
if (rng < 0.4) {
|
|
return getIlluminatiPriorityMove(moves, rng);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Daedalus almost always picks the Illuminati move, but very occasionally gets distracted.
|
|
*/
|
|
async function getDaedalusPriorityMove(moves: MoveOptions, rng: number): Promise<PointState | null> {
|
|
if (rng < 0.9) {
|
|
return await 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.
|
|
*/
|
|
async function getIlluminatiPriorityMove(moves: MoveOptions, rng: number): Promise<PointState | null> {
|
|
if (await moves.capture()) {
|
|
//console.debug("capture: capture move chosen");
|
|
return (await moves.capture())?.point ?? null;
|
|
}
|
|
|
|
if (await moves.defendCapture()) {
|
|
//console.debug("defend capture: defend move chosen");
|
|
return (await moves.defendCapture())?.point ?? null;
|
|
}
|
|
|
|
if (moves.eyeMove()) {
|
|
//console.debug("Create eye move chosen");
|
|
return moves.eyeMove()?.point ?? null;
|
|
}
|
|
|
|
const surround = moves.surround();
|
|
if (surround && surround.point && (surround?.newLibertyCount ?? 9) <= 1) {
|
|
//console.debug("surround move chosen");
|
|
return surround.point;
|
|
}
|
|
|
|
if (moves.eyeBlock()) {
|
|
//console.debug("Block eye move chosen");
|
|
return moves.eyeBlock()?.point ?? null;
|
|
}
|
|
|
|
if (moves.corner()) {
|
|
//console.debug("Corner move chosen");
|
|
return moves.corner()?.point ?? null;
|
|
}
|
|
|
|
const hasMoves = [moves.eyeMove(), moves.eyeBlock(), moves.growth(), moves.defend, surround].filter((m) => m).length;
|
|
const usePattern = rng > 0.25 || !hasMoves;
|
|
|
|
if ((await moves.pattern()) && usePattern) {
|
|
//console.debug("pattern match move chosen");
|
|
return (await moves.pattern())?.point ?? null;
|
|
}
|
|
|
|
if (rng > 0.4 && moves.jump()) {
|
|
//console.debug("Jump move chosen");
|
|
return moves.jump()?.point ?? null;
|
|
}
|
|
|
|
if (rng < 0.6 && surround && surround.point && (surround?.newLibertyCount ?? 9) <= 2) {
|
|
//console.debug("surround move chosen");
|
|
return surround.point;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get a move that places a piece to influence (and later control) a corner
|
|
*/
|
|
function getCornerMove(board: Board) {
|
|
const boardEdge = board[0].length - 1;
|
|
const cornerMax = boardEdge - 2;
|
|
if (isCornerAvailableForMove(board, cornerMax, cornerMax, boardEdge, boardEdge)) {
|
|
return board[cornerMax][cornerMax];
|
|
}
|
|
if (isCornerAvailableForMove(board, 0, cornerMax, cornerMax, boardEdge)) {
|
|
return board[2][cornerMax];
|
|
}
|
|
if (isCornerAvailableForMove(board, 0, 0, 2, 2)) {
|
|
return board[2][2];
|
|
}
|
|
if (isCornerAvailableForMove(board, cornerMax, 0, boardEdge, 2)) {
|
|
return board[cornerMax][2];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Find all non-offline nodes in a given area
|
|
*/
|
|
function findLiveNodesInArea(board: Board, x1: number, y1: number, x2: number, y2: number) {
|
|
const foundPoints: PointState[] = [];
|
|
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(board: Board, x1: number, y1: number, x2: number, y2: number) {
|
|
const foundPoints = findLiveNodesInArea(board, x1, y1, x2, y2);
|
|
const foundPieces = foundPoints.filter((point) => point.color !== GoColor.empty);
|
|
return foundPoints.length >= 7 ? foundPieces.length === 0 : false;
|
|
}
|
|
|
|
/**
|
|
* Select a move from the list of open-area moves
|
|
*/
|
|
function getExpansionMove(board: Board, availableSpaces: PointState[], rng: number, moveArray?: Move[]) {
|
|
const moveOptions = moveArray ?? getExpansionMoveArray(board, availableSpaces);
|
|
const randomIndex = Math.floor(rng * moveOptions.length);
|
|
return moveOptions[randomIndex];
|
|
}
|
|
|
|
/**
|
|
* Get a move in open space that is nearby a friendly piece
|
|
*/
|
|
function getJumpMove(board: Board, player: GoColor, availableSpaces: PointState[], rng: number, moveArray?: Move[]) {
|
|
const moveOptions = (moveArray ?? getExpansionMoveArray(board, 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?.color === player),
|
|
);
|
|
|
|
const randomIndex = Math.floor(rng * moveOptions.length);
|
|
return moveOptions[randomIndex];
|
|
}
|
|
|
|
/**
|
|
* Finds a move in an open area to expand influence and later build on
|
|
*/
|
|
export function getExpansionMoveArray(board: Board, availableSpaces: PointState[]): Move[] {
|
|
// Look for any empty spaces fully surrounded by empty spaces to expand into
|
|
const emptySpaces = availableSpaces.filter((space) => {
|
|
const neighbors = findNeighbors(board, space.x, space.y);
|
|
return (
|
|
[neighbors.north, neighbors.east, neighbors.south, neighbors.west].filter(
|
|
(point) => point && point.color === GoColor.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(board, availableSpaces, 1);
|
|
|
|
const moveOptions = [...emptySpaces, ...disputedSpaces];
|
|
|
|
return moveOptions.map((point) => {
|
|
return {
|
|
point: point,
|
|
newLibertyCount: -1,
|
|
oldLibertyCount: -1,
|
|
};
|
|
});
|
|
}
|
|
|
|
function getDisputedTerritoryMoves(board: Board, availableSpaces: PointState[], maxChainSize = 99) {
|
|
const chains = getAllChains(board).filter((chain) => chain.length <= maxChainSize);
|
|
|
|
return availableSpaces.filter((space) => {
|
|
const chain = chains.find((chain) => chain[0].chain === space.chain) ?? [];
|
|
const playerNeighbors = getAllNeighboringChains(board, chain, chains);
|
|
const hasWhitePieceNeighbor = playerNeighbors.find((neighborChain) => neighborChain[0]?.color === GoColor.white);
|
|
const hasBlackPieceNeighbor = playerNeighbors.find((neighborChain) => neighborChain[0]?.color === GoColor.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.
|
|
*/
|
|
function getLibertyGrowthMoves(board: Board, player: GoColor, availableSpaces: PointState[]) {
|
|
const friendlyChains = getAllChains(board).filter((chain) => chain[0].color === 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(isNotNullish).map((liberty) => ({
|
|
libertyPoint: liberty,
|
|
oldLibertyCount: chain[0].liberties?.length,
|
|
})),
|
|
)
|
|
.flat()
|
|
.filter(isNotNullish)
|
|
.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(board, move.x, move.y, player).length;
|
|
|
|
// Get the smallest liberty count of connected chains to represent the old state
|
|
const oldLibertyCount = findMinLibertyCountOfAdjacentChains(board, 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
|
|
*/
|
|
function getGrowthMove(board: Board, player: GoColor, availableSpaces: PointState[], rng: number) {
|
|
const growthMoves = getLibertyGrowthMoves(board, 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[Math.floor(rng * moveCandidates.length)];
|
|
}
|
|
|
|
/**
|
|
* Find a move that specifically increases a chain's liberties from 1 to more than 1, preventing capture
|
|
*/
|
|
function getDefendMove(board: Board, player: GoColor, availableSpaces: PointState[]) {
|
|
const growthMoves = getLibertyGrowthMoves(board, 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[Math.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
|
|
*/
|
|
function getSurroundMove(board: Board, player: GoColor, availableSpaces: PointState[], smart = true) {
|
|
const opposingPlayer = player === GoColor.black ? GoColor.white : GoColor.black;
|
|
const enemyChains = getAllChains(board).filter((chain) => chain[0].color === 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(isNotNullish);
|
|
|
|
const captureMoves: Move[] = [];
|
|
const atariMoves: Move[] = [];
|
|
const surroundMoves: Move[] = [];
|
|
|
|
enemyLiberties.forEach((move) => {
|
|
const newLibertyCount = findEffectiveLibertiesOfNewMove(board, move.x, move.y, player).length;
|
|
|
|
const weakestEnemyChain = findEnemyNeighborChainWithFewestLiberties(
|
|
board,
|
|
move.x,
|
|
move.y,
|
|
player === GoColor.black ? GoColor.white : GoColor.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(board: Board, player: GoColor, availableSpaces: PointState[], maxLiberties = 99) {
|
|
const allEyes = getAllEyesByChainId(board, player);
|
|
const currentEyes = getAllEyes(board, 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(board);
|
|
const friendlyLiberties = chains
|
|
.filter((chain) => chain[0].color === 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(isNotNullish)
|
|
.filter((point) =>
|
|
availableSpaces.find((availablePoint) => availablePoint.x === point.x && availablePoint.y === point.y),
|
|
)
|
|
.filter((point: PointState) => {
|
|
const neighbors = findNeighbors(board, point.x, point.y);
|
|
const neighborhood = [neighbors.north, neighbors.east, neighbors.south, neighbors.west];
|
|
return (
|
|
neighborhood.filter((point) => !point || point?.color === player).length >= 2 &&
|
|
neighborhood.some((point) => point?.color === GoColor.empty)
|
|
);
|
|
});
|
|
|
|
const eyeCreationMoves = friendlyLiberties.reduce((moveOptions: EyeMove[], point: PointState) => {
|
|
const evaluationBoard = evaluateMoveResult(board, 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(board: Board, player: GoColor, availableSpaces: PointState[]) {
|
|
return getEyeCreationMoves(board, 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(board: Board, player: GoColor, availablePoints: PointState[]) {
|
|
const opposingPlayer = player === GoColor.white ? GoColor.black : GoColor.white;
|
|
const opponentEyeMoves = getEyeCreationMoves(board, 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
|
|
*/
|
|
function getMoveOptions(boardState: BoardState, player: GoColor, rng: number, smart = true) {
|
|
const board = boardState.board;
|
|
const availableSpaces = findDisputedTerritory(boardState, player, smart);
|
|
const contestedPoints = getDisputedTerritoryMoves(board, availableSpaces);
|
|
const expansionMoves = getExpansionMoveArray(board, 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 moveOptions: { [s in MoveType]: Move | null | undefined } = {
|
|
capture: undefined,
|
|
defendCapture: undefined,
|
|
eyeMove: undefined,
|
|
eyeBlock: undefined,
|
|
pattern: undefined,
|
|
growth: undefined,
|
|
expansion: undefined,
|
|
jump: undefined,
|
|
defend: undefined,
|
|
surround: undefined,
|
|
corner: undefined,
|
|
random: undefined,
|
|
};
|
|
|
|
const moveOptionGetters: MoveOptions = {
|
|
capture: async () => {
|
|
const surroundMove = await retrieveMoveOption("surround");
|
|
return surroundMove && surroundMove?.newLibertyCount === 0 ? surroundMove : null;
|
|
},
|
|
defendCapture: async () => {
|
|
const defendMove = await retrieveMoveOption("defend");
|
|
return defendMove &&
|
|
defendMove.oldLibertyCount == 1 &&
|
|
defendMove?.newLibertyCount &&
|
|
defendMove?.newLibertyCount > 1
|
|
? defendMove
|
|
: null;
|
|
},
|
|
eyeMove: () => (endGameAvailable ? null : getEyeCreationMove(board, player, availableSpaces) ?? null),
|
|
eyeBlock: () => (endGameAvailable ? null : getEyeBlockingMove(board, player, availableSpaces) ?? null),
|
|
pattern: async () => {
|
|
const point = endGameAvailable ? null : await findAnyMatchedPatterns(board, player, availableSpaces, smart, rng);
|
|
return point ? { point } : null;
|
|
},
|
|
growth: () => (endGameAvailable ? null : getGrowthMove(board, player, availableSpaces, rng) ?? null),
|
|
expansion: () => getExpansionMove(board, availableSpaces, rng, expansionMoves) ?? null,
|
|
jump: () => getJumpMove(board, player, availableSpaces, rng, expansionMoves) ?? null,
|
|
defend: () => getDefendMove(board, player, availableSpaces) ?? null,
|
|
surround: () => getSurroundMove(board, player, availableSpaces, smart) ?? null,
|
|
corner: () => {
|
|
const point = getCornerMove(board);
|
|
return point ? { point } : null;
|
|
},
|
|
random: () => {
|
|
// 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 point = contestedPoints.length ? availableSpaces[Math.floor(rng * availableSpaces.length)] : null;
|
|
return point ? { point } : null;
|
|
},
|
|
} as const;
|
|
|
|
async function retrieveMoveOption(id: MoveType): Promise<Move | null> {
|
|
await waitCycle();
|
|
if (moveOptions[id] !== undefined) {
|
|
return moveOptions[id] ?? null;
|
|
}
|
|
|
|
const move = (await moveOptionGetters[id]()) ?? null;
|
|
moveOptions[id] = move;
|
|
return move;
|
|
}
|
|
|
|
return moveOptionGetters;
|
|
}
|
|
|
|
/**
|
|
* Gets the starting score for white.
|
|
*/
|
|
export function getKomi(opponent: GoOpponent) {
|
|
return opponentDetails[opponent].komi;
|
|
}
|
|
|
|
/**
|
|
* Allows time to pass
|
|
*/
|
|
export function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
/**
|
|
* Spend some time waiting to allow the UI & CSS to render smoothly
|
|
* If bonus time is available, significantly decrease the length of the wait
|
|
*/
|
|
function waitCycle(useOfflineCycles = true): Promise<void> {
|
|
if (useOfflineCycles && Go.storedCycles > 0) {
|
|
Go.storedCycles -= 2;
|
|
return sleep(40);
|
|
}
|
|
return sleep(200);
|
|
}
|
|
|
|
export function showWorldDemon() {
|
|
return Player.hasAugmentation(AugmentationName.TheRedPill, true) && Player.activeSourceFileLvl(1);
|
|
}
|