From 2f0cfce995a6d2deb9277a4fc6614dfb36a61d0a Mon Sep 17 00:00:00 2001 From: Simon Lemieux <1105380+simlmx@users.noreply.github.com> Date: Mon, 22 Jul 2024 01:34:57 +0000 Subject: [PATCH] Refactor the way we deal with bot moves --- game-template/game/package.json | 3 +- game-template/game/src/index.test.ts | 13 ++- game-template/game/src/index.ts | 21 ++++- packages/game/src/gameDef.ts | 122 ++++++++++++++++++--------- packages/game/src/testing.ts | 118 +++++++++++++------------- packages/ui/src/index.ts | 20 ++--- 6 files changed, 178 insertions(+), 119 deletions(-) diff --git a/game-template/game/package.json b/game-template/game/package.json index 61c4a3a..d3b80ca 100644 --- a/game-template/game/package.json +++ b/game-template/game/package.json @@ -12,7 +12,8 @@ "build": "rm -rf ./dist && pnpm rollup --config", "watch": "pnpm rollup --config --watch", "format": "pnpm eslint . --fix", - "check-format": "pnpm eslint . --quiet" + "check-format": "pnpm eslint . --quiet", + "test": "vitest run src" }, "devDependencies": { "@lefun/core": "workspace:*", diff --git a/game-template/game/src/index.test.ts b/game-template/game/src/index.test.ts index c1b7ec0..f46a68f 100644 --- a/game-template/game/src/index.test.ts +++ b/game-template/game/src/index.test.ts @@ -1,4 +1,4 @@ -import { test } from "vitest"; +import { expect, test } from "vitest"; import { MatchTester as _MatchTester } from "@lefun/game"; @@ -14,4 +14,15 @@ test("sanity check", () => { match.makeMove(userId, "roll"); match.makeMove(userId, "moveWithArg", { someArg: "123" }); + + // Time has no passed yet + expect(match.board.lastSomeBoardMoveValue).toBeUndefined(); + + // Not enough time + match.fastForward(50); + expect(match.board.lastSomeBoardMoveValue).toBeUndefined(); + + // Enough time + match.fastForward(50); + expect(match.board.lastSomeBoardMoveValue).toEqual(3); }); diff --git a/game-template/game/src/index.ts b/game-template/game/src/index.ts index 82a7582..2d2d98c 100644 --- a/game-template/game/src/index.ts +++ b/game-template/game/src/index.ts @@ -1,5 +1,12 @@ import { UserId } from "@lefun/core"; -import { BoardMove, Game, GameState, INIT_MOVE, PlayerMove } from "@lefun/game"; +import { + AutoMove, + BoardMove, + Game, + GameState, + INIT_MOVE, + PlayerMove, +} from "@lefun/game"; type Player = { isRolling: boolean; @@ -9,6 +16,7 @@ type Player = { export type Board = { count: number; players: Record; + lastSomeBoardMoveValue?: number; }; export type RollGameState = GameState; @@ -53,8 +61,8 @@ const someBoardMoveWithArgs: BoardMove< BMT["someBoardMoveWithArgs"], BMT > = { - execute() { - // + execute({ board, payload }) { + board.lastSomeBoardMoveValue = payload.someArg; }, }; @@ -73,4 +81,11 @@ export const game = { maxPlayers: 10, } satisfies Game; +export const autoMove: AutoMove = ({ random }) => { + if (random.d2() === 1) { + return ["moveWithArg", { someArg: "123" }]; + } + return "roll"; +}; + export type RollGame = typeof game; diff --git a/packages/game/src/gameDef.ts b/packages/game/src/gameDef.ts index 9a1a2fc..e418802 100644 --- a/packages/game/src/gameDef.ts +++ b/packages/game/src/gameDef.ts @@ -95,11 +95,6 @@ export type Execute = ( options: ExecuteOptions, ) => void; -// `any` doesn't seem to work but `infer ?` does. -export type GetPayloadOfPlayerMove = - // eslint-disable-next-line @typescript-eslint/no-unused-vars - PM extends PlayerMove ? P : never; - export type PlayerMove< G extends GameStateBase, P = NoPayload, @@ -189,40 +184,64 @@ export type AutoMoveInfo = { time?: number; }; -export type AutoMoveRet = - | { - move: PlayerMoveObj; - duration?: number; - } - | PlayerMoveObj; +export type GetPayload< + G extends Game, + K extends keyof G["playerMoves"] & string, +> = + // eslint-disable-next-line @typescript-eslint/no-unused-vars + G["playerMoves"][K] extends PlayerMove + ? P + : never; + +/* + * `name: string` if the move doesn't have any payload, [name: string, payload: ?] + * otherwise. + */ +type MoveObj> = { + [K in keyof G["playerMoves"] & string]: IfNever< + GetPayload, + K, + [K, GetPayload] + >; +}[keyof G["playerMoves"] & string]; -type AutoMoveType = (arg0: { +/* + * Stateless (and older) version of `GetAgent`. + */ +export type AutoMove> = (arg0: { userId: UserId; board: GS["B"]; playerboard: GS["PB"]; secretboard: GS["SB"]; random: Random; - returnAutoMoveInfo: boolean; -}) => AutoMoveRet; + withInfo: boolean; +}) => BotMove; -type GetAgent = (arg0: { +/* + * Supply a `getAgent` function that implements this interface to add bots to your game. + */ +export type GetAgent> = (arg0: { matchSettings: MatchSettings; matchPlayerSettings: MatchPlayerSettings; numPlayers: number; -}) => Promise>; +}) => Promise>; -export type AgentGetMoveRet

= { - // The `move` that should be performed. - move: PlayerMoveObj

; - // Some info used for training. - autoMoveInfo?: AutoMoveInfo; - // How much "thinking time" should be pretend this move took. - // After having calculated our move, we'll wait the difference before actually - // executing the move so that it takes that much time. - duration?: number; -}; +export type BotMove> = + | MoveObj + | { + move: MoveObj; + // Some info used for training. + autoMoveInfo?: AutoMoveInfo; + // How much "thinking time" should be pretend this move took. + // After having calculated our move, we'll wait the difference before actually + // executing the move so that it takes that much time. + duration?: number; + }; -export abstract class Agent { +// A full bot move with some extra fields, or just the vanilla (name and payload) move. +// export type BotMove> = MoveObj | BotMove; + +export abstract class Agent> { abstract getMove({ board, playerboard, @@ -231,21 +250,19 @@ export abstract class Agent { withInfo, verbose, }: { - board: B; - playerboard: PB; + board: GS["B"]; + playerboard: GS["PB"]; random: Random; userId: UserId; withInfo: boolean; verbose?: boolean; - }): Promise; + }): Promise>; } export type GetMatchScoreTextOptions = { board: B; }; -type PlayerMoveObj

= { name: string; payload: P }; - // This is what the game developer must implement. export type Game< GS extends GameStateBase, @@ -283,13 +300,6 @@ export type Game< // Games can customize the match score representation using this hook. getMatchScoreText?: (options: GetMatchScoreTextOptions) => string; - // Return a move for a given state of the game for a player. This is used for bots and - // could be used to play for an unactive user. - // Not that technically we don't need the `secretboard` in here. In practice sometimes - // we put data in the secretboard to optimize calculations. - autoMove?: AutoMoveType; - getAgent?: GetAgent; - // Game-level bot move duration. botMoveDuration?: number; @@ -379,3 +389,37 @@ export type GameManifest = { }; name?: string; }; + +/* Util to parse the diverse format that can take bot moves, as returned by `autoMove` + * and `Agent.getMove`. + */ +export function parseBotMove>( + botMove: BotMove, +): { + name: string; + payload?: unknown; + autoMoveInfo?: AutoMoveInfo; + duration?: number; +} { + let name: string; + let payload: unknown = undefined; + let autoMoveInfo: AutoMoveInfo | undefined = undefined; + let duration: number | undefined = undefined; + + if (typeof botMove === "string") { + name = botMove; + } else if (Array.isArray(botMove)) { + [name, payload] = botMove; + } else { + ({ autoMoveInfo, duration } = botMove); + + const { move } = botMove; + if (typeof move === "string") { + name = move; + } else { + [name, payload] = move; + } + } + + return { name, payload, autoMoveInfo, duration }; +} diff --git a/packages/game/src/testing.ts b/packages/game/src/testing.ts index 9c2f11d..27fba6e 100644 --- a/packages/game/src/testing.ts +++ b/packages/game/src/testing.ts @@ -16,16 +16,18 @@ import { import { ADD_PLAYER, Agent, - AgentGetMoveRet, + AutoMove, AutoMoveInfo, - AutoMoveRet, + BotMove, Game, Game_, GameStateBase, - GetPayloadOfPlayerMove, + GetAgent, + GetPayload, INIT_MOVE, KICK_PLAYER, MATCH_WAS_ABORTED, + parseBotMove, parseGame, RewardPayload, } from "./gameDef"; @@ -38,8 +40,10 @@ type DelayedBoardMove = { ts: number; }; -type MatchTesterOptions = { +type MatchTesterOptions> = { game: Game; + getAgent?: GetAgent; + autoMove?: AutoMove; gameData?: any; matchData?: any; numPlayers: number; @@ -74,14 +78,13 @@ type UsersState = { byId: Record }; type MakeMoveRest< G extends Game, - K extends keyof G["playerMoves"], + K extends keyof G["playerMoves"] & string, > = IfNever< - GetPayloadOfPlayerMove, + GetPayload, // [] | [EmptyObject, { canFail?: boolean }], // - | [GetPayloadOfPlayerMove] - | [GetPayloadOfPlayerMove, { canFail?: boolean }] + [GetPayload] | [GetPayload, { canFail?: boolean }] >; /* @@ -94,6 +97,8 @@ export class MatchTester< BMT extends Record = any, > { game: Game_; + autoMove?: AutoMove; + getAgent?: GetAgent; gameData: any; matchData?: any; meta: Meta; @@ -121,11 +126,13 @@ export class MatchTester< // Are we using the MatchTester for training. // TODO We should probably use different classes for training and for testing. _training: boolean; - _agents: Record>; + _agents: Record>; _isPlaying: boolean; constructor({ game, + autoMove, + getAgent, gameData = undefined, matchData = undefined, numPlayers, @@ -137,7 +144,7 @@ export class MatchTester< training = false, logBoardToTrainingLog = false, locale = "en", - }: MatchTesterOptions) { + }: MatchTesterOptions) { if (random == null) { random = new Random(); } @@ -255,6 +262,8 @@ export class MatchTester< }; }); + this.autoMove = autoMove; + this.getAgent = getAgent; this.gameData = gameData; this.matchData = matchData; this.board = board; @@ -402,7 +411,9 @@ export class MatchTester< ...payloadAndDelay: IfNever ) => { const [payload, delay] = - payloadAndDelay.length === 1 ? [{}, 0] : payloadAndDelay; + payloadAndDelay.length === 1 + ? [{}, payloadAndDelay[0]] + : payloadAndDelay; // const { name, payload } = move; const dm = { name, payload, ts: this.time + delay }; @@ -475,7 +486,7 @@ export class MatchTester< moveName: K, ...rest: MakeMoveRest ) { - let payload: GetPayloadOfPlayerMove = {} as any; + let payload: GetPayload = {} as any; let canFail: boolean = false; if (rest.length === 1) { @@ -571,16 +582,17 @@ export class MatchTester< throw new Error("already playing"); } this._isPlaying = true; - const { game, meta, _agents, matchSettings, matchPlayersSettings } = this; + const { getAgent, meta, _agents, matchSettings, matchPlayersSettings } = + this; const numPlayers = meta.players.allIds.length; // Initialize agents. // TODO remove the `if` when we deprecate `autoMove`. - if (game.getAgent) { + if (getAgent) { for (const userId of meta.players.allIds) { if (meta.players.byId[userId].isBot) { - _agents[userId] = await game.getAgent({ + _agents[userId] = await getAgent({ matchPlayerSettings: matchPlayersSettings[userId], matchSettings, numPlayers, @@ -592,7 +604,8 @@ export class MatchTester< } async makeNextBotMove() { - const { meta, game, board, playerboards, secretboard, random } = this; + const { meta, game, autoMove, board, playerboards, secretboard, random } = + this; // Check if we should do a bot move. for ( let userIndex = 0; @@ -615,48 +628,34 @@ export class MatchTester< } } - let autoMoveRet: AutoMoveRet | AgentGetMoveRet; const t0 = new Date().getTime(); - if (game.autoMove !== undefined) { - // TODO deprecate the `autoMove` function in favor of the AutoMover class? - autoMoveRet = await game.autoMove({ - board, - playerboard: playerboards[userId], - secretboard: secretboard!, - userId, - random, - returnAutoMoveInfo: this._training, - }); - } else { - autoMoveRet = await this._agents[userId].getMove({ - board, - playerboard: playerboards[userId], - random, - userId, - withInfo: this._training, - verbose: this._logBoardToTrainingLog, - }); - } - const t1 = new Date().getTime(); + const agent = this._agents[userId]; - const thinkingTime = t1 - t0; + let botMove: BotMove | undefined = undefined; - let move: { name: string; payload: any } | undefined; - let autoMoveInfo: AutoMoveInfo | undefined = undefined; + const args = { + board, + playerboard: playerboards[userId], + secretboard, + userId, + random, + withInfo: this._training, + }; - if ("autoMoveInfo" in autoMoveRet) { - if (autoMoveRet.autoMoveInfo !== undefined) { - autoMoveInfo = autoMoveRet.autoMoveInfo; - } - ({ move } = autoMoveRet); + if (autoMove) { + botMove = await autoMove(args); + } else if (agent) { + botMove = await agent.getMove(args); } else { - if ("move" in autoMoveRet) { - ({ move } = autoMoveRet); - } else { - move = autoMoveRet; - } + throw new Error("no autoMove or agent defined"); } + const { name, payload, autoMoveInfo } = parseBotMove(botMove); + + const t1 = new Date().getTime(); + + const thinkingTime = t1 - t0; + if (autoMoveInfo) { autoMoveInfo.time = thinkingTime; this._botTrainingLog.push({ type: "MOVE_INFO", ...autoMoveInfo }); @@ -681,16 +680,13 @@ export class MatchTester< ); } - if (move) { - const { name, payload } = move; - // We only play one bot move per call. The function will be called again if it's - // another bot's turn after. - return await this.makeMoveAndContinue( - userId, - name, - ...([payload] as any), - ); - } + // We only play one bot move per call. The function will be called again if it's + // another bot's turn after. + return await this.makeMoveAndContinue( + userId, + name, + ...((payload !== undefined ? [payload] : []) as any), + ); } } // No bot played this time. diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 920f3b0..24ddaf1 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -3,7 +3,7 @@ import { StoreApi, useStore as _useStore } from "zustand"; import { useShallow } from "zustand/react/shallow"; import type { MatchState as _MatchState, UserId } from "@lefun/core"; -import type { Game, GameStateBase, GetPayloadOfPlayerMove } from "@lefun/game"; +import type { Game, GameStateBase, GetPayload } from "@lefun/game"; // In the selectors, assume that the boards are defined. We will add a check in the // client code to make sure this is true. @@ -32,11 +32,7 @@ type MakeMove> = < K extends keyof G["playerMoves"] & string, >( moveName: K, - ...payload: IfNever< - GetPayloadOfPlayerMove, - [], - [GetPayloadOfPlayerMove] - > + ...payload: IfNever, [], [GetPayload]> ) => void; type MakeMoveFull> = < @@ -44,9 +40,9 @@ type MakeMoveFull> = < >( moveName: K, ...payload: IfNever< - GetPayloadOfPlayerMove, - [GetPayloadOfPlayerMove | undefined], - [GetPayloadOfPlayerMove] + GetPayload, + [GetPayload | undefined], + [GetPayload] > ) => void; @@ -85,11 +81,7 @@ export function useMakeMove>(): MakeMove { function newMakeMove( moveName: K, - ...payload: IfNever< - GetPayloadOfPlayerMove, - [], - [GetPayloadOfPlayerMove] - > + ...payload: IfNever, [], [GetPayload]> ) { return makeMovefull(moveName, payload[0] || {}); }