diff --git a/src/components/Board/GameBoard.tsx b/src/components/Board/GameBoard.tsx index 57f17975..0be0a1dc 100644 --- a/src/components/Board/GameBoard.tsx +++ b/src/components/Board/GameBoard.tsx @@ -28,12 +28,17 @@ interface Props { availableMoves?: Map onSelectSquare?: (square: Key) => void onPlayerMakeMove?: (move: [string, string]) => void + onSetPremove?: (move: [string, string]) => void + onUnsetPremove?: () => void setCurrentSquare?: Dispatch> shapes?: DrawShape[] brushes?: DrawBrushes goToNode?: (node: GameNode) => void gameTree?: any destinationBadges?: DestinationBadge[] + movableColor?: Color | 'both' + premovesEnabled?: boolean + premoveResetKey?: number } const getBoardSquarePosition = (square: string, orientation: Color) => { @@ -64,12 +69,17 @@ export const GameBoard: React.FC = ({ orientation = 'white', availableMoves, onPlayerMakeMove, + onSetPremove, + onUnsetPremove, setCurrentSquare, onSelectSquare, destinationBadges = [], + movableColor = 'both', + premovesEnabled = false, + premoveResetKey = 0, }: Props) => { const { playMoveSound } = useSound() - const boardInstanceKey = game?.id ?? 'board' + const boardInstanceKey = `${game?.id ?? 'board'}-${premoveResetKey}` const after = useCallback( (from: string, to: string) => { @@ -146,6 +156,9 @@ export const GameBoard: React.FC = ({ ? ((currentNode.turn === 'w' ? 'white' : 'black') as 'white' | 'black') : false, orientation: orientation as 'white' | 'black', + turnColor: (currentNode.turn === 'w' ? 'white' : 'black') as + | 'white' + | 'black', } }, [currentNode, game, orientation]) @@ -157,11 +170,23 @@ export const GameBoard: React.FC = ({ config={{ movable: { free: false, + color: movableColor, dests: availableMoves as any, events: { after, }, }, + premovable: { + enabled: premovesEnabled, + events: { + set: (from, to) => { + onSetPremove && onSetPremove([from, to]) + }, + unset: () => { + onUnsetPremove && onUnsetPremove() + }, + }, + }, events: { select: (key) => { onSelectSquare && onSelectSquare(key) @@ -176,6 +201,7 @@ export const GameBoard: React.FC = ({ lastMove: boardConfig.lastMove as Key[], check: boardConfig.check as boolean | 'white' | 'black' | undefined, orientation: boardConfig.orientation, + turnColor: boardConfig.turnColor, }} /> {destinationBadges.length > 0 ? ( diff --git a/src/components/Board/GameplayInterface.tsx b/src/components/Board/GameplayInterface.tsx index f7ed8139..0b12c953 100644 --- a/src/components/Board/GameplayInterface.tsx +++ b/src/components/Board/GameplayInterface.tsx @@ -42,12 +42,17 @@ export const GameplayInterface: React.FC> = ( currentNode, setCurrentNode, maiaVersion, + playerActive, goToRootNode, goToNextNode, availableMoves, makePlayerMove, setOrientation, goToPreviousNode, + premovesEnabled, + setPremove, + clearPremove, + premoveResetKey, } = useContext(PlayControllerContext) const { user } = useContext(AuthContext) @@ -70,15 +75,16 @@ export const GameplayInterface: React.FC> = ( if (matching.length > 1) { // Multiple matching moves (i.e. promotion) + clearPremove() setPromotionFromTo(move) - } else { + } else if (matching[0]) { const moveUci = matching[0].from + matching[0].to + (matching[0].promotion ?? '') makePlayerMove(moveUci) } } }, - [availableMoves, makePlayerMove, setPromotionFromTo], + [availableMoves, clearPremove, makePlayerMove, setPromotionFromTo], ) const onPlayerSelectPromotion = useCallback( @@ -207,9 +213,16 @@ export const GameplayInterface: React.FC> = ( game={game} availableMoves={availableMovesMapped} onPlayerMakeMove={onPlayerMakeMove} + onSetPremove={([from, to]) => setPremove(from, to)} + onUnsetPremove={clearPremove} shapes={props.boardShapes} currentNode={currentNode} orientation={orientation} + movableColor={player} + premovesEnabled={ + premovesEnabled && !game.termination && !playerActive + } + premoveResetKey={premoveResetKey} /> {promotionFromTo ? ( > = ( game={game} availableMoves={availableMovesMapped} onPlayerMakeMove={onPlayerMakeMove} + onSetPremove={([from, to]) => setPremove(from, to)} + onUnsetPremove={clearPremove} shapes={props.boardShapes} currentNode={currentNode} orientation={orientation} + movableColor={player} + premovesEnabled={ + premovesEnabled && !game.termination && !playerActive + } + premoveResetKey={premoveResetKey} /> {promotionFromTo ? ( ['reset'] makePlayerMove: ReturnType['makePlayerMove'] updateClock: ReturnType['updateClock'] + updateClockForColor: ReturnType< + typeof usePlayController + >['updateClockForColor'] setCurrentNode: ReturnType['setCurrentNode'] addMoveWithTime: ReturnType['addMoveWithTime'] + premovesEnabled: ReturnType['premovesEnabled'] + queuedPremove: ReturnType['queuedPremove'] + setPremove: ReturnType['setPremove'] + clearPremove: ReturnType['clearPremove'] + premoveResetKey: ReturnType['premoveResetKey'] } const fn = () => { @@ -62,6 +70,7 @@ export const PlayControllerContext = reset: fn, makePlayerMove: fn, updateClock: fn, + updateClockForColor: fn, gameTree: defaultGameTree, currentNode: defaultGameTree.getRoot(), setCurrentNode: fn, @@ -70,4 +79,9 @@ export const PlayControllerContext = goToPreviousNode: fn, goToRootNode: fn, addMoveWithTime: fn, + premovesEnabled: false, + queuedPremove: null, + setPremove: fn, + clearPremove: fn, + premoveResetKey: 0, }) diff --git a/src/hooks/usePlayController/usePlayController.ts b/src/hooks/usePlayController/usePlayController.ts index 22ba0ebd..6762b2da 100644 --- a/src/hooks/usePlayController/usePlayController.ts +++ b/src/hooks/usePlayController/usePlayController.ts @@ -1,6 +1,6 @@ import { Color, Check, GameTree, Termination, PlayGameConfig } from 'src/types' import { AllStats } from '../useStats' -import { PlayedGame } from 'src/types/play' +import { PlayedGame, QueuedPremove } from 'src/types/play' import { Chess, Piece, SQUARES } from 'chess.ts' import { useTreeController } from '../useTreeController' import { useMemo, useState, useCallback, useEffect } from 'react' @@ -165,9 +165,13 @@ export const usePlayController = (id: string, config: PlayGameConfig) => { return { availableMoves, pieces } }, [controller.currentNode, playerActive, game.termination, treeVersion]) - const updateClock = useCallback( - (overrideTime: number | undefined = undefined): number => { - if (moveList.length < 2) { + const updateClockForColor = useCallback( + ( + color: Color, + overrideTime: number | undefined = undefined, + forceClockUpdate = false, + ): number => { + if (moveList.length < 2 && !forceClockUpdate) { return 0 // Clock does not start until first two moves made } @@ -176,7 +180,7 @@ export const usePlayController = (id: string, config: PlayGameConfig) => { overrideTime === undefined ? now - lastMoveTime : overrideTime if (lastMoveTime > 0) { - if (toPlay == 'white') { + if (color == 'white') { setWhiteClock( Math.max(whiteClock - elapsed + incrementSeconds * 1000, 0), ) @@ -190,14 +194,18 @@ export const usePlayController = (id: string, config: PlayGameConfig) => { setLastMoveTime(now) return elapsed }, - [ - moveList.length, - lastMoveTime, - toPlay, - whiteClock, - blackClock, - incrementSeconds, - ], + [moveList.length, lastMoveTime, whiteClock, blackClock, incrementSeconds], + ) + + const updateClock = useCallback( + (overrideTime: number | undefined = undefined): number => { + if (!toPlay) { + return 0 + } + + return updateClockForColor(toPlay, overrideTime) + }, + [toPlay, updateClockForColor], ) const expireOnTime = useCallback((color: Color) => { @@ -275,12 +283,23 @@ export const usePlayController = (id: string, config: PlayGameConfig) => { setTreeVersion((prev) => prev + 1) } - const makePlayerMove = async (moveUci: string): Promise => { + const makePlayerMove = async ( + _moveUci: string, + _moveTimeOverride?: number, + ): Promise => { throw new Error( 'makePlayerMove should be overridden by the consuming component', ) } + const queuedPremove = null as QueuedPremove | null + const setPremove = (_from: string, _to: string) => { + return + } + const clearPremove = () => { + return + } + const stats: AllStats = { lifetime: undefined, session: { gamesWon: 0, gamesPlayed: 0 }, @@ -322,5 +341,11 @@ export const usePlayController = (id: string, config: PlayGameConfig) => { reset, makePlayerMove, updateClock, + updateClockForColor, + premovesEnabled: false, + queuedPremove, + setPremove, + clearPremove, + premoveResetKey: 0, } } diff --git a/src/hooks/usePlayController/useVsMaiaController.ts b/src/hooks/usePlayController/useVsMaiaController.ts index e81bb731..ff1f373c 100644 --- a/src/hooks/usePlayController/useVsMaiaController.ts +++ b/src/hooks/usePlayController/useVsMaiaController.ts @@ -1,6 +1,6 @@ -import { useEffect } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Chess } from 'chess.ts' -import { PlayGameConfig } from 'src/types' +import { PlayGameConfig, QueuedPremove } from 'src/types' import { backOff } from 'exponential-backoff' import { useStats } from 'src/hooks/useStats' import { usePlayController } from 'src/hooks/usePlayController' @@ -17,6 +17,8 @@ const playStatsLoader = async () => { } } +const PREMOVE_SOUND_DELAY_MS = 120 + export const useVsMaiaPlayController = ( id: string, playGameConfig: PlayGameConfig, @@ -25,9 +27,52 @@ export const useVsMaiaPlayController = ( const controller = usePlayController(id, playGameConfig) const [stats, incrementStats, updateRating] = useStats(playStatsLoader) const { playMoveSound } = useSound() + const [queuedPremove, setQueuedPremove] = useState(null) + const queuedPremoveRef = useRef(null) + const [premoveResetKey, setPremoveResetKey] = useState(0) + + const clearPremove = useCallback(() => { + if (!queuedPremoveRef.current) { + return + } + + queuedPremoveRef.current = null + setQueuedPremove(null) + setPremoveResetKey((prev) => prev + 1) + }, []) + + const setPremove = useCallback((from: string, to: string) => { + const nextPremove = { from, to } + queuedPremoveRef.current = nextPremove + setQueuedPremove(nextPremove) + }, []) + + const getLegalPremoveUci = useCallback( + (fen: string, premove: QueuedPremove): string | null => { + const chess = new Chess(fen) + const matchingMoves = chess + .moves({ verbose: true }) + .filter((move) => move.from === premove.from && move.to === premove.to) - const makePlayerMove = async (moveUci: string) => { - const moveTime = controller.updateClock() + if (matchingMoves.length === 0) { + return null + } + + const matchingPromotion = + matchingMoves.find((move) => move.promotion === 'q') || matchingMoves[0] + + return ( + matchingPromotion.from + + matchingPromotion.to + + (matchingPromotion.promotion || '') + ) + }, + [], + ) + + const makePlayerMove = async (moveUci: string, moveTimeOverride?: number) => { + const moveTime = moveTimeOverride ?? controller.updateClock() + clearPremove() controller.addMoveWithTime(moveUci, moveTime) } @@ -79,24 +124,66 @@ export const useVsMaiaPlayController = ( return } - const moveTime = controller.updateClock() - const chess = new Chess(controller.currentNode.fen) + const moveTime = controller.updateClock() const destinationSquare = nextMove.slice(2, 4) const isCapture = !!chess.get(destinationSquare) + const moveResult = chess.move(nextMove, { sloppy: true }) controller.addMoveWithTime(nextMove, moveTime) playMoveSound(isCapture) + + const legalPremoveUci = + moveResult && queuedPremoveRef.current + ? getLegalPremoveUci(chess.fen(), queuedPremoveRef.current) + : null + + if (queuedPremoveRef.current) { + clearPremove() + } + + if (legalPremoveUci) { + const premoveDestination = legalPremoveUci.slice(2, 4) + const isPremoveCapture = !!chess.get(premoveDestination) + + controller.updateClockForColor(controller.player, 0, true) + controller.addMoveWithTime(legalPremoveUci, 0) + setTimeout( + () => playMoveSound(isPremoveCapture), + PREMOVE_SOUND_DELAY_MS, + ) + } }, delayMs) } else { - const moveTime = controller.updateClock() - const chess = new Chess(controller.currentNode.fen) + const moveTime = controller.updateClock() const destinationSquare = nextMove.slice(2, 4) const isCapture = !!chess.get(destinationSquare) + const moveResult = chess.move(nextMove, { sloppy: true }) controller.addMoveWithTime(nextMove, moveTime) playMoveSound(isCapture) + + const legalPremoveUci = + moveResult && queuedPremoveRef.current + ? getLegalPremoveUci(chess.fen(), queuedPremoveRef.current) + : null + + if (queuedPremoveRef.current) { + clearPremove() + } + + if (legalPremoveUci) { + const premoveDestination = legalPremoveUci.slice(2, 4) + const isPremoveCapture = !!chess.get(premoveDestination) + + controller.updateClockForColor(controller.player, 0, true) + controller.addMoveWithTime(legalPremoveUci, 0) + setTimeout( + () => playMoveSound(isPremoveCapture), + PREMOVE_SOUND_DELAY_MS, + ) + } } } } @@ -118,6 +205,8 @@ export const useVsMaiaPlayController = ( playGameConfig.maiaVersion, playGameConfig.startFen, simulateMaiaTime, + clearPremove, + getLegalPremoveUci, ]) useEffect(() => { @@ -167,6 +256,11 @@ export const useVsMaiaPlayController = ( return { ...controller, makePlayerMove, + premovesEnabled: true, + queuedPremove, + setPremove, + clearPremove, + premoveResetKey, stats, } } diff --git a/src/types/play.ts b/src/types/play.ts index b0bb22b6..bcb5ea67 100644 --- a/src/types/play.ts +++ b/src/types/play.ts @@ -38,3 +38,8 @@ export interface AvailableMove { to: string promotion?: string } + +export interface QueuedPremove { + from: string + to: string +}