mirror of
https://github.com/bitburner-official/bitburner-src.git
synced 2026-04-29 12:27:07 +02:00
IPVGO: Support scripts playing against each other as each color on "No AI" boards (#1917)
This is a big change with a *lot* of moving parts. The largest part of it is enabling scripts to `playAsWhite` as a parameter to many Go functions. In the implementation, this involved a significant rewrite of `opponentNextTurn` promise handling. A number of other changes and bugfixes are included: * Fixes the issue where handicap stones are added on game load. * Better typing for error callbacks. * Throw errors instead of deadlocking on bad cheat usage. * Return always-resolved gameOver promise after game end * Added a new `resetStats` api function. --------- Co-authored-by: David Walker <d0sboots@gmail.com>
This commit is contained in:
committed by
GitHub
parent
de6b202341
commit
c8d2c9f769
@@ -691,7 +691,7 @@ export function getColorOnBoardString(boardString: string, x: number, y: number)
|
||||
|
||||
/** Find a move made by the previous player, if present. */
|
||||
export function getPreviousMove(): [number, number] | null {
|
||||
const priorBoard = Go.currentGame?.previousBoards[0];
|
||||
const priorBoard = Go.currentGame.previousBoards[0];
|
||||
if (Go.currentGame.passCount || !priorBoard) {
|
||||
return null;
|
||||
}
|
||||
@@ -725,7 +725,7 @@ export function getPreviousMoveDetails(): Play {
|
||||
}
|
||||
|
||||
return {
|
||||
type: !priorMove && Go.currentGame?.passCount ? GoPlayType.pass : GoPlayType.gameOver,
|
||||
type: Go.currentGame.previousPlayer ? GoPlayType.pass : GoPlayType.gameOver,
|
||||
x: null,
|
||||
y: null,
|
||||
};
|
||||
|
||||
@@ -14,51 +14,75 @@ import {
|
||||
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";
|
||||
import { exceptionAlert } from "../../utils/helpers/exceptionAlert";
|
||||
|
||||
let isAiThinking: boolean = false;
|
||||
let currentTurnResolver: (() => void) | null = null;
|
||||
type PlayerPromise = {
|
||||
nextTurn: Promise<Play>;
|
||||
resolver: ((play?: Play) => void) | null;
|
||||
};
|
||||
|
||||
const gameOver = { type: GoPlayType.gameOver, x: null, y: null } as const;
|
||||
const playerPromises: Record<GoColor.black | GoColor.white, PlayerPromise> = {
|
||||
[GoColor.black]: { nextTurn: Promise.resolve(gameOver), resolver: null },
|
||||
[GoColor.white]: { nextTurn: Promise.resolve(gameOver), resolver: null },
|
||||
};
|
||||
|
||||
export function getNextTurn(color: GoColor.black | GoColor.white): Promise<Play> {
|
||||
return playerPromises[color].nextTurn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a move from the current faction in response to the player's move
|
||||
* Does common processing in response to a move being made.
|
||||
*
|
||||
* Due to asynchronous and/or timer-based functions, this function might be
|
||||
* called multiple times per turn. Therefore, it is (and must be) idempotent.
|
||||
* It is also used to handle the first turn of the game, and post-load
|
||||
* processing.
|
||||
* On the AI's turn, it starts AI processing. On all turns, it does promise
|
||||
* handling and dispatches common events.
|
||||
* @returns the nextTurn promise for the player who just moved
|
||||
*/
|
||||
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;
|
||||
export function handleNextTurn(boardState: BoardState, useOfflineCycles = true): Promise<Play> {
|
||||
const previousColor = boardState.previousPlayer;
|
||||
if (previousColor === null) {
|
||||
// The game is over. We shouldn't get here in most circumstances,
|
||||
// because when the game ends resetAI() will be called to resolve promises.
|
||||
// Return an already-resolved promise until a new game is started.
|
||||
return Promise.resolve(gameOver);
|
||||
}
|
||||
isAiThinking = true;
|
||||
let encounteredError = false;
|
||||
const currentColor = previousColor === GoColor.black ? GoColor.white : GoColor.black;
|
||||
// Promises are indexed by who wants to wait on them, not by who triggers them.
|
||||
// So the index color is reversed here.
|
||||
const previousPromise = playerPromises[currentColor];
|
||||
const currentPromise = playerPromises[currentColor === GoColor.black ? GoColor.white : GoColor.black];
|
||||
// If we've already handled this turn, return the existing promise.
|
||||
if (previousPromise.resolver === null) {
|
||||
return currentPromise.nextTurn;
|
||||
}
|
||||
previousPromise.resolver();
|
||||
previousPromise.resolver = null;
|
||||
GoEvents.emit();
|
||||
|
||||
// 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 {
|
||||
// If an AI is in use, find the faction's move in response, and recursively call handleNextTurn to resolve the nextTurn promise once it is found and played.
|
||||
if (boardState.ai !== GoOpponent.none && currentColor == GoColor.white) {
|
||||
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) {
|
||||
getMove(boardState, currentColor, Go.currentGame.ai, useOfflineCycles)
|
||||
.then(async (play) => {
|
||||
if (currentMoveCount !== Go.currentGame.previousBoards.length || boardState !== Go.currentGame) {
|
||||
//Stale game
|
||||
encounteredError = true;
|
||||
return play;
|
||||
return;
|
||||
}
|
||||
|
||||
// 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;
|
||||
passTurn(boardState, currentColor);
|
||||
return handleNextTurn(boardState, useOfflineCycles);
|
||||
}
|
||||
|
||||
// Handle AI making a move
|
||||
@@ -66,50 +90,58 @@ export function makeAIMove(boardState: BoardState, useOfflineCycles = true): Pro
|
||||
|
||||
if (currentMoveCount !== Go.currentGame.previousBoards.length || boardState !== Go.currentGame) {
|
||||
console.warn("AI move attempted, but the board state has changed.");
|
||||
encounteredError = true;
|
||||
return play;
|
||||
return;
|
||||
}
|
||||
|
||||
const aiUpdatedBoard = makeMove(boardState, play.x, play.y, GoColor.white);
|
||||
const aiUpdatedBoard = makeMove(boardState, play.x, play.y, currentColor);
|
||||
|
||||
// Handle the AI breaking. This shouldn't ever happen.
|
||||
if (!aiUpdatedBoard) {
|
||||
boardState.previousPlayer = GoColor.white;
|
||||
boardState.previousPlayer = currentColor;
|
||||
console.error(`Invalid AI move attempted: ${play.x}, ${play.y}. This should not happen.`);
|
||||
}
|
||||
|
||||
return play;
|
||||
},
|
||||
);
|
||||
// Recursively update promises for the next turn. This can't create an
|
||||
// infinite loop because the recursion is happenning asynchronously from a
|
||||
// delayed promise.
|
||||
return handleNextTurn(boardState, useOfflineCycles);
|
||||
})
|
||||
.catch((error) => exceptionAlert(error));
|
||||
}
|
||||
|
||||
// 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())));
|
||||
// If we haven't resolved currentPromise yet (for instance, at game start),
|
||||
// we should continue to use it instead of resolving it and creating a new one.
|
||||
if (!currentPromise.resolver) {
|
||||
createPromise(currentPromise);
|
||||
}
|
||||
return currentPromise.nextTurn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the current turn.
|
||||
* This is used for players manually playing against their script on the no-ai board.
|
||||
* Reset the promises for white and black turns.
|
||||
* This will notify scripts waiting on the old promises with gameOver,
|
||||
* potentially even when it is not their turn.
|
||||
* If the game has already ended, it won't re-notify (that was handled in
|
||||
* endGoGame()), which is why it is important to call this *before* resetting
|
||||
* the board state.
|
||||
*/
|
||||
export function resolveCurrentTurn() {
|
||||
// Call the resolve function on Go.nextTurn, if it exists
|
||||
currentTurnResolver?.();
|
||||
currentTurnResolver = null;
|
||||
export function resetAI(endOfGame = false): void {
|
||||
for (const playerPromise of Object.values(playerPromises)) {
|
||||
if (playerPromise.resolver) {
|
||||
playerPromise.resolver(gameOver);
|
||||
playerPromise.resolver = null;
|
||||
}
|
||||
if (!endOfGame && !playerPromise.resolver) {
|
||||
createPromise(playerPromise);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Returns a promise that resolves with the previous move details when the other player / script / AI makes a move
|
||||
function createPromise(promiseObj: PlayerPromise): void {
|
||||
promiseObj.resolver?.();
|
||||
promiseObj.nextTurn = new Promise((resolve) => {
|
||||
promiseObj.resolver = (play?: Play) => resolve(play ?? getPreviousMoveDetails());
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { Board, BoardState, PointState } from "../Types";
|
||||
|
||||
import { Player } from "@player";
|
||||
import { GoOpponent, GoColor, GoPlayType } from "@enums";
|
||||
import { GoOpponent, GoColor } from "@enums";
|
||||
import { newOpponentStats } from "../Constants";
|
||||
import { getAllChains, getPlayerNeighbors } from "./boardAnalysis";
|
||||
import { getKomi } from "./goAI";
|
||||
import { getKomi, resetAI } from "./goAI";
|
||||
import { getDifficultyMultiplier, getMaxFavor, getWinstreakMultiplier } from "../effects/effect";
|
||||
import { isNotNullish } from "../boardState/boardState";
|
||||
import { Factions } from "../../Faction/Factions";
|
||||
import { getEnumHelper } from "../../utils/EnumHelper";
|
||||
import { Go } from "../Go";
|
||||
import { Go, GoEvents } from "../Go";
|
||||
|
||||
/**
|
||||
* Returns the score of the current board.
|
||||
@@ -46,11 +46,6 @@ export function endGoGame(boardState: BoardState) {
|
||||
if (boardState.previousPlayer === null) {
|
||||
return;
|
||||
}
|
||||
Go.nextTurn = Promise.resolve({
|
||||
type: GoPlayType.gameOver,
|
||||
x: null,
|
||||
y: null,
|
||||
});
|
||||
|
||||
boardState.previousPlayer = null;
|
||||
const statusToUpdate = getOpponentStats(boardState.ai);
|
||||
@@ -59,7 +54,6 @@ export function endGoGame(boardState: BoardState) {
|
||||
|
||||
if (score[GoColor.black].sum < score[GoColor.white].sum) {
|
||||
resetWinstreak(boardState.ai, true);
|
||||
statusToUpdate.nodePower += Math.floor(score[GoColor.black].sum * 0.25);
|
||||
} else {
|
||||
statusToUpdate.wins++;
|
||||
statusToUpdate.oldWinStreak = statusToUpdate.winStreak;
|
||||
@@ -89,6 +83,8 @@ export function endGoGame(boardState: BoardState) {
|
||||
statusToUpdate.nodes += score[GoColor.black].sum;
|
||||
Go.currentGame = boardState;
|
||||
Go.previousGame = boardState;
|
||||
resetAI(true);
|
||||
GoEvents.emit();
|
||||
|
||||
// Update multipliers with new bonuses, once at the end of the game
|
||||
Player.applyEntropy(Player.entropy);
|
||||
@@ -123,7 +119,9 @@ function getColoredPieceCount(boardState: BoardState, color: GoColor) {
|
||||
* Finds all empty spaces fully surrounded by a single player's stones
|
||||
*/
|
||||
function getTerritoryScores(board: Board) {
|
||||
const emptyTerritoryChains = getAllChains(board).filter((chain) => chain?.[0]?.color === GoColor.empty);
|
||||
const emptyTerritoryChains = getAllChains(board).filter(
|
||||
(chain) => chain?.[0]?.color === GoColor.empty && chain.length <= board.length * 2,
|
||||
);
|
||||
|
||||
return emptyTerritoryChains.reduce(
|
||||
(scores, currentChain) => {
|
||||
|
||||
Reference in New Issue
Block a user