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:
Michael Ficocelli
2025-02-02 23:47:16 -05:00
committed by GitHub
parent de6b202341
commit c8d2c9f769
30 changed files with 502 additions and 263 deletions
+2 -2
View File
@@ -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,
};
+90 -58
View File
@@ -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());
});
}
/*
+8 -10
View File
@@ -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) => {