From f75ebbc9a673500039adf281b8bb82ddfaa6ecb6 Mon Sep 17 00:00:00 2001 From: Nathan Mande Date: Tue, 10 Dec 2024 20:22:18 +0200 Subject: [PATCH] feat(web): handle ai game mode --- apps/web/src/core/ai/ai.controller.ts | 3 +- apps/web/src/core/ai/ai.module.ts | 1 + apps/web/src/core/ai/ai.service.ts | 17 +- apps/web/src/core/ai/ai.worker.ts | 4 - .../main-menu/new-section-ai.component.tsx | 7 +- apps/web/src/routes/_hooks/index.ts | 2 - apps/web/src/routes/_hooks/use-ai.hook.ts | 134 ----------- .../play/_components/with-ai.component.tsx | 213 ++++++++++++++---- apps/web/src/routes/play/index.tsx | 23 +- apps/web/src/shared/types/player.type.ts | 2 - apps/web/src/shared/utils/url.util.ts | 8 +- 11 files changed, 210 insertions(+), 204 deletions(-) delete mode 100644 apps/web/src/routes/_hooks/use-ai.hook.ts diff --git a/apps/web/src/core/ai/ai.controller.ts b/apps/web/src/core/ai/ai.controller.ts index 7b2fe66..da63143 100644 --- a/apps/web/src/core/ai/ai.controller.ts +++ b/apps/web/src/core/ai/ai.controller.ts @@ -5,6 +5,7 @@ import type { RegisterLifecycleState } from "@quick-threejs/reactive"; import { AI_WILL_PERFORM_MOVE_TOKEN } from "../../shared/tokens"; import type { MessageEventPayload } from "../../shared/types"; +import { SupportedAiModel } from "@chess-d/ai"; @singleton() export class AiController { @@ -13,7 +14,7 @@ export class AiController { MessageEventPayload<{ move: Move }> >(); public readonly willPerformMove$ = fromEvent< - MessageEvent> + MessageEvent> >(self, "message").pipe( filter((message) => message.data.token === AI_WILL_PERFORM_MOVE_TOKEN) ); diff --git a/apps/web/src/core/ai/ai.module.ts b/apps/web/src/core/ai/ai.module.ts index 5096bff..94cce77 100644 --- a/apps/web/src/core/ai/ai.module.ts +++ b/apps/web/src/core/ai/ai.module.ts @@ -20,6 +20,7 @@ export class AiModule implements Module, WorkerThreadModule { this._subscriptions.push( this.controller.willPerformMove$.subscribe((payload) => { const move = this.service.handleWillPerformMove( + payload.data.value?.ai, payload.data.value?.fen ); diff --git a/apps/web/src/core/ai/ai.service.ts b/apps/web/src/core/ai/ai.service.ts index 47a90f4..7478ed0 100644 --- a/apps/web/src/core/ai/ai.service.ts +++ b/apps/web/src/core/ai/ai.service.ts @@ -1,20 +1,23 @@ +import { register, SupportedAiModel } from "@chess-d/ai"; import { inject, singleton } from "tsyringe"; import { Chess, validateFen } from "chess.js"; -import { AiModel } from "@chess-d/ai"; @singleton() export class AiService { - constructor( - @inject(Chess) private readonly game: Chess, - @inject(AiModel) private readonly ai: AiModel - ) {} + constructor(@inject(Chess) private readonly game: Chess) {} + + public handleWillPerformMove = (ai?: SupportedAiModel, fen?: string) => { + if (!ai) return console.warn("Received invalid AI model"); - public handleWillPerformMove = (fen?: string) => { if (!fen || !validateFen(fen).ok) return console.warn("AI received invalid FEN string"); this.game.load(fen); + const { container, model } = register(ai, this.game); + const move = model?.getMove(this.game.turn()); + + container.clearInstances(); - return this.ai?.getMove(this.game.turn()); + return move; }; } diff --git a/apps/web/src/core/ai/ai.worker.ts b/apps/web/src/core/ai/ai.worker.ts index 569d637..5ccc278 100644 --- a/apps/web/src/core/ai/ai.worker.ts +++ b/apps/web/src/core/ai/ai.worker.ts @@ -3,7 +3,6 @@ import "reflect-metadata"; import { container } from "tsyringe"; import { expose } from "threads/worker"; import { Chess } from "chess.js"; -import { AiModel, register, SupportedAiModel } from "@chess-d/ai"; import { ExposedAppModule } from "@quick-threejs/reactive/worker"; import { AiModule } from "./ai.module"; @@ -13,9 +12,6 @@ const game = new Chess( ); container.register(Chess, { useValue: game }); -container.register(AiModel, { - useValue: register(SupportedAiModel.zeyu, game) -}); const aiModule = container.resolve(AiModule); aiModule.init(); diff --git a/apps/web/src/routes/_components/main-menu/new-section-ai.component.tsx b/apps/web/src/routes/_components/main-menu/new-section-ai.component.tsx index 67b0d81..c644298 100644 --- a/apps/web/src/routes/_components/main-menu/new-section-ai.component.tsx +++ b/apps/web/src/routes/_components/main-menu/new-section-ai.component.tsx @@ -3,11 +3,13 @@ import { FC } from "react"; import { useNavigate } from "react-router"; import { MainMenuSection } from "../../../shared/enum"; -import { useMainMenuStore } from "../../_stores"; +import { useGameStore, useMainMenuStore } from "../../_stores"; export const NewGameAISection: FC = () => { const navigate = useNavigate(); + const { setSection } = useMainMenuStore(); + const { reset: resetGame } = useGameStore(); const renderSupportedAiModels = () => { return Object.keys(SupportedAiModel) @@ -24,12 +26,13 @@ export const NewGameAISection: FC = () => { const formData = new FormData(event.currentTarget); const aiOpponent = formData.get("select-ai") as - | keyof SupportedAiModel + | keyof typeof SupportedAiModel | null; if (!aiOpponent || SupportedAiModel[aiOpponent] === undefined) return; navigate(`/play?mode=ai&ai=${aiOpponent}`); + resetGame(); }; return ( diff --git a/apps/web/src/routes/_hooks/index.ts b/apps/web/src/routes/_hooks/index.ts index b3a478f..8d7e54f 100644 --- a/apps/web/src/routes/_hooks/index.ts +++ b/apps/web/src/routes/_hooks/index.ts @@ -1,3 +1 @@ -export * from "./use-ai.hook"; -export * from "./use-game.hook"; export * from "./use-socket.hook"; diff --git a/apps/web/src/routes/_hooks/use-ai.hook.ts b/apps/web/src/routes/_hooks/use-ai.hook.ts deleted file mode 100644 index 3f6cc6e..0000000 --- a/apps/web/src/routes/_hooks/use-ai.hook.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import { RegisterModule } from "@quick-threejs/reactive"; -import { Move, validateFen } from "chess.js"; -import { merge } from "rxjs"; - -import { PlayerModel } from "../../shared/models"; -import { MessageEventPayload } from "../../shared/types"; -import { - AI_PERFORMED_MOVE_TOKEN, - AI_WILL_PERFORM_MOVE_TOKEN -} from "../../shared/tokens"; -import { WorkerPool } from "@quick-threejs/utils"; - -/** @description Ai login worker location. */ -const workerLocation = new URL( - "../../core/ai/ai.worker.ts", - import.meta.url -) as unknown as string; - -/** @description Provide resources about the chess game and the application logic. */ -export const useAi = () => { - const [players, setPlayers] = useState([]); - - const [workerThread, setWorkerThread] = useState< - | Awaited["run"]>> - | undefined - >(); - - const init = useCallback(async (workerPool: WorkerPool) => { - const _workerThread = await workerPool.run({ - payload: { - path: workerLocation, - subject: {} - } - }); - - setWorkerThread(_workerThread); - }, []); - - const createPlayer = useCallback(() => { - const player = new PlayerModel(); - setPlayers((prev) => [...prev, player]); - - return player; - }, []); - - const removePlayer = useCallback( - (player: PlayerModel) => - setPlayers((prev) => - prev.filter((_player) => { - if (_player !== player) return true; - - _player.unsubscribe(); - _player.complete(); - - return false; - }) - ), - [] - ); - - const dispose = useCallback(() => { - workerThread?.thread?.terminate(); - workerThread?.worker?.terminate(); - - players.forEach((player) => { - player.unsubscribe(); - player.complete(); - }); - setPlayers([]); - }, [players, workerThread]); - - useEffect(() => { - const subscription = workerThread?.thread - ?.movePerformed$() - ?.subscribe((message: MessageEventPayload<{ move: Move }>) => { - const { token, value } = message; - - if (token !== AI_PERFORMED_MOVE_TOKEN || !value) return; - - const { move } = value; - - if (!move || !validateFen(move?.after).ok) return; - - console.log("AI move", move, players); - - players.forEach((player) => { - if (player.color === move.color) - player?.next({ - token: "PLACED_PIECE", - value: { - move - } - }); - }); - }); - - return () => { - subscription?.unsubscribe(); - }; - }, [players, workerThread]); - - useEffect(() => { - const subscription = merge(...players).subscribe((payload) => { - const { token, value } = payload; - const { turn, fen, move } = value || {}; - - if ( - token === "NOTIFIED" && - move && - fen && - validateFen(fen) && - players.find((player) => player.color === turn) - ) - workerThread?.worker?.postMessage?.({ - token: AI_WILL_PERFORM_MOVE_TOKEN, - value: { fen } - } satisfies MessageEventPayload<{ fen: string }>); - }); - - return () => { - subscription?.unsubscribe(); - }; - }, [players, workerThread]); - - return { - workerThread, - players, - init, - createPlayer, - removePlayer, - dispose - }; -}; diff --git a/apps/web/src/routes/play/_components/with-ai.component.tsx b/apps/web/src/routes/play/_components/with-ai.component.tsx index 9586ed4..26f780a 100644 --- a/apps/web/src/routes/play/_components/with-ai.component.tsx +++ b/apps/web/src/routes/play/_components/with-ai.component.tsx @@ -1,64 +1,185 @@ -import { Move } from "chess.js"; -import { FC, useEffect } from "react"; +import { SupportedAiModel } from "@chess-d/ai"; +import { ColorSide } from "@chess-d/shared"; +import { RegisterModule } from "@quick-threejs/reactive"; +import { Move, validateFen } from "chess.js"; +import { FC, useCallback, useEffect, useRef, useState } from "react"; +import { useSearchParams } from "react-router"; import { merge, Subscription } from "rxjs"; -import { EngineGameUpdatedMessageEventPayload } from "../../../shared/types"; -import { useAi } from "../../_hooks"; +import { PlayerModel } from "../../../shared/models"; +import { + AI_PERFORMED_MOVE_TOKEN, + AI_WILL_PERFORM_MOVE_TOKEN +} from "../../../shared/tokens"; +import { + EngineGameUpdatedMessageEventPayload, + MessageEventPayload +} from "../../../shared/types"; import { useGameStore } from "../../_stores"; +import { + GAME_UPDATED_TOKEN, + PIECE_WILL_MOVE_TOKEN +} from "../../../shared/tokens"; -export interface WithAIComponentProps { - performPieceMove: (move: Move) => void; - onGameUpdate: ( - callback: ( - payload: EngineGameUpdatedMessageEventPayload["value"] - ) => unknown - ) => void; -} - -export const WithAIComponent: FC = ({ - performPieceMove, - onGameUpdate -}) => { - const { app: gameApp } = useGameStore(); - const { - players: aiPlayers, - init: initAI, - createPlayer: createAIPlayer, - dispose: disposeAi - } = useAi(); +export interface WithAIComponentProps {} + +/** @internal */ +const workerLocation = new URL( + "../../../core/ai/ai.worker.ts", + import.meta.url +) as unknown as string; + +export const WithAIComponent: FC = () => { + const { app } = useGameStore(); + const [searchParams] = useSearchParams(); + + const state = useRef<{ + isPending: boolean; + isReady: boolean; + }>({ isPending: false, isReady: false }); + + const [workerThread, setWorkerThread] = useState< + | Awaited["run"]>> + | undefined + >(); + + const init = useCallback(async () => { + state.current.isPending = true; + + const _workerThread = await app?.workerPool()?.run?.({ + payload: { + path: workerLocation, + subject: {} + } + }); + + state.current.isPending = false; + state.current.isReady = !!_workerThread; + + setWorkerThread(_workerThread); + }, [app]); + + const dispose = useCallback(() => { + workerThread?.worker?.terminate(); + workerThread?.thread?.lifecycle$?.(); + setWorkerThread(undefined); + + state.current.isPending = false; + state.current.isReady = false; + }, [workerThread]); + + const performPieceMove = useCallback( + (move: Move) => { + app?.worker()?.postMessage?.({ + token: PIECE_WILL_MOVE_TOKEN, + value: move + } satisfies MessageEventPayload); + }, + [app] + ); useEffect(() => { - if (gameApp) { - initAI(gameApp.workerPool()); - createAIPlayer(); - } + const _state = state.current; + + if (app && !workerThread && !_state.isPending && !_state.isReady) init(); - return () => disposeAi(); - }, [createAIPlayer, disposeAi, gameApp, initAI]); + return () => { + if (workerThread && !_state.isPending && _state.isReady) dispose(); + }; + }, [app, dispose, init, workerThread]); useEffect(() => { - const players = [...aiPlayers]; + const appGui = app?.gui(); + const aiGui = appGui.addFolder("AI Controls"); - const playersSubscription: Subscription = merge(...players).subscribe( - (payload) => { - if (payload.token === "PLACED_PIECE" && payload.value?.move) - return performPieceMove(payload.value?.move); - } - ); + const players: PlayerModel[] = []; + + const searchedAIParam = searchParams.get("ai"); + const aiModel = + searchedAIParam !== null && SupportedAiModel[searchedAIParam] + ? searchedAIParam + : "zeyu"; + const aiPlayer = new PlayerModel(); + aiPlayer.id = SupportedAiModel[aiModel]; + aiPlayer.color = ColorSide.black; + + players.push(aiPlayer); + + if (import.meta.env?.DEV) { + const options = { ai: aiModel }; + aiGui + .add( + options, + "ai", + Object.keys(SupportedAiModel).filter((key) => isNaN(Number(key))) + ) + .onChange((value) => { + const aiModel = SupportedAiModel[value]; + aiPlayer.id = aiModel; + }) + .name("AI Model"); + } - onGameUpdate((payload) => { - console.log("Game updated", payload); + const handleMessages = ( + payload: MessageEvent + ) => { + if (!payload.data?.token) return; - players.forEach((player) => { - player?.next({ - token: "NOTIFIED", - value: payload + if (payload.data.token === GAME_UPDATED_TOKEN && payload.data?.value?.fen) + players.forEach((player) => { + player.next({ + token: "NOTIFIED", + value: payload.data.value + }); }); - }); + }; + + const aimPerformedMoveSubscription: Subscription | undefined = + workerThread?.thread + ?.movePerformed$() + ?.subscribe((message: MessageEventPayload<{ move: Move }>) => { + const { token, value } = message; + + if (token !== AI_PERFORMED_MOVE_TOKEN || !value) return; + + const { move } = value; + + if (!move || !validateFen(move?.after).ok) return; + + players + .find((player) => player.color === move.color) + ?.next({ + value: { + move + }, + token: "PLACED_PIECE" + }); + }); + + const playersSubscription = merge(...players).subscribe((payload) => { + const { token, value } = payload; + const { turn, fen, move } = value || {}; + const player = players.find((player) => player.color === turn); + + if (token === "NOTIFIED" && move && fen && player) + workerThread?.worker?.postMessage?.({ + token: AI_WILL_PERFORM_MOVE_TOKEN, + value: { fen, ai: player.id as SupportedAiModel } + } satisfies MessageEventPayload<{ fen: string; ai: SupportedAiModel }>); + + if (payload.token === "PLACED_PIECE" && payload.value?.move) + return performPieceMove(payload.value?.move); }); - return () => playersSubscription.unsubscribe(); - }, [aiPlayers, performPieceMove, onGameUpdate]); + app?.worker()?.addEventListener("message", handleMessages); + + return () => { + app?.worker()?.removeEventListener?.("message", handleMessages); + aimPerformedMoveSubscription?.unsubscribe?.(); + playersSubscription.unsubscribe(); + aiGui.destroy(); + }; + }, [app, performPieceMove, searchParams, workerThread]); return null; }; diff --git a/apps/web/src/routes/play/index.tsx b/apps/web/src/routes/play/index.tsx index 37c8a9a..e6d15ab 100644 --- a/apps/web/src/routes/play/index.tsx +++ b/apps/web/src/routes/play/index.tsx @@ -1,8 +1,12 @@ import { register, RegisterModule } from "@quick-threejs/reactive"; -import { FC, useCallback, useEffect, useRef } from "react"; +import { FC, useCallback, useEffect, useMemo, useRef } from "react"; import { useGameStore } from "../_stores"; import { FreeModeComponent } from "./_components/free-mode.component"; +import { getGameModeFromUrl } from "../../shared/utils"; +import { useSearchParams } from "react-router"; +import { GameMode } from "../../shared/enum"; +import { WithAIComponent } from "./_components/with-ai.component"; /** @internal */ const workerLocation = new URL( @@ -12,6 +16,11 @@ const workerLocation = new URL( export const PlayRoute: FC = () => { const { app, setApp } = useGameStore(); + const [searchParams] = useSearchParams(); + const gameMode = useMemo( + () => getGameModeFromUrl(searchParams), + [searchParams] + ); const state = useRef<{ isPending: boolean; @@ -52,6 +61,16 @@ export const PlayRoute: FC = () => { setApp(undefined); }, [app, setApp]); + const renderGameMode = useCallback(() => { + switch (gameMode) { + case GameMode.ai: + return ; + + default: + return ; + } + }, [gameMode]); + useEffect(() => { if (!app) init().then(setApp); @@ -62,5 +81,5 @@ export const PlayRoute: FC = () => { if (!app) return null; - return ; + return renderGameMode(); }; diff --git a/apps/web/src/shared/types/player.type.ts b/apps/web/src/shared/types/player.type.ts index 38668cf..a48a54b 100644 --- a/apps/web/src/shared/types/player.type.ts +++ b/apps/web/src/shared/types/player.type.ts @@ -1,5 +1,3 @@ -import { Move } from "chess.js"; - import { MessageEventPayload } from "./events.type"; import { EngineGameUpdatedMessageEventPayload } from "./engine.type"; diff --git a/apps/web/src/shared/utils/url.util.ts b/apps/web/src/shared/utils/url.util.ts index ee9a642..711c394 100644 --- a/apps/web/src/shared/utils/url.util.ts +++ b/apps/web/src/shared/utils/url.util.ts @@ -1,11 +1,11 @@ import { GameMode } from "../enum/game.enum"; -export const getGameModeFromUrl = (): GameMode | undefined => { +export const getGameModeFromUrl = ( + searchParams: URLSearchParams = new URL(window.location.href).searchParams +): GameMode | undefined => { if (window.location.pathname !== "/play") return undefined; - const mode = new URL(window.location.href).searchParams?.get("mode") as - | keyof typeof GameMode - | undefined; + const mode = searchParams?.get("mode") as keyof typeof GameMode | undefined; if (!mode || typeof GameMode[mode] === "undefined") return GameMode.free;