diff --git a/src/Go/Go.ts b/src/Go/Go.ts index 53360beb9..a8ba1c012 100644 --- a/src/Go/Go.ts +++ b/src/Go/Go.ts @@ -1,6 +1,6 @@ -import type { GoOpponent } from "@enums"; import type { BoardState, OpponentStats, Play } from "./Types"; +import { GoPlayType, type GoOpponent } from "@enums"; import { getRecordValues, PartialRecord } from "../Types/Record"; import { getNewBoardState } from "./boardState/boardState"; import { EventEmitter } from "../utils/EventEmitter"; @@ -10,7 +10,7 @@ export class GoObject { previousGame: BoardState | null = null; currentGame: BoardState = getNewBoardState(7); stats: PartialRecord = {}; - nextTurn: Promise | null = null; + nextTurn: Promise = Promise.resolve({ type: GoPlayType.gameOver, x: null, y: null }); prestigeAugmentation() { for (const stats of getRecordValues(this.stats)) { diff --git a/src/Go/SaveLoad.ts b/src/Go/SaveLoad.ts index 19b4f5365..b9d70d0ae 100644 --- a/src/Go/SaveLoad.ts +++ b/src/Go/SaveLoad.ts @@ -2,13 +2,14 @@ import type { BoardState, OpponentStats, SimpleBoard } from "./Types"; import type { PartialRecord } from "../Types/Record"; import { Truthy } from "lodash"; -import { GoColor, GoOpponent } from "@enums"; +import { GoColor, GoOpponent, GoPlayType } from "@enums"; import { Go } from "./Go"; -import { boardStateFromSimpleBoard, simpleBoardFromBoard } from "./boardAnalysis/boardAnalysis"; +import { boardStateFromSimpleBoard, getPreviousMove, simpleBoardFromBoard } from "./boardAnalysis/boardAnalysis"; import { assertLoadingType } from "../utils/TypeAssertion"; import { getEnumHelper } from "../utils/EnumHelper"; import { boardSizes } from "./Constants"; import { isInteger, isNumber } from "../types"; +import { makeAIMove } from "./boardAnalysis/goAI"; type PreviousGameSaveData = { ai: GoOpponent; board: SimpleBoard; previousPlayer: GoColor | null } | null; type CurrentGameSaveData = PreviousGameSaveData & { @@ -77,6 +78,18 @@ export function loadGo(data: unknown): boolean { Go.currentGame = currentGame; Go.previousGame = previousGame; Go.stats = stats; + + // If it's the AI's turn, initiate their turn, which will populate nextTurn + if (currentGame.previousPlayer === GoColor.black && currentGame.ai !== GoOpponent.none) makeAIMove(currentGame); + // If it's not the AI's turn and we're not in gameover status, initialize nextTurn promise based on the previous move/pass + else if (currentGame.previousPlayer) { + const previousMove = getPreviousMove(); + Go.nextTurn = Promise.resolve( + previousMove + ? { type: GoPlayType.move, x: previousMove[0], y: previousMove[1] } + : { type: GoPlayType.pass, x: null, y: null }, + ); + } return true; } diff --git a/src/Go/Types.ts b/src/Go/Types.ts index fdaf0297c..1697e20ae 100644 --- a/src/Go/Types.ts +++ b/src/Go/Types.ts @@ -51,11 +51,17 @@ export type PointState = { y: number; }; -export type Play = { - type: GoPlayType; - x: number | null; - y: number | null; -}; +export type Play = + | { + type: GoPlayType.move; + x: number; + y: number; + } + | { + type: GoPlayType.gameOver | GoPlayType.pass; + x: null; + y: null; + }; export type Neighbor = { north: PointState | null; diff --git a/src/Go/boardAnalysis/boardAnalysis.ts b/src/Go/boardAnalysis/boardAnalysis.ts index c98f8fa1c..deb6d9824 100644 --- a/src/Go/boardAnalysis/boardAnalysis.ts +++ b/src/Go/boardAnalysis/boardAnalysis.ts @@ -1,6 +1,7 @@ import type { Board, BoardState, Neighbor, PointState, SimpleBoard } from "../Types"; import { GoValidity, GoOpponent, GoColor } from "@enums"; +import { Go } from "../Go"; import { findAdjacentPointsInChain, findNeighbors, @@ -638,3 +639,27 @@ export function getColorOnSimpleBoard(simpleBoard: SimpleBoard, x: number, y: nu if (char === ".") return GoColor.empty; return null; } + +/** Find a move made by the previous player, if present. */ +export function getPreviousMove(): [number, number] | null { + const priorBoard = Go.currentGame?.previousBoards[0]; + if (Go.currentGame.passCount || !priorBoard) { + return null; + } + + for (const rowIndexString in Go.currentGame.board) { + const row = Go.currentGame.board[+rowIndexString] ?? []; + for (const pointIndexString in row) { + const point = row[+pointIndexString]; + const priorColor = point && priorBoard && getColorOnSimpleBoard(priorBoard, point.x, point.y); + const currentColor = point?.color; + const isPreviousPlayer = currentColor === Go.currentGame.previousPlayer; + const isChanged = priorColor !== currentColor; + if (priorColor && currentColor && isPreviousPlayer && isChanged) { + return [+rowIndexString, +pointIndexString]; + } + } + } + + return null; +} diff --git a/src/Go/boardAnalysis/goAI.ts b/src/Go/boardAnalysis/goAI.ts index e771f593a..33709a5cf 100644 --- a/src/Go/boardAnalysis/goAI.ts +++ b/src/Go/boardAnalysis/goAI.ts @@ -1,9 +1,9 @@ -import type { Board, BoardState, EyeMove, Move, MoveOptions, PointState } from "../Types"; +import type { Board, BoardState, EyeMove, Move, MoveOptions, Play, PointState } from "../Types"; import { Player } from "@player"; import { AugmentationName, GoOpponent, GoColor, GoPlayType } from "@enums"; import { opponentDetails } from "../Constants"; -import { findNeighbors, floor, isDefined, isNotNull, passTurn } from "../boardState/boardState"; +import { findNeighbors, floor, isDefined, isNotNull, makeMove, passTurn } from "../boardState/boardState"; import { evaluateIfMoveIsValid, evaluateMoveResult, @@ -19,6 +19,49 @@ import { import { findDisputedTerritory } from "./controlledTerritory"; import { findAnyMatchedPatterns } from "./patternMatching"; import { WHRNG } from "../../Casino/RNG"; +import { Go, GoEvents } from "../Go"; + +let currentAITurn: Promise | null = null; + +/** + * Retrieves a move from the current faction in response to the player's move + */ +export function makeAIMove(boardState: BoardState): Promise { + // If AI is already taking their turn, return the existing turn. + if (currentAITurn) return currentAITurn; + currentAITurn = Go.nextTurn = getMove(boardState, GoColor.white, Go.currentGame.ai) + .then(async (play): Promise => { + if (boardState !== Go.currentGame) return play; //Stale game + + // 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 sleep(500); + 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; + }) + .finally(() => { + currentAITurn = null; + GoEvents.emit(); + }); + + return Go.nextTurn; +} /* Basic GO AIs, each with some personality and weaknesses @@ -38,7 +81,12 @@ import { WHRNG } from "../../Casino/RNG"; * * @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, rngOverride?: number) { +export async function getMove( + boardState: BoardState, + player: GoColor, + opponent: GoOpponent, + rngOverride?: number, +): Promise { await sleep(300); const rng = new WHRNG(rngOverride || Player.totalPlaytime); const smart = isSmart(opponent, rng.random()); @@ -72,40 +120,10 @@ export async function getMove(boardState: BoardState, player: GoColor, opponent: if (chosenMove) { await sleep(200); //console.debug(`Non-priority move chosen: ${chosenMove.x} ${chosenMove.y}`); - return { - type: GoPlayType.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: GoColor) { - passTurn(boardState, player); - const opposingPlayer = player === GoColor.white ? GoColor.black : GoColor.white; - const remainingTerritory = getAllValidMoves(boardState, opposingPlayer).length; - if (remainingTerritory > 0 && boardState.passCount < 2) { - return { - type: GoPlayType.pass, - x: null, - y: null, - }; - } else { - return { - type: GoPlayType.gameOver, - x: null, - y: null, - }; + 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 }; } /** diff --git a/src/Go/boardAnalysis/scoring.ts b/src/Go/boardAnalysis/scoring.ts index 436f29945..3591bd223 100644 --- a/src/Go/boardAnalysis/scoring.ts +++ b/src/Go/boardAnalysis/scoring.ts @@ -50,7 +50,6 @@ export function endGoGame(boardState: BoardState) { type: GoPlayType.gameOver, x: null, y: null, - success: true, }); boardState.previousPlayer = null; diff --git a/src/Go/effects/netscriptGoImplementation.ts b/src/Go/effects/netscriptGoImplementation.ts index 7f2d41cfa..f11953824 100644 --- a/src/Go/effects/netscriptGoImplementation.ts +++ b/src/Go/effects/netscriptGoImplementation.ts @@ -1,14 +1,14 @@ -import { BoardState, Play, SimpleOpponentStats } from "../Types"; +import { Play, SimpleOpponentStats } from "../Types"; import { Player } from "@player"; import { AugmentationName, GoColor, GoOpponent, GoPlayType, GoValidity } from "@enums"; import { Go, GoEvents } from "../Go"; -import { getMove, sleep } from "../boardAnalysis/goAI"; import { getNewBoardState, makeMove, passTurn, updateCaptures, updateChains } from "../boardState/boardState"; +import { makeAIMove } from "../boardAnalysis/goAI"; import { evaluateIfMoveIsValid, - getColorOnSimpleBoard, getControlledSpace, + getPreviousMove, simpleBoardFromBoard, } from "../boardAnalysis/boardAnalysis"; import { getOpponentStats, getScore, resetWinstreak } from "../boardAnalysis/scoring"; @@ -104,7 +104,7 @@ export async function handlePassTurn(logger: (s: string) => void) { logEndGame(logger); return getOpponentNextMove(false, logger); } else { - return getAIMove(Go.currentGame); + return makeAIMove(Go.currentGame); } } @@ -122,26 +122,13 @@ export async function makePlayerMove(logger: (s: string) => void, error: (s: str GoEvents.emit(); logger(`Go move played: ${x}, ${y}`); - return getAIMove(boardState); + return makeAIMove(boardState); } /** Returns the promise that provides the opponent's move, once it finishes thinking. */ export async function getOpponentNextMove(logOpponentMove = true, logger: (s: string) => void) { - // Handle the case where Go.nextTurn isn't populated yet - if (!Go.nextTurn) { - const previousMove = getPreviousMove(); - const type = - Go.currentGame.previousPlayer === null ? GoPlayType.gameOver : previousMove ? GoPlayType.move : GoPlayType.pass; - - Go.nextTurn = Promise.resolve({ - type, - x: previousMove?.[0] ?? null, - y: previousMove?.[1] ?? null, - }); - } - // Only asynchronously log the opponent move if not disabled by the player if (logOpponentMove) { return Go.nextTurn.then((move) => { @@ -159,43 +146,6 @@ export async function getOpponentNextMove(logOpponentMove = true, logger: (s: st return Go.nextTurn; } -/** - * Retrieves a move from the current faction in response to the player's move - */ -export async function getAIMove(boardState: BoardState): Promise { - let resolve: (value: Play) => void; - Go.nextTurn = new Promise((res) => { - resolve = res; - }); - - getMove(boardState, GoColor.white, Go.currentGame.ai).then(async (result) => { - if (result.type === GoPlayType.pass) { - passTurn(Go.currentGame, GoColor.white); - } - - // If there is no move to apply, simply return the result - if (boardState !== Go.currentGame || result.type !== GoPlayType.move || result.x === null || result.y === null) { - return resolve(result); - } - - await sleep(400); - const aiUpdatedBoard = makeMove(boardState, result.x, result.y, GoColor.white); - - // Handle the AI breaking. This shouldn't ever happen. - if (!aiUpdatedBoard) { - boardState.previousPlayer = GoColor.white; - console.error(`Invalid AI move attempted: ${result.x}, ${result.y}. This should not happen.`); - GoEvents.emit(); - return resolve(result); - } - - await sleep(300); - GoEvents.emit(); - resolve(result); - }); - return Go.nextTurn; -} - /** * Returns a grid of booleans indicating if the coordinates at that location are a valid move for the player (black pieces) */ @@ -297,32 +247,6 @@ export function getCurrentPlayer(): "None" | "White" | "Black" { return Go.currentGame.previousPlayer === GoColor.black ? GoColor.white : GoColor.black; } -/** - * Find a move made by the previous player, if present. - */ -export function getPreviousMove(): [number, number] | null { - const priorBoard = Go.currentGame?.previousBoards[0]; - if (Go.currentGame.passCount || !priorBoard) { - return null; - } - - for (const rowIndexString in Go.currentGame.board) { - const row = Go.currentGame.board[+rowIndexString] ?? []; - for (const pointIndexString in row) { - const point = row[+pointIndexString]; - const priorColor = point && priorBoard && getColorOnSimpleBoard(priorBoard, point.x, point.y); - const currentColor = point?.color; - const isPreviousPlayer = currentColor === Go.currentGame.previousPlayer; - const isChanged = priorColor !== currentColor; - if (priorColor && currentColor && isPreviousPlayer && isChanged) { - return [+rowIndexString, +pointIndexString]; - } - } - } - - return null; -} - /** * Handle post-game logging */ @@ -418,7 +342,7 @@ export async function determineCheatSuccess( callback(); state.cheatCount++; GoEvents.emit(); - return getAIMove(state); + return makeAIMove(state); } // If there have been prior cheat attempts, and the cheat fails, there is a 10% chance of instantly losing else if (state.cheatCount && (ejectRngOverride ?? rng.random()) < 0.1) { @@ -435,7 +359,7 @@ export async function determineCheatSuccess( logger(`Cheat failed. Your turn has been skipped.`); passTurn(state, GoColor.black, false); state.cheatCount++; - return getAIMove(state); + return makeAIMove(state); } } diff --git a/src/Go/ui/GoGameboardWrapper.tsx b/src/Go/ui/GoGameboardWrapper.tsx index 84fcf3466..4b8bb6de2 100644 --- a/src/Go/ui/GoGameboardWrapper.tsx +++ b/src/Go/ui/GoGameboardWrapper.tsx @@ -18,7 +18,7 @@ import { GoScoreModal } from "./GoScoreModal"; import { GoGameboard } from "./GoGameboard"; import { GoSubnetSearch } from "./GoSubnetSearch"; import { CorruptableText } from "../../ui/React/CorruptableText"; -import { getAIMove } from "../effects/netscriptGoImplementation"; +import { makeAIMove } from "../boardAnalysis/goAI"; interface GoGameboardWrapperProps { showInstructions: () => void; @@ -45,21 +45,13 @@ export function GoGameboardWrapper({ showInstructions }: GoGameboardWrapperProps const [showPriorMove, setShowPriorMove] = useState(false); const [scoreOpen, setScoreOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false); - const [waitingOnAI, setWaitingOnAI] = useState(false); const classes = boardStyles(); const boardSize = boardState.board[0].length; const currentPlayer = boardState.previousPlayer === GoColor.white ? GoColor.black : GoColor.white; + const waitingOnAI = boardState.previousPlayer === GoColor.black && boardState.ai !== GoOpponent.none; const score = getScore(boardState); - // Only run this once on first component mount, to handle scenarios where the game was saved or closed while waiting on the AI to make a move - useEffect(() => { - if (boardState.previousPlayer === GoColor.black && !waitingOnAI && boardState.ai !== GoOpponent.none) { - takeAiTurn(Go.currentGame); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - // Do not implement useCallback for this function without ensuring GoGameboard still rerenders for every move // Currently this function changing is what triggers a GoGameboard rerender, which is needed async function clickHandler(x: number, y: number) { @@ -117,8 +109,7 @@ export function GoGameboardWrapper({ showInstructions }: GoGameboardWrapperProps } async function takeAiTurn(boardState: BoardState) { - setWaitingOnAI(true); - const move = await getAIMove(boardState); + const move = await makeAIMove(boardState); if (move.type === GoPlayType.pass) { SnackbarEvents.emit(`The opponent passes their turn; It is now your turn to move.`, ToastVariant.WARNING, 4000); @@ -130,7 +121,6 @@ export function GoGameboardWrapper({ showInstructions }: GoGameboardWrapperProps setScoreOpen(true); return; } - setWaitingOnAI(false); } function newSubnet() { @@ -172,8 +162,6 @@ export function GoGameboardWrapper({ showInstructions }: GoGameboardWrapperProps const endGameAvailable = boardState.previousPlayer === GoColor.white && boardState.passCount; const noLegalMoves = boardState.previousPlayer === GoColor.white && !getAllValidMoves(boardState, GoColor.black).length; - const disablePassButton = - Go.currentGame.ai !== GoOpponent.none && boardState.previousPlayer === GoColor.black && waitingOnAI; const scoreBoxText = boardState.previousBoards.length ? `Score: Black: ${score[GoColor.black].sum} White: ${score[GoColor.white].sum}` @@ -186,7 +174,7 @@ export function GoGameboardWrapper({ showInstructions }: GoGameboardWrapperProps if (boardState.previousPlayer === null) { return "View Final Score"; } - if (boardState.previousPlayer === GoColor.black && waitingOnAI) { + if (waitingOnAI) { return "Waiting for opponent"; } const currentPlayer = boardState.previousPlayer === GoColor.black ? GoColor.white : GoColor.black; @@ -242,7 +230,7 @@ export function GoGameboardWrapper({ showInstructions }: GoGameboardWrapperProps {scoreBoxText}